mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 00:47:48 +01:00
feat(lyrics): expose source provenance
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
5fafda1544
commit
e90d94ff06
15 changed files with 271 additions and 47 deletions
|
|
@ -21,6 +21,17 @@ var _ = Describe("Lyrics", func() {
|
|||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
expectLyricsWithSource := func(actual, expected model.LyricList, source model.LyricsSource) {
|
||||
GinkgoHelper()
|
||||
withoutSource := make(model.LyricList, len(actual))
|
||||
copy(withoutSource, actual)
|
||||
for i := range withoutSource {
|
||||
Expect(withoutSource[i].Source).To(Equal(&source))
|
||||
withoutSource[i].Source = nil
|
||||
}
|
||||
Expect(withoutSource).To(Equal(expected))
|
||||
}
|
||||
|
||||
embeddedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
|
|
@ -162,19 +173,19 @@ var _ = Describe("Lyrics", func() {
|
|||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList, source model.LyricsSource) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
expectLyricsWithSource(list, expected, source)
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics, model.LyricsSource{Type: model.LyricsSourceEmbedded}),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "lrc"}),
|
||||
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "elrc"}),
|
||||
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "srt"}),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "txt"}),
|
||||
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics, model.LyricsSource{Type: model.LyricsSourceSidecar, Format: "ttml"}))
|
||||
|
||||
It("resolves source priority across duplicate media files", func() {
|
||||
conf.Server.LyricsPriority = ".ttml,embedded"
|
||||
|
|
@ -196,7 +207,10 @@ var _ = Describe("Lyrics", func() {
|
|||
|
||||
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(ttmlLyrics))
|
||||
expectLyricsWithSource(list, ttmlLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: "ttml",
|
||||
})
|
||||
})
|
||||
|
||||
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
|
||||
|
|
@ -289,7 +303,9 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
|
|
@ -316,7 +332,10 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "test-lyrics-plugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should try plugin after embedded returns nothing", func() {
|
||||
|
|
@ -326,7 +345,10 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "test-lyrics-plugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
|
|
@ -335,7 +357,9 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
|
|
@ -344,7 +368,9 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should preserve plugin name case from config", func() {
|
||||
|
|
@ -354,7 +380,10 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
expectLyricsWithSource(list, unsyncedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "MyLyricsPlugin",
|
||||
})
|
||||
})
|
||||
|
||||
It("should handle plugin error gracefully", func() {
|
||||
|
|
@ -363,7 +392,9 @@ var _ = Describe("Lyrics", func() {
|
|||
svc := lyrics.NewLyrics(nil, mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
expectLyricsWithSource(list, embeddedLyrics, model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
})
|
||||
})
|
||||
|
||||
It("should return a plugin error when no later source has lyrics", func() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -17,7 +18,8 @@ import (
|
|||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
list, err := mf.StructuredLyrics()
|
||||
return withLyricsSource(list, model.LyricsSource{Type: model.LyricsSourceEmbedded}), err
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
|
|
@ -65,7 +67,10 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
|||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file")
|
||||
return list, nil
|
||||
return withLyricsSource(list, model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: strings.ToLower(strings.TrimPrefix(suffix, ".")),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
|
|
@ -89,5 +94,39 @@ func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, plu
|
|||
if len(lyricsList) > 0 {
|
||||
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||
}
|
||||
return lyricsList, nil
|
||||
return withLyricsSource(lyricsList, model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: pluginName,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// withLyricsSource copies the lyric entries before adding defaults so resolver
|
||||
// metadata does not mutate cached embedded lyrics or a provider-owned result.
|
||||
func withLyricsSource(list model.LyricList, defaults model.LyricsSource) model.LyricList {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
|
||||
result := make(model.LyricList, len(list))
|
||||
copy(result, list)
|
||||
for i := range result {
|
||||
source := defaults
|
||||
if result[i].Source != nil {
|
||||
source = *result[i].Source
|
||||
if source.Type == "" {
|
||||
source.Type = defaults.Type
|
||||
}
|
||||
if source.Name == "" {
|
||||
source.Name = defaults.Name
|
||||
}
|
||||
if source.Provider == "" {
|
||||
source.Provider = defaults.Provider
|
||||
}
|
||||
if source.Format == "" {
|
||||
source.Format = defaults.Format
|
||||
}
|
||||
}
|
||||
result[i].Source = &source
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ var _ = Describe("sources", func() {
|
|||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(len(expectedList)))
|
||||
for i := range lyrics {
|
||||
Expect(lyrics[i].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourceEmbedded,
|
||||
}))
|
||||
lyrics[i].Source = nil
|
||||
}
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
|
|
@ -91,6 +98,10 @@ var _ = Describe("sources", func() {
|
|||
Expect(lyrics).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Line).ToNot(BeEmpty())
|
||||
Expect(lyrics[0].Synced).To(Equal(expectSynced))
|
||||
Expect(lyrics[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourceSidecar,
|
||||
Format: suffix[1:],
|
||||
}))
|
||||
},
|
||||
Entry(".lrc synced", "test.mp3", ".lrc", true),
|
||||
Entry(".elrc enhanced", "test.mp3", ".elrc", true),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ type Agent struct {
|
|||
Name string `structs:"name,omitempty" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type LyricsSourceType string
|
||||
|
||||
const (
|
||||
LyricsSourceEmbedded LyricsSourceType = "embedded"
|
||||
LyricsSourceSidecar LyricsSourceType = "sidecar"
|
||||
LyricsSourcePlugin LyricsSourceType = "plugin"
|
||||
)
|
||||
|
||||
// LyricsSource describes where Navidrome resolved a lyric. Name identifies a
|
||||
// plugin, while Provider identifies an optional upstream used by that plugin.
|
||||
type LyricsSource struct {
|
||||
Type LyricsSourceType `structs:"type" json:"type"`
|
||||
Name string `structs:"name,omitempty" json:"name,omitempty"`
|
||||
Provider string `structs:"provider,omitempty" json:"provider,omitempty"`
|
||||
Format string `structs:"format,omitempty" json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
Start *int64 `structs:"start,omitempty" json:"start,omitempty"`
|
||||
End *int64 `structs:"end,omitempty" json:"end,omitempty"`
|
||||
|
|
@ -28,14 +45,15 @@ type Line struct {
|
|||
}
|
||||
|
||||
type Lyrics struct {
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `structs:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lang string `structs:"lang" json:"lang"`
|
||||
Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"`
|
||||
Line []Line `structs:"line" json:"line"`
|
||||
Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"`
|
||||
Source *LyricsSource `structs:"source,omitempty" json:"source,omitempty"`
|
||||
Synced bool `structs:"synced" json:"synced"`
|
||||
}
|
||||
|
||||
// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are
|
||||
|
|
|
|||
|
|
@ -15,12 +15,20 @@ type GetLyricsRequest struct {
|
|||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Source *LyricsSource `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// LyricsSource identifies the optional upstream provider and format selected
|
||||
// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,24 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LyricsText'
|
||||
source:
|
||||
$ref: '#/components/schemas/LyricsSource'
|
||||
nullable: true
|
||||
required:
|
||||
- lyrics
|
||||
LyricsSource:
|
||||
description: |-
|
||||
LyricsSource identifies the optional upstream provider and format selected
|
||||
by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
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.
|
||||
Navidrome content-sniffs and parses the returned text.
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package plugins
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -26,13 +27,18 @@ func init() {
|
|||
}
|
||||
|
||||
func newLyricsPlugin(p *plugin) *LyricsPlugin {
|
||||
return &LyricsPlugin{name: p.name, plugin: p}
|
||||
displayName := p.name
|
||||
if p.manifest != nil && strings.TrimSpace(p.manifest.Name) != "" {
|
||||
displayName = strings.TrimSpace(p.manifest.Name)
|
||||
}
|
||||
return &LyricsPlugin{name: p.name, displayName: displayName, plugin: p}
|
||||
}
|
||||
|
||||
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||
type LyricsPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
name string
|
||||
displayName string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response
|
||||
|
|
@ -57,6 +63,14 @@ 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,
|
||||
}
|
||||
if resp.Source != nil {
|
||||
source.Provider = strings.TrimSpace(resp.Source.Provider)
|
||||
source.Format = strings.ToLower(strings.TrimSpace(resp.Source.Format))
|
||||
}
|
||||
|
||||
var result model.LyricList
|
||||
for _, lt := range resp.Lyrics {
|
||||
|
|
@ -71,6 +85,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode
|
|||
}
|
||||
for _, lyric := range parsed {
|
||||
if !lyric.IsEmpty() {
|
||||
lyric.Source = source
|
||||
result = append(result, lyric)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
|
|||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Line).ToNot(BeEmpty())
|
||||
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
||||
Expect(result[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "Test Lyrics",
|
||||
}))
|
||||
})
|
||||
|
||||
It("defaults language to 'xxx' when plugin does not provide one", func() {
|
||||
|
|
@ -73,6 +77,30 @@ var _ = Describe("LyricsPlugin", Ordered, func() {
|
|||
Expect(result[0].Lang).To(Equal("xxx"))
|
||||
})
|
||||
|
||||
It("preserves optional provider provenance from the plugin", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {
|
||||
"source_provider": "unison",
|
||||
"source_format": "ttml",
|
||||
},
|
||||
}, "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(1))
|
||||
Expect(result[0].Source).To(Equal(&model.LyricsSource{
|
||||
Type: model.LyricsSourcePlugin,
|
||||
Name: "Test Lyrics",
|
||||
Provider: "unison",
|
||||
Format: "ttml",
|
||||
}))
|
||||
})
|
||||
|
||||
It("blocks new calls while the per-plugin concurrency cap is saturated", func() {
|
||||
sem := provider.plugin.lyricsSem
|
||||
for range cap(sem) {
|
||||
|
|
|
|||
|
|
@ -22,11 +22,19 @@ type GetLyricsRequest struct {
|
|||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
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.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
|
|
|||
|
|
@ -20,11 +20,19 @@ type GetLyricsRequest struct {
|
|||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
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.
|
||||
type LyricsSource struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
// Navidrome content-sniffs and parses the returned text.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
|
|
|
|||
|
|
@ -34,9 +34,21 @@ pub struct GetLyricsRequest {
|
|||
pub struct GetLyricsResponse {
|
||||
#[serde(default)]
|
||||
pub lyrics: Vec<LyricsText>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<LyricsSource>,
|
||||
}
|
||||
/// LyricsSource identifies the optional upstream provider and format selected
|
||||
/// by a plugin. Navidrome supplies the plugin name and source type itself.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsSource {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub provider: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub format: String,
|
||||
}
|
||||
/// LyricsText represents a single set of lyrics in raw text format.
|
||||
/// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
/// Navidrome content-sniffs and parses the returned text.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsText {
|
||||
|
|
|
|||
9
plugins/testdata/test-lyrics/main.go
vendored
9
plugins/testdata/test-lyrics/main.go
vendored
|
|
@ -20,6 +20,13 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
|||
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
var source *lyrics.LyricsSource
|
||||
provider, hasProvider := pdk.GetConfig("source_provider")
|
||||
sourceFormat, hasSourceFormat := pdk.GetConfig("source_format")
|
||||
if hasProvider || hasSourceFormat {
|
||||
source = &lyrics.LyricsSource{Provider: provider, Format: sourceFormat}
|
||||
}
|
||||
|
||||
// Config-selected format lets tests exercise the adapter's content-sniffing per format.
|
||||
format, hasFormat := pdk.GetConfig("format")
|
||||
if hasFormat {
|
||||
|
|
@ -52,6 +59,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
|||
if text != "" {
|
||||
return lyrics.GetLyricsResponse{
|
||||
Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}},
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +73,7 @@ func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsR
|
|||
|
||||
// Return test lyrics based on track info
|
||||
return lyrics.GetLyricsResponse{
|
||||
Source: source,
|
||||
Lyrics: []lyrics.LyricsText{
|
||||
{
|
||||
Lang: lang,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced boo
|
|||
|
||||
if enhanced {
|
||||
structured.Kind = lyrics.EffectiveKind()
|
||||
if lyrics.Source != nil && lyrics.Source.Type != "" {
|
||||
structured.Source = &responses.LyricsSource{
|
||||
Type: string(lyrics.Source.Type),
|
||||
Name: lyrics.Source.Name,
|
||||
Provider: lyrics.Source.Provider,
|
||||
Format: lyrics.Source.Format,
|
||||
}
|
||||
}
|
||||
if len(cueLines) > 0 && len(agents.response) > 0 {
|
||||
structured.Agents = agents.response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,9 @@ var _ = Describe("GetLyricsBySongId", func() {
|
|||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, lyric := range response.LyricsList.StructuredLyrics {
|
||||
Expect(lyric.Source).To(BeNil())
|
||||
}
|
||||
|
||||
porTime := int64(18800)
|
||||
ttmlTime := int64(22800)
|
||||
|
|
@ -274,6 +277,12 @@ var _ = Describe("GetLyricsBySongId", func() {
|
|||
|
||||
response, err := router.GetLyricsBySongId(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, lyric := range response.LyricsList.StructuredLyrics {
|
||||
Expect(lyric.Source).To(Equal(&responses.LyricsSource{
|
||||
Type: "sidecar",
|
||||
Format: "ttml",
|
||||
}))
|
||||
}
|
||||
|
||||
mainStartA := int64(1000)
|
||||
mainStartB := int64(2000)
|
||||
|
|
|
|||
|
|
@ -572,16 +572,24 @@ type CueLine struct {
|
|||
Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"`
|
||||
}
|
||||
|
||||
type LyricsSource struct {
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
|
||||
Provider string `xml:"provider,attr,omitempty" json:"provider,omitempty"`
|
||||
Format string `xml:"format,attr,omitempty" json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type StructuredLyric struct {
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
|
||||
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"`
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"`
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"`
|
||||
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Source *LyricsSource `xml:"source,omitempty" json:"source,omitempty"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
}
|
||||
|
||||
type StructuredLyrics []StructuredLyric
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue