mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-01 05:19:19 -05:00
Compare commits
2 Commits
feat/plugi
...
feat/lyric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6920ce9f6e | ||
|
|
4a53650981 |
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@@ -103,7 +104,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||||
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager {
|
|||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
@@ -44,6 +45,7 @@ var allProviders = wire.NewSet(
|
|||||||
plugins.GetManager,
|
plugins.GetManager,
|
||||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||||
|
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||||
|
|||||||
@@ -9,7 +9,29 @@ import (
|
|||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
// Lyrics can fetch lyrics for a media file.
|
||||||
|
type Lyrics interface {
|
||||||
|
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginLoader discovers and loads lyrics provider plugins.
|
||||||
|
type PluginLoader interface {
|
||||||
|
LoadLyricsProvider(name string) (Lyrics, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsService struct {
|
||||||
|
pluginLoader PluginLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
||||||
|
// system is available.
|
||||||
|
func NewLyrics(pluginLoader PluginLoader) Lyrics {
|
||||||
|
return &lyricsService{pluginLoader: pluginLoader}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyrics returns lyrics for the given media file, trying sources in the
|
||||||
|
// order specified by conf.Server.LyricsPriority.
|
||||||
|
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||||
var lyricsList model.LyricList
|
var lyricsList model.LyricList
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -21,11 +43,11 @@ func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error
|
|||||||
case strings.HasPrefix(pattern, "."):
|
case strings.HasPrefix(pattern, "."):
|
||||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||||
default:
|
default:
|
||||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(lyricsList) > 0 {
|
if len(lyricsList) > 0 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package lyrics_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
|
|||||||
|
|
||||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||||
conf.Server.LyricsPriority = priority
|
conf.Server.LyricsPriority = priority
|
||||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
svc := lyrics.NewLyrics(nil)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(list).To(Equal(expected))
|
Expect(list).To(Equal(expected))
|
||||||
},
|
},
|
||||||
@@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
|
|||||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||||
|
|
||||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
svc := lyrics.NewLyrics(nil)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(list).To(Equal(embeddedLyrics))
|
Expect(list).To(Equal(embeddedLyrics))
|
||||||
})
|
})
|
||||||
@@ -115,10 +118,94 @@ var _ = Describe("sources", func() {
|
|||||||
It("should return nothing if error happens when trying to parse file", func() {
|
It("should return nothing if error happens when trying to parse file", func() {
|
||||||
conf.Server.LyricsPriority = ".mp3"
|
conf.Server.LyricsPriority = ".mp3"
|
||||||
|
|
||||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
svc := lyrics.NewLyrics(nil)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(list).To(BeEmpty())
|
Expect(list).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("plugin sources", func() {
|
||||||
|
var mockLoader *mockPluginLoader
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
mockLoader = &mockPluginLoader{}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return lyrics from a plugin", func() {
|
||||||
|
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||||
|
mockLoader.lyrics = unsyncedLyrics
|
||||||
|
svc := lyrics.NewLyrics(mockLoader)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(list).To(Equal(unsyncedLyrics))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should try plugin after embedded returns nothing", func() {
|
||||||
|
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||||
|
mf.Lyrics = "" // No embedded lyrics
|
||||||
|
mockLoader.lyrics = unsyncedLyrics
|
||||||
|
svc := lyrics.NewLyrics(mockLoader)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(list).To(Equal(unsyncedLyrics))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should skip plugin if embedded has lyrics", func() {
|
||||||
|
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||||
|
mockLoader.lyrics = unsyncedLyrics
|
||||||
|
svc := lyrics.NewLyrics(mockLoader)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should skip unknown plugin names gracefully", func() {
|
||||||
|
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||||
|
mockLoader.notFound = true
|
||||||
|
svc := lyrics.NewLyrics(mockLoader)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle plugin error gracefully", func() {
|
||||||
|
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||||
|
mockLoader.err = fmt.Errorf("plugin error")
|
||||||
|
svc := lyrics.NewLyrics(mockLoader)
|
||||||
|
list, err := svc.GetLyrics(ctx, &mf)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type mockPluginLoader struct {
|
||||||
|
lyrics model.LyricList
|
||||||
|
err error
|
||||||
|
notFound bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPluginLoader) PluginNames(_ string) []string {
|
||||||
|
if m.notFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"test-lyrics-plugin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPluginLoader) LoadLyricsProvider(_ string) (lyrics.Lyrics, bool) {
|
||||||
|
if m.notFound {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockLyricsProvider struct {
|
||||||
|
lyrics model.LyricList
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
|
||||||
|
return m.lyrics, m.err
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
|||||||
|
|
||||||
return model.LyricList{*lyrics}, nil
|
return model.LyricList{*lyrics}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||||
|
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
|
||||||
|
if l.pluginLoader == nil {
|
||||||
|
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
|
||||||
|
if !ok {
|
||||||
|
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsList, err := provider.GetLyrics(ctx, mf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lyricsList) > 0 {
|
||||||
|
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||||
|
}
|
||||||
|
return lyricsList, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@@ -28,4 +29,5 @@ var Set = wire.NewSet(
|
|||||||
scrobbler.GetPlayTracker,
|
scrobbler.GetPlayTracker,
|
||||||
playback.GetInstance,
|
playback.GetInstance,
|
||||||
metrics.GetInstance,
|
metrics.GetInstance,
|
||||||
|
lyrics.NewLyrics,
|
||||||
)
|
)
|
||||||
|
|||||||
26
plugins/capabilities/lyrics.go
Normal file
26
plugins/capabilities/lyrics.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package capabilities
|
||||||
|
|
||||||
|
// Lyrics provides lyrics for a given track from external sources.
|
||||||
|
//
|
||||||
|
//nd:capability name=lyrics required=true
|
||||||
|
type Lyrics interface {
|
||||||
|
//nd:export name=nd_lyrics_get_lyrics
|
||||||
|
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||||
|
type GetLyricsRequest struct {
|
||||||
|
Track TrackInfo `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||||
|
type GetLyricsResponse struct {
|
||||||
|
Lyrics []LyricsText `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LyricsText represents a single set of lyrics in raw text format.
|
||||||
|
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||||
|
type LyricsText struct {
|
||||||
|
Lang string `json:"lang,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
115
plugins/capabilities/lyrics.yaml
Normal file
115
plugins/capabilities/lyrics.yaml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
version: v1-draft
|
||||||
|
exports:
|
||||||
|
nd_lyrics_get_lyrics:
|
||||||
|
input:
|
||||||
|
$ref: '#/components/schemas/GetLyricsRequest'
|
||||||
|
contentType: application/json
|
||||||
|
output:
|
||||||
|
$ref: '#/components/schemas/GetLyricsResponse'
|
||||||
|
contentType: application/json
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ArtistRef:
|
||||||
|
description: ArtistRef is a reference to an artist with name and optional MBID.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome artist ID (if known).
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name is the artist name.
|
||||||
|
mbid:
|
||||||
|
type: string
|
||||||
|
description: MBID is the MusicBrainz ID for the artist.
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
GetLyricsRequest:
|
||||||
|
description: GetLyricsRequest contains the track information for lyrics lookup.
|
||||||
|
properties:
|
||||||
|
track:
|
||||||
|
$ref: '#/components/schemas/TrackInfo'
|
||||||
|
required:
|
||||||
|
- track
|
||||||
|
GetLyricsResponse:
|
||||||
|
description: GetLyricsResponse contains the lyrics returned by the plugin.
|
||||||
|
properties:
|
||||||
|
lyrics:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LyricsText'
|
||||||
|
required:
|
||||||
|
- lyrics
|
||||||
|
LyricsText:
|
||||||
|
description: |-
|
||||||
|
LyricsText represents a single set of lyrics in raw text format.
|
||||||
|
Text can be plain text or LRC format — Navidrome will parse it.
|
||||||
|
properties:
|
||||||
|
lang:
|
||||||
|
type: string
|
||||||
|
text:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- text
|
||||||
|
TrackInfo:
|
||||||
|
description: TrackInfo contains track metadata for scrobbling.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome track ID.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Title is the track title.
|
||||||
|
album:
|
||||||
|
type: string
|
||||||
|
description: Album is the album name.
|
||||||
|
artist:
|
||||||
|
type: string
|
||||||
|
description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||||
|
albumArtist:
|
||||||
|
type: string
|
||||||
|
description: AlbumArtist is the formatted album artist name for display.
|
||||||
|
artists:
|
||||||
|
type: array
|
||||||
|
description: Artists is the list of track artists.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ArtistRef'
|
||||||
|
albumArtists:
|
||||||
|
type: array
|
||||||
|
description: AlbumArtists is the list of album artists.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ArtistRef'
|
||||||
|
duration:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
description: Duration is the track duration in seconds.
|
||||||
|
trackNumber:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: TrackNumber is the track number on the album.
|
||||||
|
discNumber:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: DiscNumber is the disc number.
|
||||||
|
mbzRecordingId:
|
||||||
|
type: string
|
||||||
|
description: MBZRecordingID is the MusicBrainz recording ID.
|
||||||
|
mbzAlbumId:
|
||||||
|
type: string
|
||||||
|
description: MBZAlbumID is the MusicBrainz album/release ID.
|
||||||
|
mbzReleaseGroupId:
|
||||||
|
type: string
|
||||||
|
description: MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||||
|
mbzReleaseTrackId:
|
||||||
|
type: string
|
||||||
|
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- title
|
||||||
|
- album
|
||||||
|
- artist
|
||||||
|
- albumArtist
|
||||||
|
- artists
|
||||||
|
- albumArtists
|
||||||
|
- duration
|
||||||
|
- trackNumber
|
||||||
|
- discNumber
|
||||||
55
plugins/lyrics_adapter.go
Normal file
55
plugins/lyrics_adapter.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CapabilityLyrics Capability = "Lyrics"
|
||||||
|
|
||||||
|
const (
|
||||||
|
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerCapability(
|
||||||
|
CapabilityLyrics,
|
||||||
|
FuncLyricsGetLyrics,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||||
|
type LyricsPlugin struct {
|
||||||
|
name string
|
||||||
|
plugin *plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses
|
||||||
|
// using model.ToLyrics.
|
||||||
|
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||||
|
req := capabilities.GetLyricsRequest{
|
||||||
|
Track: mediaFileToTrackInfo(mf),
|
||||||
|
}
|
||||||
|
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||||
|
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result model.LyricList
|
||||||
|
for _, lt := range resp.Lyrics {
|
||||||
|
parsed, err := model.ToLyrics(lt.Lang, lt.Text)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parsed != nil && !parsed.IsEmpty() {
|
||||||
|
result = append(result, *parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
84
plugins/lyrics_adapter_test.go
Normal file
84
plugins/lyrics_adapter_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||||
|
var (
|
||||||
|
lyricsManager *Manager
|
||||||
|
provider *LyricsPlugin
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeAll(func() {
|
||||||
|
lyricsManager, _ = createTestManagerWithPlugins(nil,
|
||||||
|
"test-lyrics"+PackageExtension,
|
||||||
|
"test-metadata-agent"+PackageExtension,
|
||||||
|
)
|
||||||
|
|
||||||
|
p, ok := lyricsManager.LoadLyricsProvider("test-lyrics")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
provider = p.(*LyricsPlugin)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("LoadLyricsProvider", func() {
|
||||||
|
It("returns a lyrics provider for a plugin with Lyrics capability", func() {
|
||||||
|
Expect(provider).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for a plugin without Lyrics capability", func() {
|
||||||
|
_, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent")
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for non-existent plugin", func() {
|
||||||
|
_, ok := lyricsManager.LoadLyricsProvider("non-existent")
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetLyrics", func() {
|
||||||
|
It("successfully returns lyrics from the plugin", func() {
|
||||||
|
track := &model.MediaFile{
|
||||||
|
ID: "track-1",
|
||||||
|
Title: "Test Song",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.GetLyrics(GinkgoT().Context(), track)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result).To(HaveLen(1))
|
||||||
|
Expect(result[0].Line).ToNot(BeEmpty())
|
||||||
|
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when plugin returns error", func() {
|
||||||
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||||
|
"test-lyrics": {"error": "service unavailable"},
|
||||||
|
}, "test-lyrics"+PackageExtension)
|
||||||
|
|
||||||
|
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
|
||||||
|
_, err := p.GetLyrics(GinkgoT().Context(), track)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("PluginNames", func() {
|
||||||
|
It("returns plugin names with Lyrics capability", func() {
|
||||||
|
names := lyricsManager.PluginNames("Lyrics")
|
||||||
|
Expect(names).To(ContainElement("test-lyrics"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not return metadata agent plugins for Lyrics capability", func() {
|
||||||
|
names := lyricsManager.PluginNames("Lyrics")
|
||||||
|
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
extism "github.com/extism/go-sdk"
|
extism "github.com/extism/go-sdk"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@@ -276,6 +277,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
|
||||||
|
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
plugin, ok := m.plugins[name]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LyricsPlugin{
|
||||||
|
name: plugin.name,
|
||||||
|
plugin: plugin,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
// PluginInfo contains basic information about a plugin for metrics/insights.
|
// PluginInfo contains basic information about a plugin for metrics/insights.
|
||||||
type PluginInfo struct {
|
type PluginInfo struct {
|
||||||
Name string
|
Name string
|
||||||
|
|||||||
118
plugins/pdk/go/lyrics/lyrics.go
Normal file
118
plugins/pdk/go/lyrics/lyrics.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains export wrappers for the Lyrics capability.
|
||||||
|
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||||
|
//
|
||||||
|
//go:build wasip1
|
||||||
|
|
||||||
|
package lyrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||||
|
type ArtistRef struct {
|
||||||
|
// ID is the internal Navidrome artist ID (if known).
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
// Name is the artist name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// MBID is the MusicBrainz ID for the artist.
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||||
|
type GetLyricsRequest struct {
|
||||||
|
Track TrackInfo `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||||
|
type GetLyricsResponse struct {
|
||||||
|
Lyrics []LyricsText `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LyricsText represents a single set of lyrics in raw text format.
|
||||||
|
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||||
|
type LyricsText struct {
|
||||||
|
Lang string `json:"lang,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackInfo contains track metadata for scrobbling.
|
||||||
|
type TrackInfo struct {
|
||||||
|
// ID is the internal Navidrome track ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Title is the track title.
|
||||||
|
Title string `json:"title"`
|
||||||
|
// Album is the album name.
|
||||||
|
Album string `json:"album"`
|
||||||
|
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// AlbumArtist is the formatted album artist name for display.
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
// Artists is the list of track artists.
|
||||||
|
Artists []ArtistRef `json:"artists"`
|
||||||
|
// AlbumArtists is the list of album artists.
|
||||||
|
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||||
|
// Duration is the track duration in seconds.
|
||||||
|
Duration float32 `json:"duration"`
|
||||||
|
// TrackNumber is the track number on the album.
|
||||||
|
TrackNumber int32 `json:"trackNumber"`
|
||||||
|
// DiscNumber is the disc number.
|
||||||
|
DiscNumber int32 `json:"discNumber"`
|
||||||
|
// MBZRecordingID is the MusicBrainz recording ID.
|
||||||
|
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||||
|
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||||
|
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||||
|
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||||
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics requires all methods to be implemented.
|
||||||
|
// Lyrics provides lyrics for a given track from external sources.
|
||||||
|
type Lyrics interface {
|
||||||
|
// GetLyrics
|
||||||
|
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||||
|
} // Internal implementation holders
|
||||||
|
var (
|
||||||
|
lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register registers a lyrics implementation.
|
||||||
|
// All methods are required.
|
||||||
|
func Register(impl Lyrics) {
|
||||||
|
lyricsImpl = impl.GetLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
|
// The host recognizes this and skips the plugin gracefully.
|
||||||
|
const NotImplementedCode int32 = -2
|
||||||
|
|
||||||
|
//go:wasmexport nd_lyrics_get_lyrics
|
||||||
|
func _NdLyricsGetLyrics() int32 {
|
||||||
|
if lyricsImpl == nil {
|
||||||
|
// Return standard code - host will skip this plugin gracefully
|
||||||
|
return NotImplementedCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var input GetLyricsRequest
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := lyricsImpl(input)
|
||||||
|
if err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal file
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file provides stub implementations for non-WASM platforms.
|
||||||
|
// It allows Go plugins to compile and run tests outside of WASM,
|
||||||
|
// but the actual functionality is only available in WASM builds.
|
||||||
|
//
|
||||||
|
//go:build !wasip1
|
||||||
|
|
||||||
|
package lyrics
|
||||||
|
|
||||||
|
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||||
|
type ArtistRef struct {
|
||||||
|
// ID is the internal Navidrome artist ID (if known).
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
// Name is the artist name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// MBID is the MusicBrainz ID for the artist.
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||||
|
type GetLyricsRequest struct {
|
||||||
|
Track TrackInfo `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||||
|
type GetLyricsResponse struct {
|
||||||
|
Lyrics []LyricsText `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LyricsText represents a single set of lyrics in raw text format.
|
||||||
|
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||||
|
type LyricsText struct {
|
||||||
|
Lang string `json:"lang,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackInfo contains track metadata for scrobbling.
|
||||||
|
type TrackInfo struct {
|
||||||
|
// ID is the internal Navidrome track ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Title is the track title.
|
||||||
|
Title string `json:"title"`
|
||||||
|
// Album is the album name.
|
||||||
|
Album string `json:"album"`
|
||||||
|
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// AlbumArtist is the formatted album artist name for display.
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
// Artists is the list of track artists.
|
||||||
|
Artists []ArtistRef `json:"artists"`
|
||||||
|
// AlbumArtists is the list of album artists.
|
||||||
|
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||||
|
// Duration is the track duration in seconds.
|
||||||
|
Duration float32 `json:"duration"`
|
||||||
|
// TrackNumber is the track number on the album.
|
||||||
|
TrackNumber int32 `json:"trackNumber"`
|
||||||
|
// DiscNumber is the disc number.
|
||||||
|
DiscNumber int32 `json:"discNumber"`
|
||||||
|
// MBZRecordingID is the MusicBrainz recording ID.
|
||||||
|
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||||
|
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||||
|
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||||
|
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||||
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics requires all methods to be implemented.
|
||||||
|
// Lyrics provides lyrics for a given track from external sources.
|
||||||
|
type Lyrics interface {
|
||||||
|
// GetLyrics
|
||||||
|
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
|
const NotImplementedCode int32 = -2
|
||||||
|
|
||||||
|
// Register is a no-op on non-WASM platforms.
|
||||||
|
// This stub allows code to compile outside of WASM.
|
||||||
|
func Register(_ Lyrics) {}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//! for implementing Navidrome plugin capabilities in Rust.
|
//! for implementing Navidrome plugin capabilities in Rust.
|
||||||
|
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
|
pub mod lyrics;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod scrobbler;
|
pub mod scrobbler;
|
||||||
|
|||||||
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal file
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Code generated by ndpgen. DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// This file contains export wrappers for the Lyrics capability.
|
||||||
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
|
/// ArtistRef is a reference to an artist with name and optional MBID.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArtistRef {
|
||||||
|
/// ID is the internal Navidrome artist ID (if known).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub id: String,
|
||||||
|
/// Name is the artist name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// MBID is the MusicBrainz ID for the artist.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbid: String,
|
||||||
|
}
|
||||||
|
/// GetLyricsRequest contains the track information for lyrics lookup.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetLyricsRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub track: TrackInfo,
|
||||||
|
}
|
||||||
|
/// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetLyricsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub lyrics: Vec<LyricsText>,
|
||||||
|
}
|
||||||
|
/// LyricsText represents a single set of lyrics in raw text format.
|
||||||
|
/// Text can be plain text or LRC format — Navidrome will parse it.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LyricsText {
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub lang: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
/// TrackInfo contains track metadata for scrobbling.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TrackInfo {
|
||||||
|
/// ID is the internal Navidrome track ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
/// Title is the track title.
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
/// Album is the album name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub album: String,
|
||||||
|
/// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
/// AlbumArtist is the formatted album artist name for display.
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_artist: String,
|
||||||
|
/// Artists is the list of track artists.
|
||||||
|
#[serde(default)]
|
||||||
|
pub artists: Vec<ArtistRef>,
|
||||||
|
/// AlbumArtists is the list of album artists.
|
||||||
|
#[serde(default)]
|
||||||
|
pub album_artists: Vec<ArtistRef>,
|
||||||
|
/// Duration is the track duration in seconds.
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration: f32,
|
||||||
|
/// TrackNumber is the track number on the album.
|
||||||
|
#[serde(default)]
|
||||||
|
pub track_number: i32,
|
||||||
|
/// DiscNumber is the disc number.
|
||||||
|
#[serde(default)]
|
||||||
|
pub disc_number: i32,
|
||||||
|
/// MBZRecordingID is the MusicBrainz recording ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbz_recording_id: String,
|
||||||
|
/// MBZAlbumID is the MusicBrainz album/release ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbz_album_id: String,
|
||||||
|
/// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbz_release_group_id: String,
|
||||||
|
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbz_release_track_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error represents an error from a capability method.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self { message: message.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lyrics requires all methods to be implemented.
|
||||||
|
/// Lyrics provides lyrics for a given track from external sources.
|
||||||
|
pub trait Lyrics {
|
||||||
|
/// GetLyrics
|
||||||
|
fn get_lyrics(&self, req: GetLyricsRequest) -> Result<GetLyricsResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register all exports for the Lyrics capability.
|
||||||
|
/// This macro generates the WASM export functions for all trait methods.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! register_lyrics {
|
||||||
|
($plugin_type:ty) => {
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn nd_lyrics_get_lyrics(
|
||||||
|
req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest>
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::lyrics::GetLyricsResponse>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?;
|
||||||
|
Ok(extism_pdk::Json(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
16
plugins/testdata/test-lyrics/go.mod
vendored
Normal file
16
plugins/testdata/test-lyrics/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module test-lyrics
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/extism/go-pdk v1.1.3 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||||
14
plugins/testdata/test-lyrics/go.sum
vendored
Normal file
14
plugins/testdata/test-lyrics/go.sum
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||||
|
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
35
plugins/testdata/test-lyrics/main.go
vendored
Normal file
35
plugins/testdata/test-lyrics/main.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Test lyrics plugin for Navidrome plugin system integration tests.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/lyrics"
|
||||||
|
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
lyrics.Register(&testLyrics{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testLyrics struct{}
|
||||||
|
|
||||||
|
func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) {
|
||||||
|
// Check for configured error
|
||||||
|
errMsg, hasErr := pdk.GetConfig("error")
|
||||||
|
if hasErr && errMsg != "" {
|
||||||
|
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return test lyrics based on track info
|
||||||
|
return lyrics.GetLyricsResponse{
|
||||||
|
Lyrics: []lyrics.LyricsText{
|
||||||
|
{
|
||||||
|
Lang: "eng",
|
||||||
|
Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Test Lyrics",
|
||||||
|
"author": "Navidrome Test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test lyrics plugin for integration testing"
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@@ -396,6 +397,7 @@ func setupTestDB() {
|
|||||||
core.NewShare(ds),
|
core.NewShare(ds),
|
||||||
playback.PlaybackServer(nil),
|
playback.PlaybackServer(nil),
|
||||||
metrics.NewNoopInstance(),
|
metrics.NewNoopInstance(),
|
||||||
|
lyrics.NewLyrics(nil),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||||
@@ -48,12 +49,13 @@ type Router struct {
|
|||||||
share core.Share
|
share core.Share
|
||||||
playback playback.PlaybackServer
|
playback playback.PlaybackServer
|
||||||
metrics metrics.Metrics
|
metrics metrics.Metrics
|
||||||
|
lyrics lyrics.Lyrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||||
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||||
metrics metrics.Metrics,
|
metrics metrics.Metrics, lyrics lyrics.Lyrics,
|
||||||
) *Router {
|
) *Router {
|
||||||
r := &Router{
|
r := &Router{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
@@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
|
|||||||
share: share,
|
share: share,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
|
lyrics: lyrics,
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
playTracker = &fakePlayTracker{}
|
playTracker = &fakePlayTracker{}
|
||||||
eventBroker = &fakeEventBroker{}
|
eventBroker = &fakeEventBroker{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
@@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
|
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
@@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||||||
MockedMediaFile: mockRepo,
|
MockedMediaFile: mockRepo,
|
||||||
}
|
}
|
||||||
artwork = &fakeArtwork{data: "image data"}
|
artwork = &fakeArtwork{data: "image data"}
|
||||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil))
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
playlists = &fakePlaylists{}
|
playlists = &fakePlaylists{}
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the comment when parameter is empty", func() {
|
It("clears the comment when parameter is empty", func() {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
|
|
||||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
// Get references to the mock repositories so we can inspect their Options
|
// Get references to the mock repositories so we can inspect their Options
|
||||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||||
|
|||||||
Reference in New Issue
Block a user