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
This commit is contained in:
ranokay 2026-02-22 21:05:14 +02:00
parent 2f6f0bca79
commit 1d78373b77
No known key found for this signature in database
10 changed files with 226 additions and 159 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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