diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go index 3ccb3646a..175f964ae 100644 --- a/model/lyrics_parse.go +++ b/model/lyrics_parse.go @@ -19,49 +19,36 @@ func parseLyricsfileBytes(_ string, contents []byte) (LyricList, error) { return parseLyricsfile(string(contents)) } -// parseTTMLIfDocument parses TTML only when the content actually looks like a -// TTML document, so content-sniffing plain text or LRC does not run the XML -// decoder (and never logs a spurious parse warning for non-TTML input). -func parseTTMLIfDocument(lang string, contents []byte) (LyricList, error) { - if !isTTMLDocument(string(contents)) { - return nil, nil - } - return parseTTMLWithDefaultLang(lang, contents) -} - -// registry is the single source of truth for the structured formats. Slice -// order is the content-sniff probe order (TTML → SRT → YAML), and each row's -// suffixes drive sidecar dispatch. byContent is used when sniffing — it gates a -// format so, e.g., TTML only runs the XML decoder on something that looks like a -// document; bySuffix parses unconditionally because the file extension already -// declares the format. LRC/plain is not listed: it is the fallback floor for -// both paths. -var registry = []struct { - suffixes []string - bySuffix lyricParser - byContent lyricParser +// lyricFormats lists the structured formats. Each parser is self-skipping: it +// returns an empty list (not an error) when the content is not its format, so it +// is safe to try in order. Slice order is the content-sniff probe order +// (TTML → SRT → YAML); each row's suffixes drive sidecar dispatch. LRC/plain is +// not listed — it is the fallback floor for both paths. +var lyricFormats = []struct { + suffixes []string + parse lyricParser }{ - {[]string{".ttml"}, parseTTMLWithDefaultLang, parseTTMLIfDocument}, - {[]string{".srt"}, parseSRTWithLanguage, parseSRTWithLanguage}, - {[]string{".yaml", ".yml"}, parseLyricsfileBytes, parseLyricsfileBytes}, + {[]string{".ttml"}, parseTTMLWithDefaultLang}, + {[]string{".srt"}, parseSRTWithLanguage}, + {[]string{".yaml", ".yml"}, parseLyricsfileBytes}, } // ParseLyrics is the single entry point for parsing lyrics. A known suffix // (.ttml/.srt/.yaml/.yml/.lrc) routes to that format's parser; an empty or -// "auto" suffix content-sniffs. In both modes a structured parser that does not -// match falls back to the LRC/plain-text floor — never to another structured -// format. +// "auto" suffix content-sniffs by trying each format in order. In both modes a +// structured parser that does not match falls back to the LRC/plain-text floor — +// never to another structured format. func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { if suffix == "" || strings.EqualFold(suffix, "auto") { - candidates := make([]lyricParser, len(registry)) - for i, r := range registry { - candidates[i] = r.byContent + candidates := make([]lyricParser, len(lyricFormats)) + for i, f := range lyricFormats { + candidates[i] = f.parse } return parseFirstMatch(lang, stripBOM(contents), candidates...) } - for _, r := range registry { - if slices.ContainsFunc(r.suffixes, func(s string) bool { return strings.EqualFold(s, suffix) }) { - return parseFirstMatch(lang, contents, r.bySuffix) + 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) // .lrc and any unknown suffix diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go index 4f49c8818..59686ce16 100644 --- a/model/lyrics_ttml.go +++ b/model/lyrics_ttml.go @@ -109,6 +109,14 @@ func parseTTML(contents []byte) (LyricList, error) { func parseTTMLWithDefaultLang(defaultLang string, contents []byte) (LyricList, error) { contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) + // Self-skip non-TTML content (returns nil, nil) so content-sniffing does not + // run the XML decoder on plain text or LRC. Checked after the encoding fixup + // above so a UTF-16-declared document is still recognized. Content that is a + // document but malformed still parses below and surfaces its error. + if !isTTMLDocument(string(contents)) { + return nil, nil + } + p := ttmlParser{ decoder: xml.NewDecoder(bytes.NewReader(contents)), params: ttmlTimingParams{