From f03ca44a8ec4abc06086e84c95ecf7212854d0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 3 Mar 2026 15:48:39 -0500 Subject: [PATCH] feat(plugins): add lyrics provider plugin capability (#5126) * feat(plugins): add lyrics provider plugin capability Refactor the lyrics system from a static function to an interface-based service that supports WASM plugin providers. Plugins listed in the LyricsPriority config (alongside "embedded" and file extensions) are now resolved through the plugin system. Includes capability definition, Go/Rust PDK, adapter, Wire integration, and tests for plugin fallback behavior. * test(plugins): add lyrics capability integration test with test plugin * fix(plugins): default lyrics language to 'xxx' when plugin omits it Per the OpenSubsonic spec, the server must return 'und' or 'xxx' when the lyrics language is unknown. The lyrics plugin adapter was passing an empty string through when a plugin didn't provide a language value. This defaults the language to 'xxx', consistent with all other callers of model.ToLyrics() in the codebase. * refactor(plugins): rename lyrics import to improve clarity Signed-off-by: Deluan * refactor(lyrics): update TrackInfo description for clarity Signed-off-by: Deluan * fix(lyrics): enhance lyrics plugin handling and case sensitivity Signed-off-by: Deluan * fix(plugins): update payload type to string with byte format for task data Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/wire_gen.go | 8 +- cmd/wire_injectors.go | 2 + core/lyrics/lyrics.go | 34 +++- core/lyrics/lyrics_test.go | 108 ++++++++++++- core/lyrics/sources.go | 24 +++ core/wire_providers.go | 2 + plugins/capabilities/lyrics.go | 26 +++ plugins/capabilities/lyrics.yaml | 115 ++++++++++++++ plugins/capabilities/scrobbler.go | 2 +- plugins/capabilities/scrobbler.yaml | 2 +- plugins/lyrics_adapter.go | 59 +++++++ plugins/lyrics_adapter_test.go | 99 ++++++++++++ plugins/manager.go | 17 ++ plugins/pdk/go/lyrics/lyrics.go | 118 ++++++++++++++ plugins/pdk/go/lyrics/lyrics_stub.go | 82 ++++++++++ plugins/pdk/go/scrobbler/scrobbler.go | 2 +- plugins/pdk/go/scrobbler/scrobbler_stub.go | 2 +- .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 148 ++++++++++++++++++ .../rust/nd-pdk-capabilities/src/scrobbler.rs | 2 +- plugins/testdata/test-lyrics/go.mod | 16 ++ plugins/testdata/test-lyrics/go.sum | 14 ++ plugins/testdata/test-lyrics/main.go | 42 +++++ plugins/testdata/test-lyrics/manifest.json | 6 + server/e2e/e2e_suite_test.go | 2 + server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 5 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval.go | 5 +- server/subsonic/media_retrieval_test.go | 3 +- server/subsonic/opensubsonic_test.go | 2 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/searching_test.go | 2 +- 33 files changed, 930 insertions(+), 28 deletions(-) create mode 100644 plugins/capabilities/lyrics.go create mode 100644 plugins/capabilities/lyrics.yaml create mode 100644 plugins/lyrics_adapter.go create mode 100644 plugins/lyrics_adapter_test.go create mode 100644 plugins/pdk/go/lyrics/lyrics.go create mode 100644 plugins/pdk/go/lyrics/lyrics_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs create mode 100644 plugins/testdata/test-lyrics/go.mod create mode 100644 plugins/testdata/test-lyrics/go.sum create mode 100644 plugins/testdata/test-lyrics/main.go create mode 100644 plugins/testdata/test-lyrics/manifest.json diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 204d90ba..e8df9a38 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,6 +1,6 @@ // 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 // +build !wireinject @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "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) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) 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 } @@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager { // 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 { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 56206feb..d87a8d6d 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" @@ -44,6 +45,7 @@ var allProviders = wire.NewSet( 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)), diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index 858a3ffd..75805304 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -9,23 +9,45 @@ import ( "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 err error - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") { + for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) switch { - case pattern == "embedded": + case strings.EqualFold(pattern, "embedded"): lyricsList, err = fromEmbedded(ctx, mf) case strings.HasPrefix(pattern, "."): - lyricsList, err = fromExternalFile(ctx, mf, pattern) + lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) default: - log.Error(ctx, "Invalid lyric pattern", "pattern", pattern) + lyricsList, err = l.fromPlugin(ctx, mf, pattern) } 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 { diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index f4197ccf..2e495a71 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -3,6 +3,7 @@ package lyrics_test import ( "context" "encoding/json" + "fmt" "os" "github.com/navidrome/navidrome/conf" @@ -72,7 +73,8 @@ var _ = Describe("sources", func() { DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { 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(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() { 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(list).To(Equal(embeddedLyrics)) }) @@ -115,10 +118,109 @@ var _ = Describe("sources", func() { It("should return nothing if error happens when trying to parse file", func() { 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(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 preserve plugin name case from config", func() { + conf.Server.LyricsPriority = "MyLyricsPlugin" + mockLoader.pluginName = "MyLyricsPlugin" + mockLoader.lyrics = unsyncedLyrics + svc := lyrics.NewLyrics(mockLoader) + list, err := svc.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(Equal(unsyncedLyrics)) + }) + + 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 + pluginName string // expected plugin name (exact match, like real manager) +} + +func (m *mockPluginLoader) PluginNames(_ string) []string { + if m.notFound { + return nil + } + return []string{"test-lyrics-plugin"} +} + +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + if m.notFound { + return nil, false + } + // If pluginName is set, require exact match (like the real plugin manager) + if m.pluginName != "" && name != m.pluginName { + 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 +} diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 857dc2ee..82a10ca4 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( 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 +} diff --git a/core/wire_providers.go b/core/wire_providers.go index 503feb78..f9b47201 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -28,4 +29,5 @@ var Set = wire.NewSet( scrobbler.GetPlayTracker, playback.GetInstance, metrics.GetInstance, + lyrics.NewLyrics, ) diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go new file mode 100644 index 00000000..6f6d1917 --- /dev/null +++ b/plugins/capabilities/lyrics.go @@ -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"` +} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml new file mode 100644 index 00000000..e4f88476 --- /dev/null +++ b/plugins/capabilities/lyrics.yaml @@ -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. + 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 diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 300652cb..8091efe5 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -38,7 +38,7 @@ type ArtistRef struct { MBID string `json:"mbid,omitempty"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index d8f47c95..5de351a5 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -77,7 +77,7 @@ components: - track - timestamp TrackInfo: - description: TrackInfo contains track metadata for scrobbling. + description: TrackInfo contains track metadata. properties: id: type: string diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go new file mode 100644 index 00000000..aa993066 --- /dev/null +++ b/plugins/lyrics_adapter.go @@ -0,0 +1,59 @@ +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 { + lang := lt.Lang + if lang == "" { + lang = "xxx" + } + parsed, err := model.ToLyrics(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 +} diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go new file mode 100644 index 00000000..a1a6c180 --- /dev/null +++ b/plugins/lyrics_adapter_test.go @@ -0,0 +1,99 @@ +//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("defaults language to 'xxx' when plugin does not provide one", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"no_lang": "true"}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Lang).To(Equal("xxx")) + }) + + 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")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go index bf6bab6e..0c7c91ed 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -16,6 +16,7 @@ import ( extism "github.com/extism/go-sdk" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -282,6 +283,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { }, 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. type PluginInfo struct { Name string diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go new file mode 100644 index 00000000..4f5aa630 --- /dev/null +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -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. +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 +} diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go new file mode 100644 index 00000000..1fdf184e --- /dev/null +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -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. +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) {} diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 258b1b4c..c694f59d 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -62,7 +62,7 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index f2fc584a..6d4afd81 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -59,7 +59,7 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } -// TrackInfo contains track metadata for scrobbling. +// TrackInfo contains track metadata. type TrackInfo struct { // ID is the internal Navidrome track ID. ID string `json:"id"` diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 06c2c5c0..85375b52 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -6,6 +6,7 @@ //! for implementing Navidrome plugin capabilities in Rust. pub mod lifecycle; +pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs new file mode 100644 index 00000000..16882aba --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -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 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. +#[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, + /// AlbumArtists is the list of album artists. + #[serde(default)] + pub album_artists: Vec, + /// 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) -> 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; +} + +/// 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> { + let plugin = <$plugin_type>::default(); + let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 9dbedd04..2572712d 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -76,7 +76,7 @@ pub struct ScrobbleRequest { #[serde(default)] pub timestamp: i64, } -/// TrackInfo contains track metadata for scrobbling. +/// TrackInfo contains track metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrackInfo { diff --git a/plugins/testdata/test-lyrics/go.mod b/plugins/testdata/test-lyrics/go.mod new file mode 100644 index 00000000..fbbb23fc --- /dev/null +++ b/plugins/testdata/test-lyrics/go.mod @@ -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 diff --git a/plugins/testdata/test-lyrics/go.sum b/plugins/testdata/test-lyrics/go.sum new file mode 100644 index 00000000..af880eb5 --- /dev/null +++ b/plugins/testdata/test-lyrics/go.sum @@ -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= diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go new file mode 100644 index 00000000..0e485ceb --- /dev/null +++ b/plugins/testdata/test-lyrics/main.go @@ -0,0 +1,42 @@ +// 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) + } + + // Check if we should omit language (to test default language handling) + noLang, hasNoLang := pdk.GetConfig("no_lang") + lang := "eng" + if hasNoLang && noLang == "true" { + lang = "" + } + + // Return test lyrics based on track info + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{ + { + Lang: lang, + Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist, + }, + }, + }, nil +} + +func main() {} diff --git a/plugins/testdata/test-lyrics/manifest.json b/plugins/testdata/test-lyrics/manifest.json new file mode 100644 index 00000000..a61299e9 --- /dev/null +++ b/plugins/testdata/test-lyrics/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Test Lyrics", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test lyrics plugin for integration testing" +} diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 0e0ca606..484a91cc 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -396,6 +397,7 @@ func setupTestDB() { core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), ) } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 63c2614c..aac2d63d 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) 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() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index d0d9bb16..8674a294 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + lyricssvc "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" @@ -48,12 +49,13 @@ type Router struct { share core.Share playback playback.PlaybackServer metrics metrics.Metrics + lyrics lyricssvc.Lyrics } 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, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, - metrics metrics.Metrics, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, ) *Router { r := &Router{ ds: ds, @@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame share: share, playback: playback, metrics: metrics, + lyrics: lyrics, } r.Handler = r.routes() return r diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 6f09f534..57809fbb 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} 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() { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index c16779e3..54fcb5e3 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" @@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { return response, nil } - structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0]) + structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0]) if err != nil { return nil, err } @@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } - structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile) + structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile) if err != nil { return nil, err } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 351b4e59..1a638f06 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" @@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } 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() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 58dca682..c02b262b 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", 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() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index d99e244b..86c17b39 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { 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() }) @@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} 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() { diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 7f7de381..d4b7e970 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} 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 mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)