mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(plugins): label each lyrics entry with its source
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
7e1530994c
commit
34801479ce
@ -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"`
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)]
|
||||
|
||||
26
plugins/testdata/test-lyrics/main.go
vendored
26
plugins/testdata/test-lyrics/main.go
vendored
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user