From 0ff86b1ca5c496a8c29bac49f24b1135670a4005 Mon Sep 17 00:00:00 2001 From: Rufei Zhou Date: Sun, 31 May 2026 21:17:15 -0700 Subject: [PATCH] feat(lyrics): add support for os songLyrics v2, elrc word-level timings --- adapters/gotaglib/end_to_end_test.go | 8 +- conf/configuration.go | 2 +- core/lyrics/lyrics_test.go | 15 +- core/lyrics/sources.go | 15 +- core/lyrics/sources_test.go | 31 ++- .../20260526195106_lyrics_v2_shape.go | 173 ++++++++++++++ model/lyrics.go | 195 +++++++++++++--- model/lyrics_test.go | 106 ++++++--- model/lyricsfile.go | 220 ++++++++++++++++++ model/metadata/map_mediafile_test.go | 10 +- plugins/lyrics_adapter_test.go | 4 +- scanner/metadata_old/metadata_test.go | 19 +- server/subsonic/helpers.go | 129 +++++++++- server/subsonic/media_retrieval.go | 10 +- server/subsonic/media_retrieval_test.go | 65 ++++++ server/subsonic/opensubsonic.go | 2 +- server/subsonic/opensubsonic_test.go | 4 +- server/subsonic/responses/responses.go | 38 ++- 18 files changed, 913 insertions(+), 133 deletions(-) create mode 100644 db/migrations/20260526195106_lyrics_v2_shape.go create mode 100644 model/lyricsfile.go diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index e7dd18ac1..85829cce0 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -113,13 +113,15 @@ var _ = Describe("Extractor", func() { Describe("lyrics", func() { makeLyrics := func(code, secondLine string) model.Lyrics { + s0 := int64(0) + s1 := int64(2500) return model.Lyrics{ DisplayArtist: "", DisplayTitle: "", Lang: code, - Line: []model.Line{ - {Start: new(int64(0)), Value: "This is"}, - {Start: new(int64(2500)), Value: secondLine}, + CueLine: []model.CueLine{ + {Index: 0, Start: &s0, End: &s1, Value: "This is"}, + {Index: 1, Start: &s1, Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/conf/configuration.go b/conf/configuration.go index 08f12fc94..a024a12d7 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -776,7 +776,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("lyricspriority", ".yaml,.lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 9ab732ad1..c415df0e1 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -24,18 +24,23 @@ var _ = Describe("sources", func() { unsynced, _ := model.ToLyrics("xxx", badLyrics) embeddedLyrics := model.LyricList{*unsynced} + syncStart0 := int64(18800) + syncStart1 := int64(22801) syncedLyrics := model.LyricList{ model.Lyrics{ DisplayArtist: "Rick Astley", DisplayTitle: "That one song", Lang: "eng", - Line: []model.Line{ + CueLine: []model.CueLine{ { - Start: new(int64(18800)), + Index: 0, + Start: &syncStart0, + End: &syncStart1, Value: "We're no strangers to love", }, { - Start: new(int64(22801)), + Index: 1, + Start: &syncStart1, Value: "You know the rules and so do I", }, }, @@ -47,11 +52,13 @@ var _ = Describe("sources", func() { unsyncedLyrics := model.LyricList{ model.Lyrics{ Lang: "xxx", - Line: []model.Line{ + CueLine: []model.CueLine{ { + Index: 0, Value: "We're no strangers to love", }, { + Index: 1, Value: "You know the rules and so do I", }, }, diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 82a10ca41..f4ba629b2 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path" + "strings" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -36,7 +37,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( return nil, err } - lyrics, err := model.ToLyrics("xxx", string(contents)) + lyrics, err := parseLyricsByExt(suffix, string(contents)) if err != nil { log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) return nil, err @@ -50,6 +51,18 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) ( return model.LyricList{*lyrics}, nil } +// parseLyricsByExt dispatches between the YAML Lyricsfile parser and the +// LRC/plain text parser based on file extension. .yaml and .yml are both +// recognized as Lyricsfile candidates. +func parseLyricsByExt(suffix, contents string) (*model.Lyrics, error) { + switch strings.ToLower(suffix) { + case ".yaml", ".yml": + return model.ParseLyricsfile(contents) + default: + return model.ToLyrics("xxx", contents) + } +} + // fromPlugin attempts to load lyrics from a plugin with the given name. func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) { if l.pluginLoader == nil { diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index d1aefcb5d..f6361fcdd 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -66,18 +66,23 @@ var _ = Describe("sources", func() { lyrics, err := fromExternalFile(ctx, &mf, ".lrc") Expect(err).To(BeNil()) + s0 := int64(18800) + s1 := int64(22801) Expect(lyrics).To(Equal(model.LyricList{ model.Lyrics{ DisplayArtist: "Rick Astley", DisplayTitle: "That one song", Lang: "eng", - Line: []model.Line{ + CueLine: []model.CueLine{ { - Start: new(int64(18800)), + Index: 0, + Start: &s0, + End: &s1, Value: "We're no strangers to love", }, { - Start: new(int64(22801)), + Index: 1, + Start: &s1, Value: "You know the rules and so do I", }, }, @@ -95,11 +100,13 @@ var _ = Describe("sources", func() { Expect(lyrics).To(Equal(model.LyricList{ model.Lyrics{ Lang: "xxx", - Line: []model.Line{ + CueLine: []model.CueLine{ { + Index: 0, Value: "We're no strangers to love", }, { + Index: 1, Value: "You know the rules and so do I", }, }, @@ -120,9 +127,9 @@ var _ = Describe("sources", func() { // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") - Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) - Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) + Expect(lyrics[0].CueLine).To(HaveLen(1)) + Expect(lyrics[0].CueLine[0].Start).To(Equal(new(int64(0)))) + Expect(lyrics[0].CueLine[0].Value).To(ContainSubstring("作曲")) }) It("should handle UTF-16 LE encoded LRC files", func() { @@ -135,11 +142,11 @@ var _ = Describe("sources", func() { // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") - Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) - Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) - Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) + Expect(lyrics[0].CueLine).To(HaveLen(2)) + Expect(lyrics[0].CueLine[0].Start).To(Equal(new(int64(18800)))) + Expect(lyrics[0].CueLine[0].Value).To(Equal("We're no strangers to love")) + Expect(lyrics[0].CueLine[1].Start).To(Equal(new(int64(22801)))) + Expect(lyrics[0].CueLine[1].Value).To(Equal("You know the rules and so do I")) }) }) }) diff --git a/db/migrations/20260526195106_lyrics_v2_shape.go b/db/migrations/20260526195106_lyrics_v2_shape.go new file mode 100644 index 000000000..ddf930c0d --- /dev/null +++ b/db/migrations/20260526195106_lyrics_v2_shape.go @@ -0,0 +1,173 @@ +package migrations + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upLyricsV2Shape, downLyricsV2Shape) +} + +// upLyricsV2Shape reshapes the JSON stored in media_file.lyrics from the +// legacy v1 [{line: [...]}, ...] structure to the canonical v2 shape +// [{cueLine: [...], synced, ...}]. This is a synchronous in-place rewrite +// performed during migration. After the rewrite a full rescan is forced so +// any external Lyricsfile YAML or ELRC sidecars are picked up to populate +// the now-available v2 fields (per-word cues, agents, kind). +func upLyricsV2Shape(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, `select id, lyrics from media_file where lyrics is not null and lyrics != '' and lyrics != '[]'`) + if err != nil { + return fmt.Errorf("scan media_file.lyrics: %w", err) + } + + type updatePair struct { + id string + payload string + } + var updates []updatePair + + for rows.Next() { + var id, lyricsJSON string + if err := rows.Scan(&id, &lyricsJSON); err != nil { + _ = rows.Close() + return fmt.Errorf("scan row: %w", err) + } + reshaped, err := reshapeLyricsV1ToV2(lyricsJSON) + if err != nil { + // Skip malformed rows; the forced rescan below will attempt to + // repopulate them from source. + continue + } + if reshaped == "" { + continue + } + updates = append(updates, updatePair{id: id, payload: reshaped}) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("scan media_file.lyrics: %w", err) + } + if err := rows.Close(); err != nil { + return err + } + + stmt, err := tx.PrepareContext(ctx, `update media_file set lyrics = ? where id = ?`) + if err != nil { + return fmt.Errorf("prepare update: %w", err) + } + defer stmt.Close() + + for _, u := range updates { + if _, err := stmt.ExecContext(ctx, u.payload, u.id); err != nil { + return fmt.Errorf("update lyrics for id=%s: %w", u.id, err) + } + } + + notice(tx, "Reshaped existing lyrics to v2; a full rescan will run to populate enhanced lyric data from sources") + return forceFullRescan(tx) +} + +func downLyricsV2Shape(_ context.Context, _ *sql.Tx) error { + return nil +} + +// reshapeLyricsV1ToV2 takes a JSON document that was historically a v1 +// LyricList ([{lang, line: [{start, value}], ...}]) and rewrites it as the +// canonical v2 shape ([{lang, cueLine: [{index, start, end, value}], ...}]). +// +// All structures are local to the migration so that future changes to the +// model package do not silently change the migration's wire interpretation. +func reshapeLyricsV1ToV2(input string) (string, error) { + if input == "" { + return "", nil + } + + var src []v1Lyric + if err := json.Unmarshal([]byte(input), &src); err != nil { + return "", err + } + + out := make([]v2Lyric, 0, len(src)) + for _, l := range src { + // Skip rows that already look like the new shape. + if len(l.Line) == 0 && len(l.CueLine) > 0 { + out = append(out, v2Lyric{ + DisplayArtist: l.DisplayArtist, + DisplayTitle: l.DisplayTitle, + Lang: l.Lang, + Offset: l.Offset, + Synced: l.Synced, + CueLine: l.CueLine, + }) + continue + } + + cueLines := make([]v2CueLine, len(l.Line)) + for i, ln := range l.Line { + cueLines[i] = v2CueLine{ + Index: i, + Start: ln.Start, + Value: ln.Value, + } + } + // Infer end-of-line from the next line's start. + for i := 0; i < len(cueLines)-1; i++ { + if cueLines[i].End == nil && cueLines[i+1].Start != nil { + cueLines[i].End = cueLines[i+1].Start + } + } + + out = append(out, v2Lyric{ + DisplayArtist: l.DisplayArtist, + DisplayTitle: l.DisplayTitle, + Lang: l.Lang, + Offset: l.Offset, + Synced: l.Synced, + CueLine: cueLines, + }) + } + + bs, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(bs), nil +} + +// v1Lyric is the historical wire shape. CueLine is included so that rows that +// were already migrated by a partial run are passed through unchanged. +type v1Lyric struct { + DisplayArtist string `json:"displayArtist,omitempty"` + DisplayTitle string `json:"displayTitle,omitempty"` + Lang string `json:"lang"` + Line []v1Line `json:"line,omitempty"` + CueLine []v2CueLine `json:"cueLine,omitempty"` + Offset *int64 `json:"offset,omitempty"` + Synced bool `json:"synced"` +} + +type v1Line struct { + Start *int64 `json:"start,omitempty"` + Value string `json:"value"` +} + +type v2Lyric struct { + DisplayArtist string `json:"displayArtist,omitempty"` + DisplayTitle string `json:"displayTitle,omitempty"` + Lang string `json:"lang"` + Offset *int64 `json:"offset,omitempty"` + Synced bool `json:"synced"` + CueLine []v2CueLine `json:"cueLine,omitempty"` +} + +type v2CueLine struct { + Index int `json:"index"` + Start *int64 `json:"start,omitempty"` + End *int64 `json:"end,omitempty"` + Value string `json:"value"` +} diff --git a/model/lyrics.go b/model/lyrics.go index f75f3b11b..fbc7a55ef 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -11,39 +11,85 @@ import ( "github.com/navidrome/navidrome/utils/str" ) -type Line struct { +// LyricKind enumerates the v2 OpenSubsonic lyric kinds. +type LyricKind string + +const ( + LyricKindMain LyricKind = "main" + LyricKindTranslation LyricKind = "translation" + LyricKindPronunciation LyricKind = "pronunciation" +) + +// Cue is a single sub-line timed unit (a word or syllable) inside a CueLine. +// Used for word-level karaoke timing. +type Cue struct { Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` Value string `structs:"value" json:"value"` } +// CueLine is the canonical v2-shaped representation of a single timed lyric +// line. Index identifies a logical lyrical moment: cuelines that share an +// index are intended to render simultaneously and are disambiguated by AgentID. +// In the absence of overlapping vocals Index equals the cueLine's position in +// the slice. +type CueLine struct { + Index int `structs:"index" json:"index"` + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` +} + +// Agent declares a vocalist referenced by CueLine.AgentID. +type Agent struct { + ID string `structs:"id" json:"id"` + Name string `structs:"name,omitempty" json:"name,omitempty"` + Role string `structs:"role,omitempty" json:"role,omitempty"` +} + +// Lyrics is the canonical v2-shaped lyrics document. The legacy v1 wire shape +// (Line[]) is derived at response build time by collapsing CueLine[]. type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Lang string `structs:"lang" json:"lang"` - 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"` + Lang string `structs:"lang" json:"lang"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` + Kind LyricKind `structs:"kind,omitempty" json:"kind,omitempty"` + Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` + CueLine []CueLine `structs:"cueLine,omitempty" json:"cueLine,omitempty"` +} + +func (l Lyrics) IsEmpty() bool { + return len(l.CueLine) == 0 } // support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?\]` +// ELRC inline word-timing markers, e.g. <00:12.45> at the start of a word. +const wordTimeRegexString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(.[0-9]{1,3})?>` + var ( // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + wordTimeRegex = regexp.MustCompile(wordTimeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) ) -func (l Lyrics) IsEmpty() bool { - return len(l.Line) == 0 -} - +// ToLyrics parses a textual lyrics source (plain text, LRC, or ELRC) into the +// canonical Lyrics structure. ELRC inline word-timing markers (``) +// are recognized within timestamped lines and produce per-cue word-level data. +// CueLine.End is inferred as the next CueLine's Start; the last CueLine's End +// is left nil. func ToLyrics(language, text string) (*Lyrics, error) { text = str.SanitizeText(text) lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) + cueLines := make([]CueLine, 0, len(lines)*2) artist := "" title := "" @@ -51,6 +97,7 @@ func ToLyrics(language, text string) (*Lyrics, error) { synced := syncRegex.MatchString(text) priorLine := "" + priorCues := []Cue(nil) validLine := false repeated := false var timestamps []int64 @@ -63,8 +110,6 @@ func ToLyrics(language, text string) (*Lyrics, error) { } continue } - var text string - var time *int64 = nil if synced { idTag := lrcIdRegex.FindStringSubmatch(line) @@ -105,13 +150,9 @@ func ToLyrics(language, text string) (*Lyrics, error) { } if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } + flushPriorLine(&cueLines, timestamps, priorLine, priorCues) timestamps = nil + priorCues = nil } end := 0 @@ -139,47 +180,127 @@ func ToLyrics(language, text string) (*Lyrics, error) { if end >= len(line) { priorLine = "" } else { - priorLine = strings.TrimSpace(line[end:]) + rest := strings.TrimSpace(line[end:]) + priorLine, priorCues = parseELRCLine(rest) } validLine = true } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, + cueLines = append(cueLines, CueLine{ + Index: len(cueLines), + Value: line, }) } } if validLine { - for idx := range timestamps { - structuredLines = append(structuredLines, Line{ - Start: ×tamps[idx], - Value: strings.TrimSpace(priorLine), - }) - } + flushPriorLine(&cueLines, timestamps, priorLine, priorCues) } // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time + // In this case, sort the lyrics by start time and reassign indices. if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { + slices.SortFunc(cueLines, func(a, b CueLine) int { return cmp.Compare(*a.Start, *b.Start) }) + for i := range cueLines { + cueLines[i].Index = i + } + } + + // Infer end-of-line from the next line's start (line-only data). + for i := 0; i < len(cueLines)-1; i++ { + if cueLines[i].End == nil && cueLines[i+1].Start != nil { + cueLines[i].End = cueLines[i+1].Start + } } lyrics := Lyrics{ DisplayArtist: artist, DisplayTitle: title, Lang: language, - Line: structuredLines, + CueLine: cueLines, Offset: offset, Synced: synced, } return &lyrics, nil } +// flushPriorLine emits one CueLine per accumulated timestamp, copying the +// shared text/cue data into each. The last "Repeated" use-case is preserved +// (a single text repeats at multiple timestamps). +func flushPriorLine(cueLines *[]CueLine, timestamps []int64, text string, cues []Cue) { + trimmed := strings.TrimSpace(text) + for idx := range timestamps { + startCopy := timestamps[idx] + cl := CueLine{ + Index: len(*cueLines), + Start: &startCopy, + Value: trimmed, + } + // Cues are only meaningful for the first occurrence in a repeat; copy + // them onto each cueLine so each independent index has its own cue list. + if len(cues) > 0 { + cl.Cue = make([]Cue, len(cues)) + copy(cl.Cue, cues) + } + *cueLines = append(*cueLines, cl) + } +} + +// parseELRCLine takes the text following a line-level [mm:ss.xx] marker and +// extracts inline ELRC word timestamps `word `. It returns the +// concatenated text (which equals cueLine.value when cues are present) and +// the list of cues. When no inline markers are present the cues slice is nil +// and the returned text is the input unchanged. +func parseELRCLine(text string) (string, []Cue) { + matches := wordTimeRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text, nil + } + + // Anything before the first marker is text without a cue timestamp; we + // fold it onto the start of the first cue (most common case is no + // preamble at all). + var preamble string + first := matches[0] + if first[0] > 0 { + preamble = text[:first[0]] + } + + cues := make([]Cue, 0, len(matches)) + for i, m := range matches { + startMs, err := parseTime(text, m) + if err != nil { + // Malformed timestamp; treat the rest as plain text. + return text, nil + } + ts := startMs + var cueValue string + if i+1 < len(matches) { + cueValue = text[m[1]:matches[i+1][0]] + } else { + cueValue = text[m[1]:] + } + if i == 0 && preamble != "" { + cueValue = preamble + cueValue + } + cues = append(cues, Cue{Start: &ts, Value: cueValue}) + } + + // Set each cue's End = next cue's Start. Last cue's End remains nil. + for i := 0; i < len(cues)-1; i++ { + cues[i].End = cues[i+1].Start + } + + // CueLine.Value is the concatenation of cue values per the v2 spec. + var sb strings.Builder + for _, c := range cues { + sb.WriteString(c.Value) + } + return sb.String(), cues +} + func parseTime(line string, match []int) (int64, error) { var hours, millis int64 var err error diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 644b85ad2..53fe48ec9 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -6,6 +6,19 @@ import ( . "github.com/onsi/gomega" ) +// withEnd takes a slice of CueLine (typically the expected one) and infers +// each non-final cueLine's End from the next cueLine's Start, mirroring the +// behavior baked into ToLyrics. Tests express only Start/Value and rely on +// this helper for End so the expectation tables stay readable. +func withEnd(in []CueLine) []CueLine { + for i := 0; i < len(in)-1; i++ { + if in[i].End == nil && in[i+1].Start != nil { + in[i].End = in[i+1].Start + } + } + return in +} + var _ = Describe("ToLyrics", func() { It("should parse tags with spaces", func() { lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") @@ -27,42 +40,42 @@ var _ = Describe("ToLyrics", func() { lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "Hi there"}, + {Index: 1, Start: new(int64(10040)), Value: ""}, + {Index: 2, Start: new(int64(40000)), Value: "Test"}, + {Index: 3, Start: new(int64(1000 * 60 * 60)), Value: "late"}, + }))) }) It("Should support multiple timestamps per line", func() { lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "Repeated"}, + {Index: 1, Start: new(int64(10000)), Value: "Repeated"}, + {Index: 2, Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Index: 3, Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + }))) }) It("Should support parsing multiline string", func() { lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Index: 1, Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + }))) }) It("Does not match timestamp in middle of line", func() { lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, + Expect(lyrics.CueLine).To(Equal([]CueLine{ + {Index: 0, Value: "This could [00:00:00] be a synced file"}, })) }) @@ -70,18 +83,18 @@ var _ = Describe("ToLyrics", func() { lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Index: 1, Start: new(int64(1000)), Value: "Line 2"}, + }))) }) It("Ignores lines in synchronized lyric prior to first timestamp", func() { lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, + Expect(lyrics.CueLine).To(Equal([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "Text"}, })) }) @@ -89,23 +102,44 @@ var _ = Describe("ToLyrics", func() { lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(1)), Value: "a"}, + {Index: 1, Start: new(int64(10)), Value: "b"}, + {Index: 2, Start: new(int64(100)), Value: "c"}, + }))) }) It("Properly sorts repeated lyrics out of order", func() { lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) + Expect(lyrics.CueLine).To(Equal(withEnd([]CueLine{ + {Index: 0, Start: new(int64(0)), Value: "Repeated"}, + {Index: 1, Start: new(int64(10000)), Value: "Test"}, + {Index: 2, Start: new(int64(40000)), Value: "Not repeated"}, + {Index: 3, Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Index: 4, Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + }))) + }) + + Describe("ELRC inline word timing", func() { + It("parses inline word markers into cues", func() { + lyrics, err := ToLyrics("eng", "[00:00.00]<00:00.10>Hello <00:00.50>world") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.CueLine).To(HaveLen(1)) + cl := lyrics.CueLine[0] + Expect(cl.Value).To(Equal("Hello world")) + Expect(cl.Cue).To(HaveLen(2)) + Expect(*cl.Cue[0].Start).To(Equal(int64(100))) + Expect(cl.Cue[0].Value).To(Equal("Hello ")) + Expect(*cl.Cue[1].Start).To(Equal(int64(500))) + Expect(cl.Cue[1].Value).To(Equal("world")) + // First cue's End is inferred to second cue's Start. + Expect(cl.Cue[0].End).ToNot(BeNil()) + Expect(*cl.Cue[0].End).To(Equal(int64(500))) + // Last cue has no inferred end. + Expect(cl.Cue[1].End).To(BeNil()) + }) }) }) diff --git a/model/lyricsfile.go b/model/lyricsfile.go new file mode 100644 index 000000000..a002b7b20 --- /dev/null +++ b/model/lyricsfile.go @@ -0,0 +1,220 @@ +package model + +import ( + "errors" + "fmt" + "strings" + + "github.com/navidrome/navidrome/utils/str" + "gopkg.in/yaml.v3" +) + +// ParseLyricsfile parses a Lyricsfile YAML document +// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) and produces +// the canonical Lyrics representation. Returns a non-nil error if the input +// is not a valid YAML document or does not appear to be a Lyricsfile +func ParseLyricsfile(text string) (*Lyrics, error) { + var doc lyricsfileDocument + dec := yaml.NewDecoder(strings.NewReader(text)) + dec.KnownFields(false) + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("not a valid YAML document: %w", err) + } + + // Shape validation: a Lyricsfile must have at least version + metadata. + // We accept slightly relaxed inputs (no version) only when both metadata + // and lines are populated + if doc.Version == "" && doc.Metadata.isEmpty() && len(doc.Lines) == 0 && !doc.Metadata.Instrumental { + return nil, errors.New("YAML document does not appear to be a Lyricsfile (missing version, metadata, and lines)") + } + + lyrics := &Lyrics{ + DisplayArtist: str.SanitizeText(doc.Metadata.Artist), + DisplayTitle: str.SanitizeText(doc.Metadata.Title), + Lang: str.SanitizeText(doc.Metadata.Language), + Synced: false, + } + if lyrics.Lang == "" { + lyrics.Lang = "xxx" + } + if doc.Metadata.OffsetMs != 0 { + off := doc.Metadata.OffsetMs + lyrics.Offset = &off + } + + if doc.Metadata.Instrumental { + // Instrumental tracks are represented as an empty cue list with + // Synced=false. Clients infer instrumental status elsewhere. + return lyrics, nil + } + + if len(doc.Lines) == 0 { + return lyrics, nil + } + + cueLines, agents := buildLyricsfileCueLines(doc.Lines) + lyrics.CueLine = cueLines + lyrics.Agents = agents + lyrics.Synced = true + return lyrics, nil +} + +type lyricsfileDocument struct { + Version string `yaml:"version"` + Metadata lyricsfileMetadata `yaml:"metadata"` + Lines []lyricsfileLineEntry `yaml:"lines"` + Plain string `yaml:"plain"` + Extra map[string]yaml.Node `yaml:",inline"` +} + +type lyricsfileMetadata struct { + Title string `yaml:"title"` + Artist string `yaml:"artist"` + Album string `yaml:"album"` + DurationMs int64 `yaml:"duration_ms"` + OffsetMs int64 `yaml:"offset_ms"` + Language string `yaml:"language"` + Instrumental bool `yaml:"instrumental"` +} + +func (m lyricsfileMetadata) isEmpty() bool { + return m.Title == "" && m.Artist == "" && m.Album == "" && + m.DurationMs == 0 && m.OffsetMs == 0 && m.Language == "" && !m.Instrumental +} + +type lyricsfileLineEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` + Words []lyricsfileWordEntry `yaml:"words"` +} + +type lyricsfileWordEntry struct { + Text string `yaml:"text"` + StartMs int64 `yaml:"start_ms"` + EndMs *int64 `yaml:"end_ms"` +} + +// buildLyricsfileCueLines runs the streaming overlap-clustering algorithm over +// the parsed Lyricsfile lines and produces: +// - one CueLine per line, with Index reflecting cluster membership and +// AgentID assigned via the lowest-free voice rule. +// - the synthetic Agents slice. When the song has no overlapping vocals +// (only voice-0 ever used), the Agents slice and per-line AgentID are +// left empty so the wire format stays simple. +func buildLyricsfileCueLines(entries []lyricsfileLineEntry) ([]CueLine, []Agent) { + cueLines := make([]CueLine, 0, len(entries)) + + // Resolved end timestamps for each entry (handles missing end_ms by + // inferring from the next line's start; the last line stays open). + ends := make([]*int64, len(entries)) + for i := range entries { + if entries[i].EndMs != nil { + endCopy := *entries[i].EndMs + ends[i] = &endCopy + } else if i+1 < len(entries) { + startCopy := entries[i+1].StartMs + ends[i] = &startCopy + } + } + + // active maps voice ID -> end_ms of the line currently held by that voice. + active := map[int]int64{} + currentIndex := -1 + maxVoiceUsed := -1 + hasPriorLine := false + + for i, entry := range entries { + // Prune voices whose end <= this line's start. + for v, e := range active { + if e <= entry.StartMs { + delete(active, v) + } + } + + // New cluster when the active set is empty before adding this line. + if !hasPriorLine || len(active) == 0 { + currentIndex++ + } + + // Lowest-free voice ID. + voiceID := 0 + for { + if _, busy := active[voiceID]; !busy { + break + } + voiceID++ + } + if voiceID > maxVoiceUsed { + maxVoiceUsed = voiceID + } + + startCopy := entry.StartMs + cl := CueLine{ + Index: currentIndex, + Start: &startCopy, + End: ends[i], + Value: entry.Text, + AgentID: fmt.Sprintf("voice-%d", voiceID), + Cue: wordsToCues(entry.Words), + } + cueLines = append(cueLines, cl) + + // Track this voice as active until its resolved end. + var endMs int64 + if ends[i] != nil { + endMs = *ends[i] + } else { + // No known end; treat as immediately freed so future lines aren't + // blocked. The voice continues to render based on its Start alone. + endMs = entry.StartMs + } + active[voiceID] = endMs + hasPriorLine = true + } + + // If we never used more than voice-0, the song is monophonic. Strip the + // AgentID fields and return no Agents slice for a clean wire shape. + if maxVoiceUsed <= 0 { + for i := range cueLines { + cueLines[i].AgentID = "" + } + return cueLines, nil + } + + agents := make([]Agent, 0, maxVoiceUsed+1) + for v := 0; v <= maxVoiceUsed; v++ { + a := Agent{ID: fmt.Sprintf("voice-%d", v)} + if v == 0 { + a.Role = string(LyricKindMain) + } + agents = append(agents, a) + } + return cueLines, agents +} + +// wordsToCues converts Lyricsfile word entries into model Cues. Cue.End is +// taken from word.end_ms when present; otherwise inferred from the next word's +// start (last word's End stays nil if it has no explicit end_ms). +func wordsToCues(words []lyricsfileWordEntry) []Cue { + if len(words) == 0 { + return nil + } + cues := make([]Cue, len(words)) + for i, w := range words { + startCopy := w.StartMs + cues[i].Start = &startCopy + cues[i].Value = w.Text + if w.EndMs != nil { + endCopy := *w.EndMs + cues[i].End = &endCopy + } + } + for i := 0; i < len(cues)-1; i++ { + if cues[i].End == nil && cues[i+1].Start != nil { + cues[i].End = cues[i+1].Start + } + } + return cues +} + diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 16142f526..c3d6ebe49 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -105,12 +105,14 @@ var _ = Describe("ToMediaFile", func() { err := json.Unmarshal([]byte(mf.Lyrics), &actual) Expect(err).ToNot(HaveOccurred()) + engStart0 := int64(0) + engStart1 := int64(2500) expected := model.LyricList{ - {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: new(int64(0))}, - {Value: "English SYLT", Start: new(int64(2500))}, + {Lang: "eng", CueLine: []model.CueLine{ + {Index: 0, Value: "This is", Start: &engStart0, End: &engStart1}, + {Index: 1, Value: "English SYLT", Start: &engStart1}, }, Synced: true}, - {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, + {Lang: "xxx", CueLine: []model.CueLine{{Index: 0, Value: "Lyrics"}}, Synced: false}, } sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang }) sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang }) diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..cb759d3da 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -52,8 +52,8 @@ var _ = Describe("LyricsPlugin", Ordered, func() { result, err := provider.GetLyrics(GinkgoT().Context(), track) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) - Expect(result[0].Line).ToNot(BeEmpty()) - Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song")) + Expect(result[0].CueLine).ToNot(BeEmpty()) + Expect(result[0].CueLine[0].Value).To(ContainSubstring("Test Song")) }) It("defaults language to 'xxx' when plugin does not provide one", func() { diff --git a/scanner/metadata_old/metadata_test.go b/scanner/metadata_old/metadata_test.go index 444bb7fc4..cf5398b7a 100644 --- a/scanner/metadata_old/metadata_test.go +++ b/scanner/metadata_old/metadata_test.go @@ -20,20 +20,21 @@ var _ = Describe("Tags", func() { var secondTs int64 = 2500 makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics { - lines := []model.Line{ - {Value: "This is"}, - {Value: secondLine}, + cueLines := []model.CueLine{ + {Index: 0, Value: "This is"}, + {Index: 1, Value: secondLine}, } if synced { - lines[0].Start = &zero - lines[1].Start = &secondTs + cueLines[0].Start = &zero + cueLines[0].End = &secondTs + cueLines[1].Start = &secondTs } lyrics := model.Lyrics{ - Lang: lang, - Line: lines, - Synced: synced, + Lang: lang, + CueLine: cueLines, + Synced: synced, } return lyrics @@ -45,7 +46,7 @@ var _ = Describe("Tags", func() { if langDiff != 0 { return langDiff } - return cmp.Compare(a.Line[1].Value, b.Line[1].Value) + return cmp.Compare(a.CueLine[1].Value, b.CueLine[1].Value) }) return lines diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e4c39e373..21e25b59f 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -494,15 +494,10 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { - lines := make([]responses.Line, len(lyrics.Line)) - - for i, line := range lyrics.Line { - lines[i] = responses.Line{ - Start: line.Start, - Value: line.Value, - } - } +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { + // V1 line-level shape: collapse one CueLine per logical moment (lowest + // AgentID wins when multiple agents share an Index). + lines := buildV1Lines(lyrics.CueLine) structured := responses.StructuredLyric{ DisplayArtist: lyrics.DisplayArtist, @@ -513,6 +508,17 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St Synced: lyrics.Synced, } + if enhanced { + structured.Kind = string(lyrics.Kind) + if len(lyrics.Agents) > 0 { + structured.Agents = make([]responses.Agent, len(lyrics.Agents)) + for i, a := range lyrics.Agents { + structured.Agents[i] = responses.Agent{ID: a.ID, Name: a.Name, Role: a.Role} + } + } + structured.CueLine = buildV2CueLines(lyrics.CueLine) + } + if structured.DisplayArtist == "" { structured.DisplayArtist = mf.Artist } @@ -523,11 +529,112 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St return structured } -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { +// buildV1Lines collapses canonical CueLines into the v1 wire shape. When +// multiple cuelines share an Index (overlapping vocals), only the first one +// (lowest agentID) is included to keep v1-only clients deterministic. +func buildV1Lines(cueLines []model.CueLine) []responses.Line { + if len(cueLines) == 0 { + return nil + } + lines := make([]responses.Line, 0, len(cueLines)) + seenIndex := -1 + for _, cl := range cueLines { + if cl.Index == seenIndex { + continue + } + seenIndex = cl.Index + lines = append(lines, responses.Line{ + Start: cl.Start, + Value: cl.Value, + }) + } + return lines +} + +// buildV2CueLines emits the v2 cueLine[] structure. Line-only cuelines (no +// per-word data) are skipped +func buildV2CueLines(cueLines []model.CueLine) []responses.CueLine { + if len(cueLines) == 0 { + return nil + } + out := make([]responses.CueLine, 0, len(cueLines)) + for _, cl := range cueLines { + if len(cl.Cue) == 0 { + continue + } + out = append(out, responses.CueLine{ + Index: cl.Index, + Start: cl.Start, + End: cl.End, + Value: cl.Value, + AgentID: cl.AgentID, + Cue: buildCue(cl), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +// buildCue maps each model.Cue to one responses.Cue with inclusive UTF-8 +// ByteStart/ByteEnd offsets into cl.Value. +func buildCue(cl model.CueLine) []responses.Cue { + if len(cl.Cue) == 0 { + return nil + } + + ends := make([]*int64, len(cl.Cue)) + for i, c := range cl.Cue { + ends[i] = c.End + } + if ends[len(ends)-1] == nil && cl.End != nil { + e := *cl.End + ends[len(ends)-1] = &e + } + allHaveEnd := true + anyEnd := false + for _, e := range ends { + if e != nil { + anyEnd = true + } else { + allHaveEnd = false + } + } + if anyEnd && !allHaveEnd { + for i := range ends { + ends[i] = nil + } + } + + cues := make([]responses.Cue, len(cl.Cue)) + var byteCursor int64 + for i, c := range cl.Cue { + valueBytes := int64(len(c.Value)) + bs := byteCursor + be := bs + if valueBytes > 0 { + be = bs + valueBytes - 1 + byteCursor = be + 1 + } + bsCopy, beCopy := bs, be + + cues[i] = responses.Cue{ + Start: c.Start, + End: ends[i], + Value: c.Value, + ByteStart: &bsCopy, + ByteEnd: &beCopy, + } + } + return cues +} + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { lyricList := make(responses.StructuredLyrics, len(lyricsList)) for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) } res := &responses.LyricsList{ diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 9ab3a20b0..3d43ab491 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -121,8 +121,8 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range structuredLyrics[0].Line { - lyricsText.WriteString(line.Value + "\n") + for _, cl := range structuredLyrics[0].CueLine { + lyricsText.WriteString(cl.Value + "\n") } lyricsResponse.Value = lyricsText.String() @@ -131,10 +131,12 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) { - id, err := req.Params(r).String("id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } + enhanced, _ := p.Bool("enhanced") mediaFile, err := api.ds.MediaFile(r.Context()).Get(id) if err != nil { @@ -147,7 +149,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro } response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 12c0dff56..5bd0b5fe8 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -316,6 +316,71 @@ var _ = Describe("MediaRetrievalController", func() { }, }) }) + + When("enhanced=true is requested", func() { + setLyrics := func(lrc string) { + parsed, err := model.ToLyrics("eng", lrc) + Expect(err).ToNot(HaveOccurred()) + lyricsJson, err := json.Marshal(model.LyricList{*parsed}) + Expect(err).ToNot(HaveOccurred()) + mockRepo.SetData(model.MediaFiles{{ + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + }}) + } + + It("omits cueLine entirely when all lines are line-only LRC", func() { + setLyrics(syncedLyrics) + + response, err := router.GetLyricsBySongId(newGetRequest("id=1", "enhanced=true")) + Expect(err).ToNot(HaveOccurred()) + Expect(response.LyricsList.StructuredLyrics).To(HaveLen(1)) + Expect(response.LyricsList.StructuredLyrics[0].Line).To(HaveLen(2)) + Expect(response.LyricsList.StructuredLyrics[0].CueLine).To(BeNil()) + }) + + It("emits cueLine with per-word cue[] and inclusive byte offsets for ELRC word data, skipping line-only lines", func() { + setLyrics("[00:18.80]<00:18.80>We're <00:19.20>no <00:19.50>strangers\n[00:22.801]Line without word data") + + response, err := router.GetLyricsBySongId(newGetRequest("id=1", "enhanced=true")) + Expect(err).ToNot(HaveOccurred()) + Expect(response.LyricsList.StructuredLyrics).To(HaveLen(1)) + structured := response.LyricsList.StructuredLyrics[0] + + Expect(structured.Line).To(HaveLen(2)) + + Expect(structured.CueLine).To(HaveLen(1)) + cl := structured.CueLine[0] + Expect(cl.Index).To(Equal(0)) + Expect(cl.Value).To(Equal("We're no strangers")) + Expect(cl.Start).ToNot(BeNil()) + Expect(*cl.Start).To(Equal(int64(18800))) + Expect(cl.End).ToNot(BeNil()) + Expect(*cl.End).To(Equal(int64(22801))) + + Expect(cl.Cue).To(HaveLen(3)) + + Expect(cl.Cue[0].Value).To(Equal("We're ")) + Expect(*cl.Cue[0].Start).To(Equal(int64(18800))) + Expect(*cl.Cue[0].End).To(Equal(int64(19200))) + Expect(*cl.Cue[0].ByteStart).To(Equal(int64(0))) + Expect(*cl.Cue[0].ByteEnd).To(Equal(int64(5))) + + Expect(cl.Cue[1].Value).To(Equal("no ")) + Expect(*cl.Cue[1].Start).To(Equal(int64(19200))) + Expect(*cl.Cue[1].End).To(Equal(int64(19500))) + Expect(*cl.Cue[1].ByteStart).To(Equal(int64(6))) + Expect(*cl.Cue[1].ByteEnd).To(Equal(int64(8))) + + Expect(cl.Cue[2].Value).To(Equal("strangers")) + Expect(*cl.Cue[2].Start).To(Equal(int64(19500))) + Expect(*cl.Cue[2].End).To(Equal(int64(22801))) + Expect(*cl.Cue[2].ByteStart).To(Equal(int64(9))) + Expect(*cl.Cue[2].ByteEnd).To(Equal(int64(17))) + }) + }) }) }) diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 85edb1012..97b3cafcc 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,7 +11,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1, 2}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 3ccbf232e..e4217303f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -58,7 +58,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(6), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), @@ -88,7 +88,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index dcb458932..a760b4c2f 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -547,13 +547,39 @@ type Line struct { Value string `xml:",chardata" json:"value"` } +type Cue struct { + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart *int64 `xml:"byteStart,attr,omitempty" json:"byteStart,omitempty"` + ByteEnd *int64 `xml:"byteEnd,attr,omitempty" json:"byteEnd,omitempty"` + Value string `xml:",chardata" json:"value"` +} + +type CueLine struct { + Index int `xml:"index,attr" json:"index"` + Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` + End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []Cue `xml:"cue,omitempty" json:"cue,omitempty"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` + Role string `xml:"role,attr,omitempty" json:"role,omitempty"` +} + type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - 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"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` } type StructuredLyrics []StructuredLyric