refactor(lyrics): consolidate parsers into model.ParseLyrics

This commit is contained in:
Deluan 2026-06-19 14:41:22 -04:00
commit ff1695d074
18 changed files with 259 additions and 165 deletions

View file

@ -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)},

View file

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

View file

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

View file

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

View file

@ -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")
}
}
}

105
model/lyrics_parse.go Normal file
View file

@ -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")
}
}
}

View file

@ -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", `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">ttml line</p></div></body></tt>`, 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 := `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">auto ttml</p></div></body></tt>`
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 := `<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
@ -26,9 +67,9 @@ var _ = Describe("ParseEmbedded", func() {
</body>
</tt>`
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() {
</body>
</tt>`
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`
</body>
</tt>`
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))

View file

@ -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")
}

30
model/lyrics_srt_test.go Normal file
View file

@ -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())
})
})

View file

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

View file

@ -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")
}

View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
@ -21,7 +21,7 @@ var _ = Describe("ParseTTML", func() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
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() {
</body>
</tt>`)
list, err := ParseTTML(content)
list, err := parseTTML(content)
Expect(err).ToNot(HaveOccurred())
var pronunciation *Lyrics

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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