package model
import (
"cmp"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/str"
)
const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]`
var (
syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString)
timeRegex = regexp.MustCompile(timeRegexString)
lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`)
bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`)
unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`)
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
hasBg := false
var timestamps []int64
var pendingBg []string
flushLine := func() {
value, cues := parseEnhancedLine(priorLine)
var merged bool
value, cues, merged = mergeBgLayers(value, cues, pendingBg)
hasBg = hasBg || merged
for idx := range timestamps {
startCopy := timestamps[idx]
structuredLines = append(structuredLines, Line{
Start: &startCopy,
Value: value,
Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]),
})
}
timestamps = nil
pendingBg = nil
}
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
}
if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil {
if validLine {
pendingBg = append(pendingBg, bgMatch[1])
}
continue
}
if unknownTagRegex.MatchString(line) {
continue
}
times := timeRegex.FindAllStringSubmatchIndex(line, -1)
if len(times) > 1 {
repeated = true
}
if len(times) == 0 || times[0][0] != 0 {
if validLine {
priorLine += "\n" + line
}
continue
}
if validLine {
flushLine()
}
end := 0
for _, match := range times {
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 {
flushLine()
}
if hasBg {
fillMainAgentID(structuredLines)
}
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,
}
if hasBg {
lyrics.Agents = []Agent{
{ID: "main", Role: "main"},
{ID: backgroundAgentID("main"), Role: "bg"},
}
}
return &lyrics, nil
}
func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) {
merged := false
for _, bg := range bgLines {
bgValue, bgCues := parseEnhancedLine(bg)
if len(bgCues) == 0 {
continue
}
offset := 0
if value != "" {
offset = len(value) + 1
value += " "
}
value += bgValue
for _, c := range bgCues {
c.ByteStart += offset
c.ByteEnd += offset
c.AgentID = backgroundAgentID("main")
cues = append(cues, c)
}
merged = true
}
return value, cues, merged
}
func fillMainAgentID(lines []Line) {
for i := range lines {
for j := range lines[i].Cue {
if lines[i].Cue[j].AgentID == "" {
lines[i].Cue[j].AgentID = "main"
}
}
}
}
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(
"["+text[match[0]+1:match[1]-1]+"]",
[]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
}
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
}
func adjustGroup(match []int, groupIdx int) int {
orig := match[groupIdx]
if orig == -1 {
return -1
}
return orig - match[0]
}
func stripEnhancedMarkers(text string) string {
return enhancedLRCRegex.ReplaceAllString(text, "")
}
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 {
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]
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
}