From 34801479ce1a69157390fbb979f07faa571fe703 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 17 Oct 2026 12:11:15 +0300 Subject: [PATCH] feat(plugins): label each lyrics entry with its source Signed-off-by: ranokay --- plugins/capabilities/lyrics.go | 7 ++ plugins/capabilities/lyrics.yaml | 13 ++- plugins/lyrics_adapter.go | 35 ++++++-- plugins/lyrics_adapter_test.go | 87 +++++++++++++++++++ plugins/pdk/go/lyrics/lyrics.go | 7 ++ plugins/pdk/go/lyrics/lyrics_stub.go | 7 ++ .../rust/nd-pdk-capabilities/src/lyrics.rs | 8 ++ plugins/testdata/test-lyrics/main.go | 26 ++++++ 8 files changed, 183 insertions(+), 7 deletions(-) diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go index 8352c4fc3..bbbabb804 100644 --- a/plugins/capabilities/lyrics.go +++ b/plugins/capabilities/lyrics.go @@ -14,6 +14,8 @@ type GetLyricsRequest struct { } // GetLyricsResponse contains the lyrics returned by the plugin. +// Lyrics are in preference order: the first entry that parses to a main-kind +// lyric is the plugin's winner. type GetLyricsResponse struct { Lyrics []LyricsText `json:"lyrics"` Source *LyricsSource `json:"source,omitempty"` @@ -24,10 +26,15 @@ type GetLyricsResponse struct { type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` + // Source labels this entry's upstream, letting a plugin return several + // providers' lyrics in one response. It overrides the response-level + // Source for this entry. + Source *LyricsSource `json:"source,omitempty"` } // LyricsSource identifies the optional upstream provider and format selected // by a plugin. Navidrome supplies the plugin name and source type itself. +// The response-level value is the default for entries without their own. type LyricsSource struct { Provider string `json:"provider,omitempty"` Format string `json:"format,omitempty"` diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 1b8d9da97..636bc0a42 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -17,7 +17,10 @@ components: required: - track GetLyricsResponse: - description: GetLyricsResponse contains the lyrics returned by the plugin. + description: |- + GetLyricsResponse contains the lyrics returned by the plugin. + Lyrics are in preference order: the first entry that parses to a main-kind + lyric is the plugin's winner. properties: lyrics: type: array @@ -32,6 +35,7 @@ components: description: |- LyricsSource identifies the optional upstream provider and format selected by a plugin. Navidrome supplies the plugin name and source type itself. + The response-level value is the default for entries without their own. properties: provider: type: string @@ -46,6 +50,13 @@ components: type: string text: type: string + source: + $ref: '#/components/schemas/LyricsSource' + description: |- + Source labels this entry's upstream, letting a plugin return several + providers' lyrics in one response. It overrides the response-level + Source for this entry. + nullable: true required: - text TrackInfo: diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index f5578bcf3..75fab9027 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -63,13 +63,12 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode // The lyric text comes from the plugin, not the media file's own tags, so // attribute logs to both the plugin and the track it was fetched for. ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path) - source := &model.LyricsSource{ - Type: model.LyricsSourcePlugin, - Name: l.displayName, - } + var defaults *capabilities.LyricsSource if resp.Source != nil { - source.Provider = strings.TrimSpace(resp.Source.Provider) - source.Format = strings.ToLower(strings.TrimSpace(resp.Source.Format)) + defaults = &capabilities.LyricsSource{ + Provider: strings.TrimSpace(resp.Source.Provider), + Format: strings.ToLower(strings.TrimSpace(resp.Source.Format)), + } } var result model.LyricList @@ -83,6 +82,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode log.Warn(ctx, "Error parsing plugin lyrics", err) continue } + source := lyricsSourceFor(lt, defaults, l.displayName) for _, lyric := range parsed { if !lyric.IsEmpty() { lyric.Source = source @@ -92,3 +92,26 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode } return result, nil } + +// lyricsSourceFor resolves one entry's provenance: the entry's own source wins +// field by field, the response-level source is the default for entries without +// one, and Navidrome always supplies the plugin identity itself. +func lyricsSourceFor(entry capabilities.LyricsText, defaults *capabilities.LyricsSource, pluginName string) *model.LyricsSource { + source := &model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: pluginName, + } + if defaults != nil { + source.Provider = defaults.Provider + source.Format = defaults.Format + } + if entry.Source != nil { + if provider := strings.TrimSpace(entry.Source.Provider); provider != "" { + source.Provider = provider + } + if format := strings.ToLower(strings.TrimSpace(entry.Source.Format)); format != "" { + source.Format = format + } + } + return source +} diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index 7491e6b57..af6db1814 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -99,6 +99,93 @@ var _ = Describe("LyricsPlugin", Ordered, func() { })) }) + It("labels each variant with its own source", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": { + "variants": `[{"text":"[00:01.00]Alpha line","provider":"Alpha","format":"lrc"},` + + `{"text":"[00:02.00]Beta line","provider":"Beta","format":"lrc"}]`, + }, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + result, err := p.GetLyrics(GinkgoT().Context(), &model.MediaFile{ + ID: "track-1", Title: "Test Song", Artist: "Test Artist", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].Line[0].Value).To(Equal("Alpha line")) + Expect(result[1].Line[0].Value).To(Equal("Beta line")) + Expect(result[0].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + Provider: "Alpha", + Format: "lrc", + })) + Expect(result[1].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + Provider: "Beta", + Format: "lrc", + })) + }) + + It("falls back to the response source for entries without their own", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": { + "source_provider": "Fallback", + "source_format": "lrc", + "variants": `[{"text":"[00:01.00]Alpha line","provider":"Alpha"},` + + `{"text":"[00:02.00]Bare line"}]`, + }, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + result, err := p.GetLyrics(GinkgoT().Context(), &model.MediaFile{ + ID: "track-1", Title: "Test Song", Artist: "Test Artist", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + Provider: "Alpha", + Format: "lrc", + })) + Expect(result[1].Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + Provider: "Fallback", + Format: "lrc", + })) + }) + + It("keeps the plugin identity when no source is given anywhere", func() { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": { + "variants": `[{"text":"[00:01.00]Alpha line"},{"text":"[00:02.00]Beta line"}]`, + }, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + result, err := p.GetLyrics(GinkgoT().Context(), &model.MediaFile{ + ID: "track-1", Title: "Test Song", Artist: "Test Artist", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + for _, lyric := range result { + Expect(lyric.Source).To(Equal(&model.LyricsSource{ + Type: model.LyricsSourcePlugin, + Name: "Test Lyrics", + })) + } + }) + It("blocks new calls while the per-plugin concurrency cap is saturated", func() { sem := provider.plugin.lyricsSem for range cap(sem) { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index cd43a07df..a8fdd5b3e 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -21,6 +21,8 @@ type GetLyricsRequest struct { } // GetLyricsResponse contains the lyrics returned by the plugin. +// Lyrics are in preference order: the first entry that parses to a main-kind +// lyric is the plugin's winner. type GetLyricsResponse struct { Lyrics []LyricsText `json:"lyrics"` Source *LyricsSource `json:"source,omitempty"` @@ -28,6 +30,7 @@ type GetLyricsResponse struct { // LyricsSource identifies the optional upstream provider and format selected // by a plugin. Navidrome supplies the plugin name and source type itself. +// The response-level value is the default for entries without their own. type LyricsSource struct { Provider string `json:"provider,omitempty"` Format string `json:"format,omitempty"` @@ -38,6 +41,10 @@ type LyricsSource struct { type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` + // Source labels this entry's upstream, letting a plugin return several + // providers' lyrics in one response. It overrides the response-level + // Source for this entry. + Source *LyricsSource `json:"source,omitempty"` } // TrackInfo contains track metadata. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 59eb15852..4b0a6b383 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -19,6 +19,8 @@ type GetLyricsRequest struct { } // GetLyricsResponse contains the lyrics returned by the plugin. +// Lyrics are in preference order: the first entry that parses to a main-kind +// lyric is the plugin's winner. type GetLyricsResponse struct { Lyrics []LyricsText `json:"lyrics"` Source *LyricsSource `json:"source,omitempty"` @@ -26,6 +28,7 @@ type GetLyricsResponse struct { // LyricsSource identifies the optional upstream provider and format selected // by a plugin. Navidrome supplies the plugin name and source type itself. +// The response-level value is the default for entries without their own. type LyricsSource struct { Provider string `json:"provider,omitempty"` Format string `json:"format,omitempty"` @@ -36,6 +39,10 @@ type LyricsSource struct { type LyricsText struct { Lang string `json:"lang,omitempty"` Text string `json:"text"` + // Source labels this entry's upstream, letting a plugin return several + // providers' lyrics in one response. It overrides the response-level + // Source for this entry. + Source *LyricsSource `json:"source,omitempty"` } // TrackInfo contains track metadata. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 15438d60e..4358387b6 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -29,6 +29,8 @@ pub struct GetLyricsRequest { pub track: TrackInfo, } /// GetLyricsResponse contains the lyrics returned by the plugin. +/// Lyrics are in preference order: the first entry that parses to a main-kind +/// lyric is the plugin's winner. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetLyricsResponse { @@ -39,6 +41,7 @@ pub struct GetLyricsResponse { } /// LyricsSource identifies the optional upstream provider and format selected /// by a plugin. Navidrome supplies the plugin name and source type itself. +/// The response-level value is the default for entries without their own. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LyricsSource { @@ -56,6 +59,11 @@ pub struct LyricsText { pub lang: String, #[serde(default)] pub text: String, + /// Source labels this entry's upstream, letting a plugin return several + /// providers' lyrics in one response. It overrides the response-level + /// Source for this entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, } /// TrackInfo contains track metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 10cc06a55..af902f200 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -2,6 +2,7 @@ package main import ( + "encoding/json" "fmt" "github.com/navidrome/navidrome/plugins/pdk/go/lyrics" @@ -27,6 +28,31 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR source = &lyrics.LyricsSource{Provider: provider, Format: sourceFormat} } + // Config-provided variants let tests exercise multi-source responses: each + // entry carries its own source when it names a provider or format, and the + // response-level source stays the default for the rest. + if raw, hasVariants := pdk.GetConfig("variants"); hasVariants && raw != "" { + var variants []struct { + Lang string `json:"lang"` + Text string `json:"text"` + Provider string `json:"provider"` + Format string `json:"format"` + } + if err := json.Unmarshal([]byte(raw), &variants); err != nil { + return lyrics.GetLyricsResponse{}, fmt.Errorf("decode variants config: %w", err) + } + if len(variants) > 0 { + texts := make([]lyrics.LyricsText, len(variants)) + for i, variant := range variants { + texts[i] = lyrics.LyricsText{Lang: variant.Lang, Text: variant.Text} + if variant.Provider != "" || variant.Format != "" { + texts[i].Source = &lyrics.LyricsSource{Provider: variant.Provider, Format: variant.Format} + } + } + return lyrics.GetLyricsResponse{Lyrics: texts, Source: source}, nil + } + } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. format, hasFormat := pdk.GetConfig("format") if hasFormat {