mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
* fix(lyrics): correct TTML background-vocal cue timing and whitespace Two parsing defects surfaced by Apple Music TTML files that mix a main vocal with an x-bg (background) span group within the same line: - Cue end-time normalization ran over the whole line's cue list in document order. Background cues are stored after the main cues but interleave earlier on the timeline, so the next-cue clamp collapsed the last main cue's end down to its own start (start == end). End times are now normalized per agent group, matching how the Subsonic serializer already groups cues, so parallel layers no longer corrupt each other. - Whitespace between elements was treated as significant: pretty-printed (indented) TTML injected spurious newlines into the line text, turning one line into many. Per TTML2 default xml:space handling (linefeeds treat-as-space, whitespace-collapse), formatting whitespace now collapses to a single space and hard line breaks come only from <br/>. The line-level value and per-agent cueLine.value remain the full line text, as required by the OpenSubsonic songLyrics v2 contract; the per-agent text is carried in each cueLine's cue[] array. Two existing tests that encoded the buggy newline-as-break behavior are corrected; new tests cover whitespace collapse, <br/> preservation, and interleaved background cue timing. * fix(lyrics): only collapse XML whitespace, preserve other Unicode spaces Whitespace collapsing used unicode.IsSpace, which matches more than the XML S production (space, tab, CR, LF): it also folds characters like NBSP and U+3000 into a regular space, silently altering content. Restrict collapsing to the four XML whitespace characters so other Unicode spaces pass through unchanged, and add a regression test. Also clarify the doc comment that collapsing is applied unconditionally (xml:space="preserve" is not supported).
171 lines
4.1 KiB
Go
171 lines
4.1 KiB
Go
package model
|
|
|
|
import (
|
|
"slices"
|
|
|
|
"github.com/navidrome/navidrome/utils/gg"
|
|
)
|
|
|
|
func normalizeLyrics(lyrics Lyrics) Lyrics {
|
|
lyrics.Line = normalizeCueLines(lyrics.Line)
|
|
if len(lyrics.Agents) == 0 {
|
|
lyrics.Agents = nil
|
|
}
|
|
return lyrics
|
|
}
|
|
|
|
func normalizeCueLines(lines []Line) []Line {
|
|
if len(lines) == 0 {
|
|
return lines
|
|
}
|
|
|
|
normalized := make([]Line, len(lines))
|
|
copy(normalized, lines)
|
|
|
|
for i := range normalized {
|
|
if len(normalized[i].Cue) > 0 {
|
|
normalized[i].Cue = slices.Clone(normalized[i].Cue)
|
|
}
|
|
|
|
var fallbackEnd *int64
|
|
if normalized[i].End != nil {
|
|
v := *normalized[i].End
|
|
fallbackEnd = &v
|
|
} else if i+1 < len(normalized) && normalized[i+1].Start != nil {
|
|
v := *normalized[i+1].Start
|
|
fallbackEnd = &v
|
|
}
|
|
|
|
normalized[i] = normalizeCueLine(normalized[i], fallbackEnd)
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
func normalizeLineTiming(line Line) Line {
|
|
if len(line.Cue) == 0 {
|
|
return line
|
|
}
|
|
|
|
var earliestStart *int64
|
|
var latestEnd *int64
|
|
for i := range line.Cue {
|
|
token := line.Cue[i]
|
|
if token.Start != nil {
|
|
if earliestStart == nil || *token.Start < *earliestStart {
|
|
v := *token.Start
|
|
earliestStart = &v
|
|
}
|
|
}
|
|
|
|
candidateEnd := token.End
|
|
if candidateEnd == nil {
|
|
candidateEnd = token.Start
|
|
}
|
|
if candidateEnd != nil {
|
|
if latestEnd == nil || *candidateEnd > *latestEnd {
|
|
v := *candidateEnd
|
|
latestEnd = &v
|
|
}
|
|
}
|
|
}
|
|
|
|
if line.Start == nil && earliestStart != nil {
|
|
v := *earliestStart
|
|
line.Start = &v
|
|
}
|
|
if line.End == nil && latestEnd != nil {
|
|
v := *latestEnd
|
|
line.End = &v
|
|
}
|
|
return line
|
|
}
|
|
|
|
func normalizeCueLine(line Line, fallbackEnd *int64) Line {
|
|
if len(line.Cue) == 0 {
|
|
return line
|
|
}
|
|
line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd)
|
|
return normalizeLineTiming(line)
|
|
}
|
|
|
|
// normalizeCueEndsByAgent resolves cue end times independently per agent so that
|
|
// background (or other parallel) layers, whose cues interleave with the main
|
|
// timeline but are stored together in document order, do not clamp each other's
|
|
// ends. Each agent group is normalized in its own document order; results are
|
|
// reassembled into the original cue positions.
|
|
func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue {
|
|
groups := make(map[string][]int)
|
|
order := make([]string, 0, 2)
|
|
for i := range cues {
|
|
id := cues[i].AgentID
|
|
if _, ok := groups[id]; !ok {
|
|
order = append(order, id)
|
|
}
|
|
groups[id] = append(groups[id], i)
|
|
}
|
|
|
|
// Single agent: the document order already matches the timeline, so the
|
|
// straightforward normalization applies without regrouping.
|
|
if len(order) <= 1 {
|
|
return NormalizeCueEnds(cues, fallbackEnd)
|
|
}
|
|
|
|
out := slices.Clone(cues)
|
|
for _, id := range order {
|
|
idxs := groups[id]
|
|
group := make([]Cue, len(idxs))
|
|
for gi, pos := range idxs {
|
|
group[gi] = cues[pos]
|
|
}
|
|
group = NormalizeCueEnds(group, fallbackEnd)
|
|
for gi, pos := range idxs {
|
|
out[pos] = group[gi]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends
|
|
// per agent group while building the response; all other normalization is
|
|
// package-internal.
|
|
func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue {
|
|
if len(cues) == 0 {
|
|
return cues
|
|
}
|
|
|
|
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 = gg.Clone(end)
|
|
}
|
|
|
|
for i := range out {
|
|
if out[i].End == nil {
|
|
for j := range out {
|
|
out[j].End = nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|