From 9b862e88337e5f3fe7f8d8d6ed1fdac2c6c5151b Mon Sep 17 00:00:00 2001 From: Yuuta <61791392+ranokay@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:14:18 +0300 Subject: [PATCH] fix(subsonic): emit agent-specific cueLine values (#5679) * fix(subsonic): emit agent-specific cue line values * fix(subsonic): preserve cue line edge text --- server/subsonic/lyrics.go | 88 ++++++++++++- server/subsonic/lyrics_test.go | 222 ++++++++++++++++++++++++++++++++- 2 files changed, 304 insertions(+), 6 deletions(-) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go index ce3c3fae4..bfe1f1899 100644 --- a/server/subsonic/lyrics.go +++ b/server/subsonic/lyrics.go @@ -114,12 +114,17 @@ func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses cueLines := make([]responses.CueLine, 0, len(agentOrder)) for _, agentID := range agentOrder { + value := line.Value + cues := cuesByAgent[agentID] + if len(agentOrder) > 1 { + value, cues = buildAgentCueLineValue(line.Value, cues, line.Cue, agentID) + } cueLine := responses.CueLine{ Index: index, Start: line.Start, End: line.End, - Value: line.Value, - Cue: buildLyricCues(cuesByAgent[agentID], line.End), + Value: value, + Cue: buildLyricCues(cues, line.End), } if agentID != "" { cueLine.AgentID = agentID @@ -149,6 +154,85 @@ func (a lyricAgents) less(left, right string, origI, origJ int) bool { return origI < origJ } +func buildAgentCueLineValue(lineValue string, cues, allCues []model.Cue, agentID string) (string, []model.Cue) { + if len(cues) == 0 { + return "", nil + } + + remapped := slices.Clone(cues) + var value strings.Builder + leadingGap := cueLineGap(lineValue, 0, remapped[0].ByteStart, allCues, agentID) + if strings.TrimSpace(leadingGap) != "" { + value.WriteString(leadingGap) + } + + previousEnd := -1 + for i := range remapped { + originalStart := remapped[i].ByteStart + originalEnd := remapped[i].ByteEnd + if i > 0 { + value.WriteString(cueLineGap(lineValue, previousEnd+1, originalStart, allCues, agentID)) + } + + remapped[i].ByteStart = value.Len() + value.WriteString(remapped[i].Value) + remapped[i].ByteEnd = value.Len() - 1 + previousEnd = originalEnd + } + + trailingGap := cueLineGap(lineValue, previousEnd+1, len(lineValue), allCues, agentID) + if strings.TrimSpace(trailingGap) != "" { + value.WriteString(trailingGap) + } + return value.String(), remapped +} + +type byteRange struct { + start int + end int +} + +func cueLineGap(source string, start, end int, allCues []model.Cue, agentID string) string { + start = max(start, 0) + end = min(end, len(source)) + if start >= end { + return "" + } + + excluded := make([]byteRange, 0, 1) + for _, cue := range allCues { + if strings.TrimSpace(cue.AgentID) == agentID { + continue + } + cueStart := max(cue.ByteStart, start) + cueEnd := min(cue.ByteEnd+1, end) + if cueStart < cueEnd { + excluded = append(excluded, byteRange{start: cueStart, end: cueEnd}) + } + } + + if len(excluded) == 0 { + return source[start:end] + } + + sort.SliceStable(excluded, func(i, j int) bool { + return excluded[i].start < excluded[j].start + }) + + var gap strings.Builder + cursor := start + for _, r := range excluded { + if r.start > cursor { + gap.WriteString(source[cursor:r.start]) + } + cursor = max(cursor, r.end) + } + if cursor < end { + gap.WriteString(source[cursor:end]) + } + return gap.String() +} + func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { if len(cues) == 0 { return nil diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index f4ba6208a..3d9881872 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -431,7 +431,7 @@ var _ = Describe("GetLyricsBySongId", func() { Index: 0, Start: &lineStart, End: &lineEnd, - Value: "Hello echo", + Value: "Hello", AgentID: "lead", Cue: []responses.LyricCue{ { @@ -447,14 +447,14 @@ var _ = Describe("GetLyricsBySongId", func() { Index: 0, Start: &lineStart, End: &lineEnd, - Value: "Hello echo", + Value: "echo", AgentID: "__nd_bg__|lead", Cue: []responses.LyricCue{ { Start: tokenStartB, End: &tokenEndB, - ByteStart: 6, - ByteEnd: 9, + ByteStart: 0, + ByteEnd: 3, Value: "echo", }, }, @@ -465,6 +465,220 @@ var _ = Describe("GetLyricsBySongId", func() { }) }) + It("should preserve shared edge text when remapping agent cue lines", func() { + lineStart := int64(1000) + lineEnd := int64(2000) + cueStart := int64(1200) + cueEnd := int64(1800) + + cueLines := buildCueLines(model.Line{ + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + Cue: []model.Cue{ + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "lead", + }, + { + Start: &cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + AgentID: "__nd_bg__|lead", + }, + }, + }, 0, newLyricAgents([]model.Agent{ + {ID: "lead", Role: "main"}, + {ID: "__nd_bg__|lead", Role: "bg"}, + })) + + Expect(cueLines).To(Equal([]responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(Hello)", + AgentID: "__nd_bg__|lead", + Cue: []responses.LyricCue{ + { + Start: cueStart, + End: &cueEnd, + Value: "Hello", + ByteStart: 1, + ByteEnd: 5, + }, + }, + }, + })) + }) + + It("should remap cue offsets for interleaved agent cue lines", func() { + r := newGetRequest("id=1&enhanced=true") + + lineStart := int64(82889) + lineEnd := int64(86859) + realStart := int64(85593) + realEnd := int64(85934) + slowStart := int64(85934) + slowEnd := int64(86751) + bgStartA := int64(83881) + bgEndA := int64(84243) + bgStartB := int64(86232) + bgEndB := int64(86859) + lyricsJSON, err := json.Marshal(model.LyricList{ + { + Lang: "eng", + Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, + Synced: true, + Line: []model.Line{ + { + Start: &lineStart, + End: &lineEnd, + Value: "real slow (When you slide)", + Cue: []model.Cue{ + { + Start: &realStart, + End: &realEnd, + Value: "real", + ByteStart: 0, + ByteEnd: 3, + AgentID: "v2", + }, + { + Start: &slowStart, + End: &slowEnd, + Value: "slow", + ByteStart: 5, + ByteEnd: 8, + AgentID: "v2", + }, + { + Start: &bgStartA, + End: &bgEndA, + Value: "(When you", + ByteStart: 10, + ByteEnd: 18, + AgentID: "__nd_bg__|v2", + }, + { + Start: &bgStartB, + End: &bgEndB, + Value: "slide)", + ByteStart: 20, + ByteEnd: 25, + AgentID: "__nd_bg__|v2", + }, + }, + }, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + mockRepo.SetData(model.MediaFiles{ + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJSON), + }, + }) + + response, err := router.GetLyricsBySongId(r) + Expect(err).ToNot(HaveOccurred()) + compareResponses(response.LyricsList, responses.LyricsList{ + StructuredLyrics: responses.StructuredLyrics{ + { + DisplayArtist: "Rick Astley", + DisplayTitle: "Never Gonna Give You Up", + Kind: "main", + Lang: "eng", + Synced: true, + Agents: []responses.Agent{ + {ID: "v2", Role: "main"}, + {ID: "__nd_bg__|v2", Role: "bg"}, + }, + Line: []responses.Line{ + { + Start: &lineStart, + Value: "real slow (When you slide)", + }, + }, + CueLine: []responses.CueLine{ + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "real slow", + AgentID: "v2", + Cue: []responses.LyricCue{ + { + Start: realStart, + End: &realEnd, + ByteStart: 0, + ByteEnd: 3, + Value: "real", + }, + { + Start: slowStart, + End: &slowEnd, + ByteStart: 5, + ByteEnd: 8, + Value: "slow", + }, + }, + }, + { + Index: 0, + Start: &lineStart, + End: &lineEnd, + Value: "(When you slide)", + AgentID: "__nd_bg__|v2", + Cue: []responses.LyricCue{ + { + Start: bgStartA, + End: &bgEndA, + ByteStart: 0, + ByteEnd: 8, + Value: "(When you", + }, + { + Start: bgStartB, + End: &bgEndB, + ByteStart: 10, + ByteEnd: 15, + Value: "slide)", + }, + }, + }, + }, + }, + }, + }) + }) + It("should keep enhanced line-level lyrics when no cue data is available", func() { r := newGetRequest("id=1&enhanced=true")