feat(lyrics): expose source provenance

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-08-23 05:41:50 +03:00
commit e90d94ff06
No known key found for this signature in database
15 changed files with 271 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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