feat(plugins): label each lyrics entry with its source

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:11:15 +03:00
parent 7e1530994c
commit 34801479ce
No known key found for this signature in database
8 changed files with 183 additions and 7 deletions

View File

@ -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"`

View File

@ -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:

View File

@ -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
}

View File

@ -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) {

View File

@ -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.

View File

@ -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.

View File

@ -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<LyricsSource>,
}
/// TrackInfo contains track metadata.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]

View File

@ -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 {