diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index fbe85914d..e76cc5469 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -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() { diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 23c20122d..23535cbf5 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -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 } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 7c7922bfd..de128d8b5 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -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), diff --git a/model/lyrics.go b/model/lyrics.go index 111b1c2a9..24c810059 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -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 diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go index 6f6d19177..8352c4fc3 100644 --- a/plugins/capabilities/lyrics.go +++ b/plugins/capabilities/lyrics.go @@ -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"` +} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 7336124fc..1b8d9da97 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -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 diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 9e02115e7..f5578bcf3 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -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) } } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..b77480c73 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -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) { diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index b3c7f29ac..cd43a07df 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -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"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 76c54d2e5..59eb15852 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -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"` diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index a8449a5ec..15438d60e 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -34,9 +34,21 @@ pub struct GetLyricsRequest { pub struct GetLyricsResponse { #[serde(default)] pub lyrics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} +/// 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 { diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 2ee2dabbf..10cc06a55 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -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, diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go index bfe1f1899..ce437095e 100644 --- a/server/subsonic/lyrics.go +++ b/server/subsonic/lyrics.go @@ -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 } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 8713b7a3b..8d97aa5f3 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -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) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..d7004505d 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -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