diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go
index a16d04712..ec101922c 100644
--- a/core/lyrics/lyrics_test.go
+++ b/core/lyrics/lyrics_test.go
@@ -22,8 +22,9 @@ var _ = Describe("Lyrics", func() {
var ctx context.Context
const badLyrics = "This is a set of lyrics\nThat is not good"
- unsynced, _ := model.ToLyrics("xxx", badLyrics)
- embeddedLyrics := model.LyricList{*unsynced}
+ unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics))
+ unsynced, _ := unsyncedList.Main()
+ embeddedLyrics := model.LyricList{unsynced}
syncedLyrics := model.LyricList{
model.Lyrics{
@@ -385,9 +386,10 @@ var _ = Describe("Lyrics", func() {
})
It("resolves lyrics from the matched media files", func() {
- embedded, err := model.ToLyrics("eng", "Embedded lyrics line")
+ embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line"))
Expect(err).ToNot(HaveOccurred())
- embeddedJSON, err := json.Marshal(model.LyricList{*embedded})
+ embedded, _ := embeddedList.Main()
+ embeddedJSON, err := json.Marshal(model.LyricList{embedded})
Expect(err).ToNot(HaveOccurred())
repo.SetData(model.MediaFiles{
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go
index 3476230a5..9de2f6a18 100644
--- a/core/lyrics/sources.go
+++ b/core/lyrics/sources.go
@@ -52,7 +52,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
return nil, err
}
- list, err := model.ParseLyricsFile(suffix, contents)
+ list, err := model.ParseLyrics(suffix, "xxx", contents)
if err != nil {
log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err)
return nil, err
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index 06dcb2602..68f45424e 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -26,10 +26,12 @@ var _ = Describe("sources", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
- synced, _ := model.ToLyrics("eng", syncedLyrics)
- unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
+ syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
+ unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
+ synced, _ := syncedList.Main()
+ unsynced, _ := unsyncedList.Main()
- expectedList := model.LyricList{*synced, *unsynced}
+ expectedList := model.LyricList{synced, unsynced}
lyricsJson, err := json.Marshal(expectedList)
Expect(err).ToNot(HaveOccurred())
@@ -74,7 +76,7 @@ var _ = Describe("sources", func() {
Expect(lyrics).To(HaveLen(0))
})
- // fromExternalFile delegates format parsing to model.ParseLyricsFile; the
+ // fromExternalFile delegates format parsing to model.ParseLyrics; the
// per-format parser output is covered exhaustively in the model package.
// Here we only verify each suffix is read from the library FS and routed.
DescribeTable("should read the sidecar file and route its suffix to a parser",
diff --git a/model/lyrics.go b/model/lyrics.go
index bf3936f46..ba63daa14 100644
--- a/model/lyrics.go
+++ b/model/lyrics.go
@@ -2,7 +2,6 @@ package model
import (
"cmp"
- "fmt"
"regexp"
"slices"
"strconv"
@@ -89,7 +88,7 @@ func (l Lyrics) EffectiveKind() string {
return l.Kind
}
-func ToLyrics(language, text string) (*Lyrics, error) {
+func parseLRC(language, text string) (*Lyrics, error) {
text = str.SanitizeText(text)
lines := strings.Split(text, "\n")
@@ -236,33 +235,6 @@ func ToLyrics(language, text string) (*Lyrics, error) {
return &lyrics, nil
}
-// ParseLyricsFile parses a sidecar lyrics file, dispatching on its extension to
-// the matching format parser. Unknown extensions fall back to the generic
-// LRC/plain-text parser. It is the single owner of the suffix→parser mapping,
-// mirroring [ParseEmbedded] for tag-embedded lyrics.
-func ParseLyricsFile(suffix string, contents []byte) (LyricList, error) {
- var list LyricList
- var err error
- switch {
- case strings.EqualFold(suffix, ".ttml"):
- list, err = ParseTTML(contents)
- case strings.EqualFold(suffix, ".srt"):
- list, err = ParseSRT(contents)
- case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"):
- list, err = ParseLyricsfile(string(contents))
- default:
- var lyric *Lyrics
- lyric, err = ToLyrics("xxx", string(contents))
- if lyric != nil {
- list = LyricList{*lyric}
- }
- }
- if err != nil {
- return nil, fmt.Errorf("parsing %s lyrics: %w", strings.TrimPrefix(suffix, "."), err)
- }
- return list, 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) {
diff --git a/model/lyrics_embedded.go b/model/lyrics_embedded.go
deleted file mode 100644
index 1c12d892c..000000000
--- a/model/lyrics_embedded.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package model
-
-import (
- "encoding/xml"
- "strings"
-
- "github.com/navidrome/navidrome/log"
-)
-
-// ParseEmbedded parses lyrics read from media-file metadata tags. It detects rich
-// payloads before falling back to the generic LRC/plain-text parser, because
-// text sanitization would otherwise strip TTML XML markup.
-func ParseEmbedded(language, text string) (LyricList, error) {
- text = strings.TrimPrefix(text, "\ufeff")
-
- if isTTMLDocument(text) {
- list, err := parseTTMLWithDefaultLang([]byte(text), language)
- if err == nil && len(list) > 0 {
- return list, nil
- }
- if err != nil {
- log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err)
- }
- }
-
- list, err := parseSRTWithLanguage([]byte(text), language)
- if err == nil && len(list) > 0 {
- return list, nil
- }
- if err != nil && strings.Contains(text, "-->") {
- log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err)
- }
-
- if lyricsList, err := ParseLyricsfile(text); err == nil && len(lyricsList) > 0 {
- return lyricsList, nil
- }
-
- lyric, err := ToLyrics(language, text)
- if err != nil {
- return nil, err
- }
- if lyric == nil || lyric.IsEmpty() {
- return nil, nil
- }
- return LyricList{*lyric}, nil
-}
-
-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")
- }
- }
-}
diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go
new file mode 100644
index 000000000..4539e3641
--- /dev/null
+++ b/model/lyrics_parse.go
@@ -0,0 +1,105 @@
+package model
+
+import (
+ "encoding/xml"
+ "fmt"
+ "strings"
+
+ "github.com/navidrome/navidrome/log"
+)
+
+// ParseLyrics is the single entry point for parsing lyrics. When suffix names a
+// known format (.ttml/.srt/.yaml/.yml/.lrc) it routes to that parser, falling
+// back to plain text on failure. When suffix is empty or "auto" it content-sniffs
+// (TTML → SRT → YAML/Lyricsfile → LRC/plain) — used for tag-embedded lyrics and
+// plugin responses.
+func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) {
+ switch {
+ case suffix == "" || strings.EqualFold(suffix, "auto"):
+ return sniffLyrics(lang, contents)
+ case strings.EqualFold(suffix, ".ttml"):
+ return parseWithPlainFallback(lang, contents, parseTTMLKnown(lang))
+ case strings.EqualFold(suffix, ".srt"):
+ return parseWithPlainFallback(lang, contents, parseSRTKnown(lang))
+ case strings.EqualFold(suffix, ".yaml"), strings.EqualFold(suffix, ".yml"):
+ return parseWithPlainFallback(lang, contents, func(c []byte) (LyricList, error) {
+ return parseLyricsfile(string(c))
+ })
+ default: // .lrc and any unknown suffix: LRC/plain is the floor
+ return plainLRC(lang, contents)
+ }
+}
+
+// parseWithPlainFallback runs a known-format parser; on error or empty result it
+// falls back to the LRC/plain floor (never another structured format).
+func parseWithPlainFallback(lang string, contents []byte, parse func([]byte) (LyricList, error)) (LyricList, error) {
+ list, err := parse(contents)
+ if err == nil && len(list) > 0 {
+ return list, nil
+ }
+ if err != nil {
+ log.Warn("Error parsing lyrics by suffix, falling back to plain text", "error", err)
+ }
+ return plainLRC(lang, contents)
+}
+
+func parseTTMLKnown(lang string) func([]byte) (LyricList, error) {
+ return func(c []byte) (LyricList, error) { return parseTTMLWithDefaultLang(c, lang) }
+}
+
+func parseSRTKnown(lang string) func([]byte) (LyricList, error) {
+ return func(c []byte) (LyricList, error) { return parseSRTWithLanguage(c, lang) }
+}
+
+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
+}
+
+// sniffLyrics detects the format from content. Order: TTML → SRT → YAML → LRC/plain.
+func sniffLyrics(lang string, contents []byte) (LyricList, error) {
+ text := strings.TrimPrefix(string(contents), "\ufeff")
+
+ if isTTMLDocument(text) {
+ list, err := parseTTMLWithDefaultLang([]byte(text), lang)
+ if err == nil && len(list) > 0 {
+ return list, nil
+ }
+ if err != nil {
+ log.Warn("Error parsing embedded TTML lyrics, falling back to plain lyrics", "error", err)
+ }
+ }
+
+ list, err := parseSRTWithLanguage([]byte(text), lang)
+ if err == nil && len(list) > 0 {
+ return list, nil
+ }
+ if err != nil && strings.Contains(text, "-->") {
+ log.Warn("Error parsing embedded SRT lyrics, falling back to plain lyrics", "error", err)
+ }
+
+ if yamlList, yErr := parseLyricsfile(text); yErr == nil && len(yamlList) > 0 {
+ return yamlList, nil
+ }
+
+ return plainLRC(lang, []byte(text))
+}
+
+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")
+ }
+ }
+}
diff --git a/model/lyrics_embedded_test.go b/model/lyrics_parse_test.go
similarity index 66%
rename from model/lyrics_embedded_test.go
rename to model/lyrics_parse_test.go
index 1df9de4a1..eb58a29ef 100644
--- a/model/lyrics_embedded_test.go
+++ b/model/lyrics_parse_test.go
@@ -7,7 +7,48 @@ import (
. "github.com/onsi/gomega"
)
-var _ = Describe("ParseEmbedded", func() {
+var _ = Describe("ParseLyrics", func() {
+ DescribeTable("known suffix routes to the matching parser",
+ func(suffix, contents string, wantSynced bool, wantFirst string) {
+ list, err := ParseLyrics(suffix, "eng", []byte(contents))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Synced).To(Equal(wantSynced))
+ Expect(list[0].Line[0].Value).To(Equal(wantFirst))
+ },
+ Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"),
+ Entry(".txt plain", ".txt", "plain line", false, "plain line"),
+ Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"),
+ Entry(".ttml", ".ttml", ``, true, "ttml line"),
+ Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"),
+ )
+
+ It("empty suffix content-sniffs (TTML)", func() {
+ ttml := ``
+ list, err := ParseLyrics("", "eng", []byte(ttml))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Line[0].Value).To(Equal("auto ttml"))
+ })
+
+ It("empty suffix content-sniffs (YAML)", func() {
+ yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n"
+ list, err := ParseLyrics("auto", "eng", []byte(yaml))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Line[0].Value).To(Equal("auto yaml"))
+ })
+
+ It("falls back to plain text when a known suffix fails to parse structurally", func() {
+ list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file"))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(list).To(HaveLen(1))
+ Expect(list[0].Synced).To(BeFalse())
+ Expect(list[0].Line[0].Value).To(Equal("not actually an srt file"))
+ })
+})
+
+var _ = Describe("ParseLyrics content-sniffing", func() {
It("should parse embedded TTML with the tag language as the default", func() {
content := `
@@ -26,9 +67,9 @@ var _ = Describe("ParseEmbedded", func() {
`
- list, err := ParseEmbedded("ENG", content)
+ list, err := ParseLyrics("", "ENG", []byte(content))
- // ParseEmbedded's job is to detect TTML and apply the tag language as the
+ // ParseLyrics's job is to detect TTML and apply the tag language as the
// default; the parser's cue/agent details are covered in lyrics_ttml_test.go.
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
@@ -63,7 +104,7 @@ var _ = Describe("ParseEmbedded", func() {