From 7796faa1b2423c0e6af1dbda1d16355711337d49 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 18 Jun 2026 21:02:23 -0400 Subject: [PATCH] refactor(lyrics): share cue end-time normalization across layers buildLyricCues (subsonic response) reimplemented the same fill-from-next / fallback / all-or-none end-time logic that normalizeCueLine (model) already ran at parse time. They operate on different groupings -- the response builder normalizes per-agent cue subsets, the model the whole line -- so they cannot be collapsed, but the algorithm itself was duplicated. Extract model.NormalizeCueEnds([]Cue, fallbackEnd) as the single implementation (now also clamping ends within the model line normalization), and have buildLyricCues delegate to it and only map to the response type. --- model/lyrics.go | 79 +++++++++++++++++++++++--------------- server/subsonic/helpers.go | 50 +++++------------------- 2 files changed, 57 insertions(+), 72 deletions(-) diff --git a/model/lyrics.go b/model/lyrics.go index 527a8cc91..c16b45b90 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -467,39 +467,56 @@ func normalizeCueLine(line Line, fallbackEnd *int64) Line { if len(line.Cue) == 0 { return line } - - for i := range line.Cue { - if line.Cue[i].End != nil { - continue - } - - if i+1 < len(line.Cue) && line.Cue[i+1].Start != nil { - v := *line.Cue[i+1].Start - line.Cue[i].End = &v - continue - } - - if fallbackEnd != nil { - v := *fallbackEnd - line.Cue[i].End = &v - } - } - - for i := range line.Cue { - if line.Cue[i].End == nil { - line.Cue = clearCueEnds(line.Cue) - return NormalizeLineTiming(line) - } - } - + line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) return NormalizeLineTiming(line) } -func clearCueEnds(cues []Cue) []Cue { - normalized := make([]Cue, len(cues)) - copy(normalized, cues) - for i := range normalized { - normalized[i].End = nil +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues } - return normalized + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = ptrOrNil(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} + +// ptrOrNil returns a fresh copy of *v so callers never alias another cue's +// Start/End pointer into the result. +func ptrOrNil(v *int64) *int64 { + if v == nil { + return nil + } + c := *v + return &c } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index c75d38fdc..363305935 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -606,20 +606,18 @@ func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { return nil } - hasAnyEnd := false - for i := range cues { - if cues[i].End != nil { - hasAnyEnd = true - break - } + // Only resolve end times when at least one cue carries one; otherwise the + // group is start-only and must stay that way. + hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) + if hasAnyEnd { + cues = model.NormalizeCueEnds(cues, lineEnd) } - normalized := make([]responses.LyricCue, 0, len(cues)) + out := make([]responses.LyricCue, 0, len(cues)) for i := range cues { if cues[i].Start == nil { continue } - cue := responses.LyricCue{ Start: *cues[i].Start, Value: cues[i].Value, @@ -627,41 +625,11 @@ func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { ByteEnd: cues[i].ByteEnd, } if hasAnyEnd { - end := cues[i].End - if end == nil { - if i+1 < len(cues) && cues[i+1].Start != nil { - v := *cues[i+1].Start - end = &v - } else if lineEnd != nil { - v := *lineEnd - end = &v - } - } - if end != nil && i+1 < len(cues) && cues[i+1].Start != nil && *end > *cues[i+1].Start { - v := *cues[i+1].Start - end = &v - } - if end != nil && *end < cue.Start { - v := cue.Start - end = &v - } - cue.End = end + cue.End = cues[i].End } - normalized = append(normalized, cue) + out = append(out, cue) } - - if hasAnyEnd { - for i := range normalized { - if normalized[i].End == nil { - for j := range normalized { - normalized[j].End = nil - } - break - } - } - } - - return normalized + return out } func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList {