package model import ( "encoding/xml" "fmt" "slices" "strings" "github.com/navidrome/navidrome/log" ) // lyricParser returns an empty list (not an error) when the input is not its // format, so parsers can be tried in order. lang is the default for formats that // do not carry their own. type lyricParser func(lang string, contents []byte) (LyricList, error) func parseLyricsfileBytes(_ string, contents []byte) (LyricList, error) { return parseLyricsfile(string(contents)) } // lyricFormats is the structured formats in content-sniff probe order; each // row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. var lyricFormats = []struct { suffixes []string parse lyricParser }{ {[]string{".ttml"}, parseTTMLWithDefaultLang}, {[]string{".srt"}, parseSRTWithLanguage}, {[]string{".yaml", ".yml"}, parseLyricsfileBytes}, } // ParseLyrics is the single entry point for parsing lyrics. A known suffix routes // to that format's parser; an empty or "auto" suffix content-sniffs. Either way, // a structured parser that does not match falls back to the LRC/plain-text floor. func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { contents = stripBOM(contents) if suffix == "" || strings.EqualFold(suffix, "auto") { candidates := make([]lyricParser, len(lyricFormats)) for i, f := range lyricFormats { candidates[i] = f.parse } return parseFirstMatch(lang, contents, candidates...) } for _, f := range lyricFormats { if slices.ContainsFunc(f.suffixes, func(s string) bool { return strings.EqualFold(s, suffix) }) { return parseFirstMatch(lang, contents, f.parse) } } return plainLRC(lang, contents) } func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { for _, parse := range candidates { list, err := parse(lang, contents) if err == nil && len(list) > 0 { return list, nil } if err != nil { log.Warn("Error parsing lyrics, falling back to plain text", "error", err) } } return plainLRC(lang, contents) } func plainLRC(lang string, contents []byte) (LyricList, error) { lyric, err := parseLRC(lang, string(contents)) if err != nil { return nil, fmt.Errorf("parsing lyrics: %w", err) } if lyric == nil || lyric.IsEmpty() { return nil, nil } return LyricList{*lyric}, nil } func stripBOM(contents []byte) []byte { return []byte(strings.TrimPrefix(string(contents), "\ufeff")) } func isTTMLDocument(text string) bool { decoder := xml.NewDecoder(strings.NewReader(strings.TrimSpace(text))) for { token, err := decoder.Token() if err != nil { return false } if start, ok := token.(xml.StartElement); ok { return strings.EqualFold(start.Name.Local, "tt") } } }