navidrome/model/lyrics_lrc.go
Jorge Pardo 11f5441eb5
Some checks are pending
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
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 / Package/Release (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 / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (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 / 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
fix(lyrics): consider trailing timestamp in ELRC lyrics (#5677)
* fix: take into account trailing timestamp in elrc

* refactor: slightly simplify the loop
2026-06-27 16:11:08 -04:00

358 lines
8.9 KiB
Go

package model
import (
"cmp"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/str"
)
// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm]
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]`
var (
// Should either be at the beginning of file, or beginning of line
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
// Enhanced LRC: inline word-level timing markers like <00:12.34>
enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>`
enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString)
)
func parseLRC(language, text string) (*Lyrics, error) {
text = str.SanitizeText(text)
lines := strings.Split(text, "\n")
structuredLines := make([]Line, 0, len(lines)*2)
artist := ""
title := ""
var offset *int64 = nil
synced := syncRegex.MatchString(text)
priorLine := ""
validLine := false
repeated := false
var timestamps []int64
for _, line := range lines {
line := strings.TrimSpace(line)
if line == "" {
if validLine {
priorLine += "\n"
}
continue
}
var text string
var time *int64 = nil
if synced {
idTag := lrcIdRegex.FindStringSubmatch(line)
if idTag != nil {
switch idTag[1] {
case "ar":
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
case "lang":
language = str.SanitizeText(strings.TrimSpace(idTag[2]))
case "offset":
{
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
if err != nil {
log.Warn("Error parsing offset", "offset", idTag[2], "error", err)
} else {
offset = &off
}
}
case "ti":
title = str.SanitizeText(strings.TrimSpace(idTag[2]))
}
continue
}
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
if len(times) > 1 {
repeated = true
}
// The second condition is for when there is a timestamp in the middle of
// a line (after any text)
if len(times) == 0 || times[0][0] != 0 {
if validLine {
priorLine += "\n" + line
}
continue
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
}
end := 0
// [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd]
for _, match := range times {
// for multiple matches, we need to check that later matches are not
// in the middle of the string
if end != 0 {
middle := strings.TrimSpace(line[end:match[0]])
if middle != "" {
break
}
}
end = match[1]
timeInMillis, err := parseTime(line, match)
if err != nil {
return nil, err
}
timestamps = append(timestamps, timeInMillis)
}
if end >= len(line) {
priorLine = ""
} else {
priorLine = strings.TrimSpace(line[end:])
}
validLine = true
} else {
text = line
structuredLines = append(structuredLines, Line{
Start: time,
Value: text,
})
}
}
if validLine {
value, baseCues := parseEnhancedLine(priorLine)
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]),
})
}
}
// If there are repeated values, there is no guarantee that they are in order
// In this, case, sort the lyrics by start time
if repeated {
slices.SortFunc(structuredLines, func(a, b Line) int {
return cmp.Compare(*a.Start, *b.Start)
})
}
lyrics := Lyrics{
DisplayArtist: artist,
DisplayTitle: title,
Lang: language,
Line: normalizeCueLines(structuredLines),
Offset: offset,
Synced: synced,
}
return &lyrics, nil
}
// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers
// and computes UTF-8 byte offsets against the final stripped line value.
func parseEnhancedLine(text string) (string, []Cue) {
matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return strings.TrimSpace(text), nil
}
type segment struct {
start int64
rawStart int
rawEnd int
}
segments := make([]segment, 0, len(matches))
var rawValue strings.Builder
var trailingEnd *int64
for i, match := range matches {
timeMs, err := parseTime(
// Rewrite <...> as [...] so parseTime can handle it with the same logic
"["+text[match[0]+1:match[1]-1]+"]",
// Adjust match indices to point into our rewritten string (need start/end pairs for each group)
[]int{
0, match[1] - match[0],
adjustGroup(match, 2), adjustGroup(match, 3),
adjustGroup(match, 4), adjustGroup(match, 5),
adjustGroup(match, 6), adjustGroup(match, 7),
adjustGroup(match, 8), adjustGroup(match, 9),
},
)
if err != nil {
continue
}
// Text runs from after this marker to the start of the next marker (or end of string)
textStart := match[1]
var textEnd int
if i+1 < len(matches) {
textEnd = matches[i+1][0]
} else {
textEnd = len(text)
}
word := text[textStart:textEnd]
if word == "" {
if i == len(matches)-1 {
trailingEnd = &timeMs
}
continue
}
rawStart := rawValue.Len()
rawValue.WriteString(word)
segments = append(segments, segment{
start: timeMs,
rawStart: rawStart,
rawEnd: rawValue.Len(),
})
}
if len(segments) == 0 {
return strings.TrimSpace(stripEnhancedMarkers(text)), nil
}
finalRaw := rawValue.String()
leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace))
rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace))
trimmedEnd := len(finalRaw) - rightTrimBytes
if trimmedEnd < leftTrimBytes {
trimmedEnd = leftTrimBytes
}
cues := make([]Cue, 0, len(segments))
for _, seg := range segments {
start := seg.start
byteStart := max(seg.rawStart, leftTrimBytes)
byteEnd := min(seg.rawEnd, trimmedEnd)
if byteStart >= byteEnd {
continue
}
cues = append(cues, Cue{
Start: &start,
Value: finalRaw[byteStart:byteEnd],
ByteStart: byteStart - leftTrimBytes,
ByteEnd: byteEnd - leftTrimBytes - 1,
})
}
if trailingEnd != nil && len(cues) > 0 {
cues[len(cues)-1].End = trailingEnd
}
return strings.TrimSpace(finalRaw), cues
}
// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string.
// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same.
func adjustGroup(match []int, groupIdx int) int {
orig := match[groupIdx]
if orig == -1 {
return -1
}
// Offset is: original position minus the position of '<' in the original, plus 1 for '['
return orig - match[0]
}
// stripEnhancedMarkers removes all <mm:ss.mm> inline markers from text,
// returning the plain lyric text.
func stripEnhancedMarkers(text string) string {
return enhancedLRCRegex.ReplaceAllString(text, "")
}
// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End
// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute
// timestamps anchored at the line's first occurrence, so repeated-line LRC
// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the
// second occurrence to point at the correct moment. Returned *int64 pointers
// are freshly allocated so the input slice is never aliased into the result.
func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue {
if len(baseCues) == 0 {
return nil
}
out := make([]Cue, len(baseCues))
for i, c := range baseCues {
out[i] = c
if c.Start != nil {
s := *c.Start + offsetMs
out[i].Start = &s
}
if c.End != nil {
e := *c.End + offsetMs
out[i].End = &e
}
}
return out
}
func parseTime(line string, match []int) (int64, error) {
var hours, millis int64
var err error
hourStart := match[2]
if hourStart != -1 {
// subtract 1 because group has : at the end
hourEnd := match[3] - 1
hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64)
if err != nil {
return 0, err
}
}
minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64)
if err != nil {
return 0, err
}
sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64)
if err != nil {
return 0, err
}
msStart := match[8]
if msStart != -1 {
msEnd := match[9]
// +1 offset since this capture group contains .
millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64)
if err != nil {
return 0, err
}
length := msEnd - msStart
if length == 3 {
millis *= 10
} else if length == 2 {
millis *= 100
}
}
timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis
return timeInMillis, nil
}