refactor(lyrics): self-skipping parsers collapse the format table to one column

Move the TTML <tt>-document gate into parseTTMLWithDefaultLang itself (after
the encoding fixup, so UTF-16-declared docs are still recognized): non-TTML
content returns (nil, nil) to skip; a malformed <tt> document still errors.
SRT and Lyricsfile YAML already self-skip. With every structured parser
self-skipping, the format table drops to one {suffixes, parse} column named
lyricFormats — no bySuffix/byContent split, no separate sniff-only TTML gate.
Both the suffix and content-sniff paths share the same parser per format.
This commit is contained in:
Deluan 2026-06-19 16:10:17 -04:00
commit 630189f95f
2 changed files with 28 additions and 33 deletions

View file

@ -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

View file

@ -109,6 +109,14 @@ func parseTTML(contents []byte) (LyricList, error) {
func parseTTMLWithDefaultLang(defaultLang string, contents []byte) (LyricList, error) {
contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`))
// 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
// <tt> 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{