mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
2f6f0bca79
commit
1d78373b77
@ -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
|
||||
|
||||
@ -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"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user