From 1d78373b778008f8e994dbe85cbd53d124696d27 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 22 Feb 2026 21:05:14 +0200 Subject: [PATCH] refactor: align with OpenSubsonic spec feedback - Rename token/tokenLine to cue/cueLine across Go backend and JS frontend - Move role from individual cue to cueLine level (server pre-splits by role) - Add enhanced query parameter to getLyricsBySongId for backward compat - Add enhanced=true to UI API client so translations/pronunciations load - Update all Go and JS tests to match new naming and structure --- core/lyrics/ttml.go | 28 +++---- core/lyrics/ttml_test.go | 28 +++---- model/lyrics.go | 10 +-- server/subsonic/helpers.go | 70 ++++++++++------ server/subsonic/media_retrieval.go | 4 +- server/subsonic/media_retrieval_test.go | 70 ++++++++-------- server/subsonic/responses/responses.go | 32 ++++---- ui/src/audioplayer/lyrics.js | 101 +++++++++++++++--------- ui/src/audioplayer/lyrics.test.js | 38 +++++---- ui/src/subsonic/index.js | 4 +- 10 files changed, 226 insertions(+), 159 deletions(-) diff --git a/core/lyrics/ttml.go b/core/lyrics/ttml.go index 3aae53aa0..a0bdcac5a 100644 --- a/core/lyrics/ttml.go +++ b/core/lyrics/ttml.go @@ -162,7 +162,7 @@ func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingConte parsedLine.End = &endMs } if len(tokens) > 0 { - parsedLine.Token = tokens + parsedLine.Cue = tokens } parsedLine = hydrateLineTimingFromTokens(parsedLine) @@ -261,20 +261,20 @@ func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTiming line.End = &endMs } if len(tokens) > 0 { - line.Token = tokens + line.Cue = tokens } line = hydrateLineTimingFromTokens(line) - if line.Value == "" && len(line.Token) == 0 { + if line.Value == "" && len(line.Cue) == 0 { return ttmlMetadataEntry{}, false, nil } return ttmlMetadataEntry{key: forKey, line: line}, true, nil } -func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []model.Token, error) { +func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []model.Cue, error) { var text strings.Builder - var tokens []model.Token + var tokens []model.Cue for { token, err := p.decoder.Token() @@ -300,7 +300,7 @@ func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []model.T } } -func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) (string, []model.Token, error) { +func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) (string, []model.Cue, error) { local := strings.ToLower(start.Name.Local) if local == "br" { return "\n", nil, nil @@ -313,7 +313,7 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin hasOwnTiming := hasBegin || hasEnd || hasDur var text strings.Builder - var tokens []model.Token + var tokens []model.Cue for { token, err := p.decoder.Token() @@ -337,7 +337,7 @@ func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimin value := text.String() tokenText := sanitizeTTMLText(value) if local == "span" && hasOwnTiming && !ctx.invalid && tokenText != "" && len(tokens) == 0 { - parsedToken := model.Token{ + parsedToken := model.Cue{ Value: tokenText, Role: ctx.role, } @@ -413,7 +413,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie } line = hydrateLineTimingFromTokens(line) - if line.Value == "" && len(line.Token) == 0 { + if line.Value == "" && len(line.Cue) == 0 { continue } @@ -830,8 +830,8 @@ func linesAreSynced(lines []model.Line) bool { if lines[i].Start != nil { return true } - for j := range lines[i].Token { - if lines[i].Token[j].Start != nil { + for j := range lines[i].Cue { + if lines[i].Cue[j].Start != nil { return true } } @@ -840,14 +840,14 @@ func linesAreSynced(lines []model.Line) bool { } func hydrateLineTimingFromTokens(line model.Line) model.Line { - if len(line.Token) == 0 { + if len(line.Cue) == 0 { return line } var earliestStart *int64 var latestEnd *int64 - for i := range line.Token { - token := line.Token[i] + for i := range line.Cue { + token := line.Cue[i] if token.Start != nil { if earliestStart == nil || *token.Start < *earliestStart { v := *token.Start diff --git a/core/lyrics/ttml_test.go b/core/lyrics/ttml_test.go index c8596243b..8ec16f679 100644 --- a/core/lyrics/ttml_test.go +++ b/core/lyrics/ttml_test.go @@ -135,11 +135,11 @@ var _ = Describe("parseTTML", func() { Expect(line.Start).To(Equal(gg.P(int64(1000)))) Expect(line.Value).To(Equal("Hello\necho")) Expect(line.End).To(Equal(gg.P(int64(3000)))) - Expect(line.Token).To(HaveLen(3)) + Expect(line.Cue).To(HaveLen(3)) - Expect(line.Token[0]).To(Equal(model.Token{Start: gg.P(int64(1000)), End: gg.P(int64(1400)), Value: "He"})) - Expect(line.Token[1]).To(Equal(model.Token{Start: gg.P(int64(1400)), End: gg.P(int64(1800)), Value: "llo"})) - Expect(line.Token[2]).To(Equal(model.Token{Start: gg.P(int64(2000)), End: gg.P(int64(2500)), Value: "echo", Role: "x-bg"})) + Expect(line.Cue[0]).To(Equal(model.Cue{Start: gg.P(int64(1000)), End: gg.P(int64(1400)), Value: "He"})) + Expect(line.Cue[1]).To(Equal(model.Cue{Start: gg.P(int64(1400)), End: gg.P(int64(1800)), Value: "llo"})) + Expect(line.Cue[2]).To(Equal(model.Cue{Start: gg.P(int64(2000)), End: gg.P(int64(2500)), Value: "echo", Role: "x-bg"})) }) }) @@ -166,9 +166,9 @@ var _ = Describe("parseTTML", func() { Expect(line.Start).To(Equal(gg.P(int64(43444)))) Expect(line.Value).To(Equal("go\ngo")) Expect(line.End).To(Equal(gg.P(int64(45570)))) - Expect(line.Token).To(HaveLen(2)) - Expect(line.Token[0]).To(Equal(model.Token{Start: gg.P(int64(43444)), End: gg.P(int64(43716)), Value: "go"})) - Expect(line.Token[1]).To(Equal(model.Token{Start: gg.P(int64(43716)), End: gg.P(int64(43887)), Value: "go"})) + Expect(line.Cue).To(HaveLen(2)) + Expect(line.Cue[0]).To(Equal(model.Cue{Start: gg.P(int64(43444)), End: gg.P(int64(43716)), Value: "go"})) + Expect(line.Cue[1]).To(Equal(model.Cue{Start: gg.P(int64(43716)), End: gg.P(int64(43887)), Value: "go"})) }) }) @@ -250,9 +250,9 @@ var _ = Describe("parseTTML", func() { Expect(pronunciation.Line[0].Start).To(Equal(gg.P(int64(2000)))) Expect(pronunciation.Line[0].Value).To(Equal("konni")) Expect(pronunciation.Line[0].End).To(Equal(gg.P(int64(2600)))) - Expect(pronunciation.Line[0].Token).To(HaveLen(2)) - Expect(pronunciation.Line[0].Token[0]).To(Equal(model.Token{Start: gg.P(int64(2000)), End: gg.P(int64(2300)), Value: "ko"})) - Expect(pronunciation.Line[0].Token[1]).To(Equal(model.Token{Start: gg.P(int64(2300)), End: gg.P(int64(2600)), Value: "nni"})) + Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) + Expect(pronunciation.Line[0].Cue[0]).To(Equal(model.Cue{Start: gg.P(int64(2000)), End: gg.P(int64(2300)), Value: "ko"})) + Expect(pronunciation.Line[0].Cue[1]).To(Equal(model.Cue{Start: gg.P(int64(2300)), End: gg.P(int64(2600)), Value: "nni"})) }) }) @@ -294,10 +294,10 @@ var _ = Describe("parseTTML", func() { line := pronunciation.Line[0] Expect(line.Start).To(Equal(gg.P(int64(2747)))) Expect(line.Value).To(Equal("I woke up")) - Expect(line.Token).To(HaveLen(3)) - Expect(line.Token[0]).To(Equal(model.Token{Start: gg.P(int64(2747)), End: gg.P(int64(3018)), Value: "I"})) - Expect(line.Token[1]).To(Equal(model.Token{Start: gg.P(int64(3018)), End: gg.P(int64(3179)), Value: "woke"})) - Expect(line.Token[2]).To(Equal(model.Token{Start: gg.P(int64(3179)), End: gg.P(int64(3582)), Value: "up"})) + Expect(line.Cue).To(HaveLen(3)) + Expect(line.Cue[0]).To(Equal(model.Cue{Start: gg.P(int64(2747)), End: gg.P(int64(3018)), Value: "I"})) + Expect(line.Cue[1]).To(Equal(model.Cue{Start: gg.P(int64(3018)), End: gg.P(int64(3179)), Value: "woke"})) + Expect(line.Cue[2]).To(Equal(model.Cue{Start: gg.P(int64(3179)), End: gg.P(int64(3582)), Value: "up"})) }) }) }) diff --git a/model/lyrics.go b/model/lyrics.go index 220eec7b5..3cb1cb715 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/utils/str" ) -type Token struct { +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"` @@ -19,10 +19,10 @@ type Token struct { } type Line struct { - Start *int64 `structs:"start,omitempty" json:"start,omitempty"` - End *int64 `structs:"end,omitempty" json:"end,omitempty"` - Value string `structs:"value" json:"value"` - Token []Token `structs:"token,omitempty" json:"token,omitempty"` + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + End *int64 `structs:"end,omitempty" json:"end,omitempty"` + Value string `structs:"value" json:"value"` + Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` } type Lyrics struct { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 3b9412fb1..6922f0683 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -493,35 +493,49 @@ func mapExplicitStatus(explicitStatus string) string { return "" } -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { lines := make([]responses.Line, len(lyrics.Line)) - tokenLines := make([]responses.TokenLine, 0, len(lyrics.Line)) + var cueLines []responses.CueLine for i, line := range lyrics.Line { lines[i] = responses.Line{ Start: line.Start, Value: line.Value, } - if len(line.Token) == 0 { + if !enhanced || len(line.Cue) == 0 { continue } - tokens := make([]responses.LyricToken, len(line.Token)) - for j, token := range line.Token { - tokens[j] = responses.LyricToken{ - Start: token.Start, - End: token.End, - Value: token.Value, - Role: token.Role, + // Group cues by role, preserving order of first appearance + roleOrder := make([]string, 0, 2) + cuesByRole := make(map[string][]responses.LyricCue) + for _, cue := range line.Cue { + role := cue.Role + if _, exists := cuesByRole[role]; !exists { + roleOrder = append(roleOrder, role) } + cuesByRole[role] = append(cuesByRole[role], responses.LyricCue{ + Start: cue.Start, + End: cue.End, + Value: cue.Value, + }) + } + + // Create a separate CueLine for each role group + for _, role := range roleOrder { + cues := cuesByRole[role] + cueLine := responses.CueLine{ + Index: int32(i), + Start: line.Start, + End: line.End, + Value: line.Value, + Cue: cues, + } + if role != "" { + cueLine.Role = role + } + cueLines = append(cueLines, cueLine) } - tokenLines = append(tokenLines, responses.TokenLine{ - Index: int32(i), - Start: line.Start, - End: line.End, - Value: line.Value, - Token: tokens, - }) } kind := strings.TrimSpace(lyrics.Kind) @@ -535,7 +549,7 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St Kind: kind, Lang: lyrics.Lang, Line: lines, - TokenLine: tokenLines, + CueLine: cueLines, Offset: lyrics.Offset, Synced: lyrics.Synced, } @@ -550,11 +564,23 @@ func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.St return structured } -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { - lyricList := make(responses.StructuredLyrics, len(lyricsList)) +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { + var filtered model.LyricList + if enhanced { + filtered = lyricsList + } else { + // Without enhanced, only return "main" kind entries + for _, l := range lyricsList { + kind := strings.TrimSpace(l.Kind) + if kind == "" || kind == "main" { + filtered = append(filtered, l) + } + } + } - for i, lyrics := range lyricsList { - lyricList[i] = buildStructuredLyric(mf, lyrics) + lyricList := make(responses.StructuredLyrics, len(filtered)) + for i, lyrics := range filtered { + lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) } res := &responses.LyricsList{ diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 963db067c..de88849a2 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -149,8 +149,10 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } + enhanced, _ := req.Params(r).Bool("enhanced") + 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 6c52d38bc..7cf96fee5 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -258,36 +258,36 @@ var _ = Describe("MediaRetrievalController", func() { } } - Expect(realLyric.TokenLine).To(HaveLen(len(expectedLyric.TokenLine))) - for j, realTokenLine := range realLyric.TokenLine { - expectedTokenLine := expectedLyric.TokenLine[j] - Expect(realTokenLine.Index).To(Equal(expectedTokenLine.Index)) - Expect(realTokenLine.Value).To(Equal(expectedTokenLine.Value)) - if expectedTokenLine.Start == nil { - Expect(realTokenLine.Start).To(BeNil()) + Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) + for j, realCueLine := range realLyric.CueLine { + expectedCueLine := expectedLyric.CueLine[j] + Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) + Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) + Expect(realCueLine.Role).To(Equal(expectedCueLine.Role)) + if expectedCueLine.Start == nil { + Expect(realCueLine.Start).To(BeNil()) } else { - Expect(*realTokenLine.Start).To(Equal(*expectedTokenLine.Start)) + Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) } - if expectedTokenLine.End == nil { - Expect(realTokenLine.End).To(BeNil()) + if expectedCueLine.End == nil { + Expect(realCueLine.End).To(BeNil()) } else { - Expect(*realTokenLine.End).To(Equal(*expectedTokenLine.End)) + Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) } - Expect(realTokenLine.Token).To(HaveLen(len(expectedTokenLine.Token))) - for k, realToken := range realTokenLine.Token { - expectedToken := expectedTokenLine.Token[k] - Expect(realToken.Value).To(Equal(expectedToken.Value)) - Expect(realToken.Role).To(Equal(expectedToken.Role)) - if expectedToken.Start == nil { - Expect(realToken.Start).To(BeNil()) + Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) + for k, realCue := range realCueLine.Cue { + expectedCue := expectedCueLine.Cue[k] + Expect(realCue.Value).To(Equal(expectedCue.Value)) + if expectedCue.Start == nil { + Expect(realCue.Start).To(BeNil()) } else { - Expect(*realToken.Start).To(Equal(*expectedToken.Start)) + Expect(*realCue.Start).To(Equal(*expectedCue.Start)) } - if expectedToken.End == nil { - Expect(realToken.End).To(BeNil()) + if expectedCue.End == nil { + Expect(realCue.End).To(BeNil()) } else { - Expect(*realToken.End).To(Equal(*expectedToken.End)) + Expect(*realCue.End).To(Equal(*expectedCue.End)) } } } @@ -448,7 +448,7 @@ var _ = Describe("MediaRetrievalController", func() { It("should return metadata-linked translation and pronunciation tracks from TTML", func() { conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("id=1") + r := newGetRequest("id=1&enhanced=true") mockRepo.SetData(model.MediaFiles{ { @@ -513,13 +513,13 @@ var _ = Describe("MediaRetrievalController", func() { Value: "konni", }, }, - TokenLine: []responses.TokenLine{ + CueLine: []responses.CueLine{ { Index: 0, Start: &mainStartB, End: &tokenEndB, Value: "konni", - Token: []responses.LyricToken{ + Cue: []responses.LyricCue{ { Start: &tokenStartA, End: &tokenEndA, @@ -538,8 +538,8 @@ var _ = Describe("MediaRetrievalController", func() { }) }) - It("should return tokenized lines for songLyrics v2 clients", func() { - r := newGetRequest("id=1") + It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { + r := newGetRequest("id=1&enhanced=true") lineStart := int64(1000) lineEnd := int64(3000) @@ -556,7 +556,7 @@ var _ = Describe("MediaRetrievalController", func() { Start: &lineStart, End: &lineEnd, Value: "Hello echo", - Token: []model.Token{ + Cue: []model.Cue{ { Start: &tokenStartA, End: &tokenEndA, @@ -599,23 +599,31 @@ var _ = Describe("MediaRetrievalController", func() { Value: "Hello echo", }, }, - TokenLine: []responses.TokenLine{ + CueLine: []responses.CueLine{ { Index: 0, Start: &lineStart, End: &lineEnd, Value: "Hello echo", - Token: []responses.LyricToken{ + Cue: []responses.LyricCue{ { Start: &tokenStartA, End: &tokenEndA, Value: "Hello", }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "Hello echo", + Role: "x-bg", + Cue: []responses.LyricCue{ { Start: &tokenStartB, End: &tokenEndB, Value: "echo", - Role: "x-bg", }, }, }, diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index ff5ae0d3b..d19f99ca6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -537,30 +537,30 @@ type Line struct { Value string `xml:",chardata" json:"value"` } -type LyricToken struct { +type LyricCue struct { 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"` - Role string `xml:"role,attr,omitempty" json:"role,omitempty"` } -type TokenLine struct { - Index int32 `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,omitempty" json:"value,omitempty"` - Token []LyricToken `xml:"token,omitempty" json:"token,omitempty"` +type CueLine struct { + Index int32 `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,omitempty" json:"value,omitempty"` + Role string `xml:"role,attr,omitempty" json:"role,omitempty"` + Cue []LyricCue `xml:"cue,omitempty" json:"cue,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"` - TokenLine []TokenLine `xml:"tokenLine,omitempty" json:"tokenLine,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"` + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` + Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Synced bool `xml:"synced,attr" json:"synced"` } type StructuredLyrics []StructuredLyric diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index 3dcf9b0f9..111ded02e 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -74,25 +74,25 @@ const normalizeToken = (token) => { start: toTime(token.start), end: toTime(token.end), value, - role: typeof token.role === 'string' ? token.role : '', } } -const normalizeTokenLine = (tokenLine, fallbackIndex) => { - const index = Number.isFinite(Number(tokenLine?.index)) - ? Number(tokenLine.index) +const normalizeCueLine = (cueLine, fallbackIndex) => { + const index = Number.isFinite(Number(cueLine?.index)) + ? Number(cueLine.index) : fallbackIndex const tokens = sortTokensByStart( - Array.isArray(tokenLine?.token) - ? tokenLine.token.map(normalizeToken).filter(Boolean) + Array.isArray(cueLine?.cue) + ? cueLine.cue.map(normalizeToken).filter(Boolean) : [], ) return { index, - start: toTime(tokenLine?.start), - end: toTime(tokenLine?.end), - value: typeof tokenLine?.value === 'string' ? tokenLine.value : '', + start: toTime(cueLine?.start), + end: toTime(cueLine?.end), + value: typeof cueLine?.value === 'string' ? cueLine.value : '', + role: typeof cueLine?.role === 'string' ? cueLine.role : '', tokens, } } @@ -197,14 +197,14 @@ const buildSyntheticWordTokens = (line, token) => { })) } -export const hasTokenTiming = (structuredLyric) => +export const hasCueTiming = (structuredLyric) => Boolean( structuredLyric && - Array.isArray(structuredLyric.tokenLine) && - structuredLyric.tokenLine.some( - (tokenLine) => - Array.isArray(tokenLine?.token) && - tokenLine.token.some((token) => Number.isFinite(Number(token?.start))), + Array.isArray(structuredLyric.cueLine) && + structuredLyric.cueLine.some( + (cueLine) => + Array.isArray(cueLine?.cue) && + cueLine.cue.some((cue) => Number.isFinite(Number(cue?.start))), ), ) @@ -215,7 +215,7 @@ export const hasStructuredLyricContent = (structuredLyric) => structuredLyric.line.some( (line) => typeof line?.value === 'string' && line.value.trim() !== '', )) || - hasTokenTiming(structuredLyric)), + hasCueTiming(structuredLyric)), ) export const getPreferredLyricLanguage = () => { @@ -319,34 +319,57 @@ export const buildKaraokeLines = (structuredLyric) => { const baseLines = Array.isArray(structuredLyric.line) ? structuredLyric.line : [] - const rawTokenLines = Array.isArray(structuredLyric.tokenLine) - ? structuredLyric.tokenLine + const rawCueLines = Array.isArray(structuredLyric.cueLine) + ? structuredLyric.cueLine : [] const lines = - rawTokenLines.length > 0 - ? rawTokenLines.map((tokenLine, fallbackIndex) => { - const normalized = normalizeTokenLine(tokenLine, fallbackIndex) - const baseLine = baseLines[normalized.index] || {} - const tokens = normalized.tokens - const fallbackStart = - tokens.find((token) => token.start != null)?.start ?? null - const fallbackEnd = - [...tokens].reverse().find((token) => token.end != null)?.end ?? - null - const value = - normalized.value || - (typeof baseLine.value === 'string' ? baseLine.value : '') || - tokens.map((token) => token.value).join('') + rawCueLines.length > 0 + ? (() => { + const normalizedCueLines = rawCueLines.map( + (cueLine, fallbackIndex) => { + const normalized = normalizeCueLine(cueLine, fallbackIndex) + return { + ...normalized, + tokens: normalized.tokens.map((token) => ({ + ...token, + role: normalized.role, + })), + } + }, + ) - return { - index: normalized.index, - start: normalized.start ?? toTime(baseLine.start) ?? fallbackStart, - end: normalized.end ?? toTime(baseLine.end) ?? fallbackEnd, - value, - tokens, + const byIndex = new Map() + for (const cl of normalizedCueLines) { + if (!byIndex.has(cl.index)) { + byIndex.set(cl.index, []) + } + byIndex.get(cl.index).push(cl) } - }) + + return Array.from(byIndex.entries()).map(([index, group]) => { + const first = group[0] + const baseLine = baseLines[index] || {} + const tokens = sortTokensByStart(group.flatMap((cl) => cl.tokens)) + const fallbackStart = + tokens.find((token) => token.start != null)?.start ?? null + const fallbackEnd = + [...tokens].reverse().find((token) => token.end != null)?.end ?? + null + const value = + first.value || + (typeof baseLine.value === 'string' ? baseLine.value : '') || + tokens.map((token) => token.value).join('') + + return { + index, + start: first.start ?? toTime(baseLine.start) ?? fallbackStart, + end: first.end ?? toTime(baseLine.end) ?? fallbackEnd, + value, + tokens, + } + }) + })() : baseLines.map((line, index) => ({ index, start: toTime(line.start), diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index c60605a6f..7e0b0d105 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -1,15 +1,15 @@ import { buildKaraokeLines, findLayerLineIndexForMain, - getPreferredLyricLanguage, getActiveKaraokeState, + getPreferredLyricLanguage, hasStructuredLyricContent, pickStructuredLyric, resolveKaraokeTokenWindow, resolveLayerLineForMain, selectLyricLayers, - structuredLyricToLrc, structuredLyricsToLrc, + structuredLyricToLrc, } from './lyrics' describe('lyrics helpers', () => { @@ -200,21 +200,27 @@ describe('lyrics helpers', () => { expect(getPreferredLyricLanguage()).toBe('pt-BR') }) - it('builds karaoke lines from tokenLine payload', () => { + it('builds karaoke lines from cueLine payload', () => { const lines = buildKaraokeLines({ lang: 'eng', synced: true, line: [{ start: 1000, end: 3000, value: 'Hello world' }], - tokenLine: [ + cueLine: [ { index: 0, start: 1000, end: 3000, value: 'Hello world', - token: [ - { start: 1000, end: 1500, value: 'Hello' }, - { start: 2000, end: 2500, value: 'world', role: 'x-bg' }, - ], + role: '', + cue: [{ start: 1000, end: 1500, value: 'Hello' }], + }, + { + index: 0, + start: 1000, + end: 3000, + value: 'Hello world', + role: 'x-bg', + cue: [{ start: 2000, end: 2500, value: 'world' }], }, ], }) @@ -238,15 +244,16 @@ describe('lyrics helpers', () => { lang: 'eng', synced: true, line: [{ start: 1000, end: 3000, value: 'Hello world' }], - tokenLine: [ + cueLine: [ { index: 0, start: 1000, end: 3000, value: 'Hello world', - token: [ - { start: 2000, end: 2500, value: 'world', role: '' }, - { start: 1000, end: 1500, value: 'Hello', role: '' }, + role: '', + cue: [ + { start: 2000, end: 2500, value: 'world' }, + { start: 1000, end: 1500, value: 'Hello' }, ], }, ], @@ -263,13 +270,14 @@ describe('lyrics helpers', () => { lang: 'ko-Latn', synced: true, line: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }], - tokenLine: [ + cueLine: [ { index: 0, start: 1000, end: 2000, value: 'Da-la-lun, dun', - token: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }], + role: '', + cue: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }], }, ], }) @@ -409,7 +417,7 @@ describe('lyrics helpers', () => { it('reports structured lyric content when token timing exists', () => { expect( hasStructuredLyricContent({ - tokenLine: [{ token: [{ start: 100, value: 'a' }] }], + cueLine: [{ cue: [{ start: 100, value: 'a' }] }], }), ).toBe(true) }) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index b311d5e14..47ebabe99 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,5 @@ -import { baseUrl } from '../utils' import { httpClient } from '../dataProvider' +import { baseUrl } from '../utils' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -121,7 +121,7 @@ const getTopSongs = (artist, count = 50) => { } const getLyricsBySongId = (id) => { - return httpClient(url('getLyricsBySongId', id)) + return httpClient(url('getLyricsBySongId', id, { enhanced: true })) } const streamUrl = (id, options) => {