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", `

ttml line

`, 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 := `

auto 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() { ` - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(3)) @@ -88,7 +129,7 @@ We're from subtitles 00:00:22,801 --> 00:00:26,000 Another subtitle line` - list, err := ParseEmbedded("POR", content) + list, err := ParseLyrics("", "POR", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(Equal(LyricList{ @@ -114,7 +155,7 @@ Another subtitle line` It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -127,7 +168,7 @@ Another subtitle line` It("should keep embedded enhanced LRC cues", func() { content := "[00:01.00]<00:01.00>Lead <00:01.50>words" - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -144,7 +185,7 @@ Another subtitle line` ` - list, err := ParseEmbedded("eng", content) + list, err := ParseLyrics("", "eng", []byte(content)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -161,7 +202,7 @@ Another subtitle line` It("detects a Lyricsfile YAML payload via content-sniffing", func() { yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" - list, err := ParseEmbedded("eng", yaml) + list, err := ParseLyrics("", "eng", []byte(yaml)) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go index 928fc45d9..4cfa9fd00 100644 --- a/model/lyrics_srt.go +++ b/model/lyrics_srt.go @@ -14,7 +14,7 @@ var ( srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) ) -func ParseSRT(contents []byte) (LyricList, error) { +func parseSRT(contents []byte) (LyricList, error) { return parseSRTWithLanguage(contents, "xxx") } diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..60523e090 --- /dev/null +++ b/model/lyrics_srt_test.go @@ -0,0 +1,30 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseSRT", func() { + It("parses SRT blocks with the default language", func() { + content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") + + list, err := parseSRT(content) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Lang).To(Equal("xxx")) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(Equal([]Line{ + {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, + {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, + })) + }) + + It("returns nil for input with no valid blocks", func() { + list, err := parseSRT([]byte("not actually an srt file")) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(BeNil()) + }) +}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index b772e2f5e..5d58fac91 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,14 +1,13 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", func() { +var _ = Describe("parseLRC", func() { It("should parse tags with spaces", func() { - lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") + lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Lang).To(Equal("eng")) Expect(lyrics.Synced).To(BeTrue()) @@ -18,13 +17,13 @@ var _ = Describe("ToLyrics", func() { }) It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") + lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Offset).To(BeNil()) }) It("should accept lines with no text and weird times", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") + lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -36,7 +35,7 @@ var _ = Describe("ToLyrics", func() { }) It("Should support multiple timestamps per line", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") + lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -48,7 +47,7 @@ var _ = Describe("ToLyrics", func() { }) It("Should support parsing multiline string", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") + lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -58,7 +57,7 @@ var _ = Describe("ToLyrics", func() { }) It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("xxx", "This could [00:00:00] be a synced file") + lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeFalse()) Expect(lyrics.Line).To(Equal([]Line{ @@ -67,7 +66,7 @@ var _ = Describe("ToLyrics", func() { }) It("Allows timestamp in middle of line if also at beginning", func() { - lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") + lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -77,7 +76,7 @@ var _ = Describe("ToLyrics", func() { }) It("Ignores lines in synchronized lyric prior to first timestamp", func() { - lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") + lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -86,7 +85,7 @@ var _ = Describe("ToLyrics", func() { }) It("Handles all possible ms cases", func() { - lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") + lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -97,7 +96,7 @@ var _ = Describe("ToLyrics", func() { }) It("Properly sorts repeated lyrics out of order", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") + lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -110,7 +109,7 @@ var _ = Describe("ToLyrics", func() { }) It("should parse Enhanced LRC with word-level timing", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(HaveLen(2)) @@ -140,7 +139,7 @@ var _ = Describe("ToLyrics", func() { }) It("should not parse malformed Enhanced LRC timing markers", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker") + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ @@ -149,7 +148,7 @@ var _ = Describe("ToLyrics", func() { }) It("should handle mixed Enhanced and plain LRC lines", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Line).To(HaveLen(3)) @@ -174,7 +173,7 @@ var _ = Describe("ToLyrics", func() { }) It("should preserve byte offsets for Enhanced LRC cues", func() { - lyrics, err := ToLyrics("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") + lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Line).To(HaveLen(1)) @@ -190,7 +189,7 @@ var _ = Describe("ToLyrics", func() { }) It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { - lyrics, err := ToLyrics("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") + lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Line).To(HaveLen(2)) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go index fe3a547d5..e70ed66b0 100644 --- a/model/lyrics_ttml.go +++ b/model/lyrics_ttml.go @@ -102,7 +102,7 @@ type ttmlParser struct { metadataSeq int } -func ParseTTML(contents []byte) (LyricList, error) { +func parseTTML(contents []byte) (LyricList, error) { return parseTTMLWithDefaultLang(contents, "xxx") } diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go index ef882fdcd..323e6841c 100644 --- a/model/lyrics_ttml_test.go +++ b/model/lyrics_ttml_test.go @@ -5,7 +5,7 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("ParseTTML", func() { +var _ = Describe("parseTTML", func() { Describe("Multi-language and timing", func() { It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { content := []byte(` @@ -21,7 +21,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(2)) @@ -54,7 +54,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(1)) @@ -75,7 +75,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Lang).To(Equal("eng")) @@ -99,7 +99,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(2)) @@ -124,7 +124,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -154,7 +154,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) @@ -187,7 +187,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -222,7 +222,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -256,7 +256,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Agents).To(Equal([]Agent{ @@ -283,7 +283,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Line).To(HaveLen(1)) @@ -309,7 +309,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(1)) Expect(list[0].Lang).To(Equal("xxx")) @@ -349,7 +349,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) Expect(list).To(HaveLen(3)) @@ -404,7 +404,7 @@ var _ = Describe("ParseTTML", func() { `) - list, err := ParseTTML(content) + list, err := parseTTML(content) Expect(err).ToNot(HaveOccurred()) var pronunciation *Lyrics diff --git a/model/lyricsfile.go b/model/lyricsfile.go index b2b123256..2bf1a09b4 100644 --- a/model/lyricsfile.go +++ b/model/lyricsfile.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -// ParseLyricsfile parses a LRCLIB Lyricsfile YAML document +// parseLyricsfile parses a LRCLIB Lyricsfile YAML document // (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) // into a model.LyricList containing a single main Lyrics entry. Returns // (nil, nil) when the input parses as YAML but does not declare Lyricsfile @@ -19,7 +19,7 @@ import ( // overlapping lines are attributed to synthetic voice agents via lowest-free // voice ID assignment so the OpenSubsonic v2 enhanced response can split // parallel vocals. -func ParseLyricsfile(text string) (LyricList, error) { +func parseLyricsfile(text string) (LyricList, error) { var doc lyricsfileDocument dec := yaml.NewDecoder(strings.NewReader(text)) dec.KnownFields(false) diff --git a/model/lyricsfile_test.go b/model/lyricsfile_test.go index a3588a2ea..d40e1489f 100644 --- a/model/lyricsfile_test.go +++ b/model/lyricsfile_test.go @@ -1,15 +1,14 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ParseLyricsfile", func() { +var _ = Describe("parseLyricsfile", func() { DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", func(input string) { - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(BeNil()) }, @@ -23,7 +22,7 @@ lines: ) It("returns an error for invalid YAML", func() { - _, err := ParseLyricsfile("not: valid: yaml: [") + _, err := parseLyricsfile("not: valid: yaml: [") Expect(err).To(HaveOccurred()) }) @@ -40,7 +39,7 @@ lines: - text: "You know the rules and so do I" start_ms: 22801 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -80,7 +79,7 @@ plain: | Second line ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -116,7 +115,7 @@ lines: start_ms: 1500 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -168,7 +167,7 @@ lines: start_ms: 3000 end_ms: 3500 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -207,7 +206,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -246,7 +245,7 @@ metadata: language: 'eng' instrumental: true ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -270,7 +269,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile(input) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b46174c59..de2ba813e 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -143,7 +143,7 @@ func (md Metadata) mapLyrics() string { lang := raw.Key() text := raw.Value() - lyrics, err := model.ParseEmbedded(lang, text) + lyrics, err := model.ParseLyrics("", lang, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 08994220a..12d84f60d 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -32,8 +32,7 @@ type LyricsPlugin struct { } // GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response -// via model.ParseEmbedded (TTML/SRT/YAML/LRC/plain). A later task retargets this -// to ParseLyrics once that function is introduced. +// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), @@ -51,7 +50,7 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode if lang == "" { lang = "xxx" } - parsed, err := model.ParseEmbedded(lang, lt.Text) + parsed, err := model.ParseLyrics("", lang, []byte(lt.Text)) if err != nil { log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) continue diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index e0f291b70..fd9a3f3f3 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -99,10 +99,12 @@ var _ = Describe("GetLyricsBySongId", func() { It("should return mixed lyrics", func() { r := newGetRequest("id=1") - 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() lyricsJson, err := json.Marshal(model.LyricList{ - *synced, *unsynced, + synced, unsynced, }) Expect(err).ToNot(HaveOccurred()) @@ -155,9 +157,10 @@ var _ = Describe("GetLyricsBySongId", func() { It("should parse lrc metadata", func() { r := newGetRequest("id=1") - synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) + syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) + synced, _ := syncedList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *synced, + synced, }) Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 60deda208..f8b21c700 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -112,9 +112,10 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) + lyrics, _ := lyricsList.Main() lyricsJson, err := json.Marshal(model.LyricList{ - *lyrics, + lyrics, }) Expect(err).ToNot(HaveOccurred())