diff --git a/.golangci.yml b/.golangci.yml index 76eb882ca..200fe122f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -68,6 +68,7 @@ linters: - builtin$ - examples$ - node_modules + - _gen\.go$ formatters: exclusions: generated: lax diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index f87381905..c9fdcbae8 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -1,9 +1,13 @@ package lyrics_test import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Lyrics Suite") } + +// core/storage/local calls log.Fatal if the default scanner extractor is unregistered +// when constructing any localStorage. Register a no-op so storage.For("file://...") works +// in tests without importing the real extractor. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &noopExtractor{} + }) +}) + +type noopExtractor struct{} + +func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index a16d04712..6baacbe71 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{ @@ -224,7 +225,7 @@ var _ = Describe("Lyrics", func() { })) }) - It("falls through generic YAML sidecars that are not Lyricsfile documents", func() { + It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { @@ -241,10 +242,14 @@ var _ = Describe("Lyrics", func() { Path: "song.mp3", }) + // ParseLyrics falls back to plain text for any suffix when the content + // doesn't match the structured format, so the .yaml hit is non-empty and + // shadows the lower-priority .lrc entirely. Expect(err).To(BeNil()) Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeFalse()) Expect(list[0].Line).To(Equal([]model.Line{ - {Start: new(int64(1000)), Value: "Fallback line"}, + {Value: "title: not lyricsfile"}, })) }) @@ -385,9 +390,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 2962c6e5c..9de2f6a18 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -3,9 +3,12 @@ package lyrics import ( "context" "errors" - "os" + "fmt" + "io" + "io/fs" "path" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/ioutils" @@ -23,31 +26,44 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er } func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - basePath := mf.AbsolutePath() - ext := path.Ext(basePath) + ext := path.Ext(mf.Path) + sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix - externalLyric := basePath[0:len(basePath)-len(ext)] + suffix + store, err := storage.For(mf.LibraryPath) + if err != nil { + return nil, fmt.Errorf("getting storage for library: %w", err) + } + fsys, err := store.FS() + if err != nil { + return nil, fmt.Errorf("opening library filesystem: %w", err) + } - contents, err := ioutils.UTF8ReadFile(externalLyric) - if errors.Is(err, os.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path", "path", externalLyric) + f, err := fsys.Open(sidecarRelPath) + if errors.Is(err, fs.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath) return nil, nil } else if err != nil { return nil, err } + defer f.Close() - list, err := model.ParseLyricsFile(suffix, contents) + contents, err := io.ReadAll(ioutils.UTF8Reader(f)) if err != nil { - log.Error(ctx, "error parsing external lyric file", "path", externalLyric, err) + return nil, err + } + + list, err := model.ParseLyrics(suffix, "xxx", contents) + if err != nil { + log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err) return nil, err } if len(list) == 0 { - log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) + log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath) return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) + log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath) return list, nil } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 002931c0c..68f45424e 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -3,6 +3,7 @@ package lyrics import ( "context" "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -25,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()) @@ -53,46 +56,51 @@ var _ = Describe("sources", func() { }) Describe("fromExternalFile", func() { + var fixturesDir string + + BeforeEach(func() { + // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. + abs, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + fixturesDir = abs + }) + + mf := func(name string) *model.MediaFile { + return &model.MediaFile{LibraryPath: fixturesDir, Path: name} + } + It("should return nil for lyrics that don't exist", func() { - mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") Expect(err).To(BeNil()) 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 disk and routed to a parser. + // 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", - func(path, suffix string, expectSynced bool) { - mf := model.MediaFile{Path: path} - lyrics, err := fromExternalFile(ctx, &mf, suffix) + func(name, suffix string, expectSynced bool) { + lyrics, err := fromExternalFile(ctx, mf(name), suffix) Expect(err).To(BeNil()) Expect(lyrics).ToNot(BeEmpty()) Expect(lyrics[0].Line).ToNot(BeEmpty()) Expect(lyrics[0].Synced).To(Equal(expectSynced)) }, - Entry(".lrc synced", "tests/fixtures/test.mp3", ".lrc", true), - Entry(".elrc enhanced", "tests/fixtures/test.mp3", ".elrc", true), - Entry(".txt plain", "tests/fixtures/test.mp3", ".txt", false), - Entry(".srt subtitles", "tests/fixtures/test.mp3", ".srt", true), - Entry(".ttml multilingual", "tests/fixtures/test.mp3", ".ttml", true), - Entry(".yaml lyricsfile", "tests/fixtures/test.mp3", ".yaml", true), + Entry(".lrc synced", "test.mp3", ".lrc", true), + Entry(".elrc enhanced", "test.mp3", ".elrc", true), + Entry(".txt plain", "test.mp3", ".txt", false), + Entry(".srt subtitles", "test.mp3", ".srt", true), + Entry(".ttml multilingual", "test.mp3", ".ttml", true), + Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), ) It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - // The function looks for , so we need to pass - // a MediaFile with .mp3 path and look for .lrc suffix - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) @@ -100,14 +108,10 @@ var _ = Describe("sources", func() { }) It("should handle UTF-16 LE encoded LRC files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) - - // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) @@ -117,8 +121,7 @@ var _ = Describe("sources", func() { }) It("should handle TTML files with UTF-8 BOM marker", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(1)) @@ -130,8 +133,7 @@ var _ = Describe("sources", func() { }) It("should handle UTF-16 BE encoded TTML files", func() { - mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} - lyrics, err := fromExternalFile(ctx, &mf, ".ttml") + lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(1)) @@ -143,6 +145,5 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) }) - }) }) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index 891cb9f5b..259a37745 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { continue } - lyrics, err := model.ToLyrics("xxx", lyrics.String) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String)) if err != nil { return err } - text, err := json.Marshal(model.LyricList{*lyrics}) + text, err := json.Marshal(parsed) if err != nil { return err } diff --git a/model/lyrics.go b/model/lyrics.go index bf3936f46..111b1c2a9 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,17 +1,8 @@ package model import ( - "cmp" - "fmt" - "regexp" - "slices" - "strconv" + "encoding/json" "strings" - "unicode" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/str" ) type Cue struct { @@ -55,20 +46,6 @@ const ( LyricKindPronunciation = "pronunciation" ) -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) - - // Enhanced LRC: inline word-level timing markers like <00:12.34> - enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` - enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) -) - func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } @@ -89,358 +66,16 @@ func (l Lyrics) EffectiveKind() string { return l.Kind } -func ToLyrics(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - var timestamps []int64 - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if times == nil || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: NormalizeCueLines(structuredLines), - Offset: offset, - Synced: synced, - } - 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) { - matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) - if len(matches) == 0 { - return strings.TrimSpace(text), nil - } - - type segment struct { - start int64 - rawStart int - rawEnd int - } - - segments := make([]segment, 0, len(matches)) - var rawValue strings.Builder - for i, match := range matches { - timeMs, err := parseTime( - // Rewrite <...> as [...] so parseTime can handle it with the same logic - "["+text[match[0]+1:match[1]-1]+"]", - // Adjust match indices to point into our rewritten string (need start/end pairs for each group) - []int{ - 0, match[1] - match[0], - adjustGroup(match, 2), adjustGroup(match, 3), - adjustGroup(match, 4), adjustGroup(match, 5), - adjustGroup(match, 6), adjustGroup(match, 7), - adjustGroup(match, 8), adjustGroup(match, 9), - }, - ) - if err != nil { - continue - } - - // Text runs from after this marker to the start of the next marker (or end of string) - textStart := match[1] - var textEnd int - if i+1 < len(matches) { - textEnd = matches[i+1][0] - } else { - textEnd = len(text) - } - - word := text[textStart:textEnd] - if word == "" { - continue - } - - rawStart := rawValue.Len() - rawValue.WriteString(word) - segments = append(segments, segment{ - start: timeMs, - rawStart: rawStart, - rawEnd: rawValue.Len(), - }) - } - - if len(segments) == 0 { - return strings.TrimSpace(stripEnhancedMarkers(text)), nil - } - - finalRaw := rawValue.String() - leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) - rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) - trimmedEnd := len(finalRaw) - rightTrimBytes - if trimmedEnd < leftTrimBytes { - trimmedEnd = leftTrimBytes - } - - cues := make([]Cue, 0, len(segments)) - for _, seg := range segments { - start := seg.start - byteStart := max(seg.rawStart, leftTrimBytes) - byteEnd := min(seg.rawEnd, trimmedEnd) - if byteStart >= byteEnd { - continue - } - - cues = append(cues, Cue{ - Start: &start, - Value: finalRaw[byteStart:byteEnd], - ByteStart: byteStart - leftTrimBytes, - ByteEnd: byteEnd - leftTrimBytes - 1, - }) - } - - return strings.TrimSpace(finalRaw), cues -} - -// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. -// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. -func adjustGroup(match []int, groupIdx int) int { - orig := match[groupIdx] - if orig == -1 { - return -1 - } - // Offset is: original position minus the position of '<' in the original, plus 1 for '[' - return orig - match[0] -} - -// stripEnhancedMarkers removes all inline markers from text, -// returning the plain lyric text. -func stripEnhancedMarkers(text string) string { - return enhancedLRCRegex.ReplaceAllString(text, "") -} - -// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End -// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute -// timestamps anchored at the line's first occurrence, so repeated-line LRC -// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the -// second occurrence to point at the correct moment. Returned *int64 pointers -// are freshly allocated so the input slice is never aliased into the result. -func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { - if len(baseCues) == 0 { - return nil - } - out := make([]Cue, len(baseCues)) - for i, c := range baseCues { - out[i] = c - if c.Start != nil { - s := *c.Start + offsetMs - out[i].Start = &s - } - if c.End != nil { - e := *c.End + offsetMs - out[i].End = &e - } - } - return out -} - -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } - } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil -} - type LyricList []Lyrics +// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. +func (ll LyricList) MarshalJSON() ([]byte, error) { + if len(ll) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]Lyrics(ll)) +} + // Main returns the main-kind lyric, falling back to the first entry so untyped // lyrics still resolve. The bool is false only when the list is empty. It is // used to surface a single lyric through the plain-text legacy getLyrics @@ -456,126 +91,3 @@ func (ll LyricList) Main() (Lyrics, bool) { } return ll[0], true } - -func NormalizeLyrics(lyrics Lyrics) Lyrics { - lyrics.Line = NormalizeCueLines(lyrics.Line) - if len(lyrics.Agents) == 0 { - lyrics.Agents = nil - } - return lyrics -} - -func NormalizeCueLines(lines []Line) []Line { - if len(lines) == 0 { - return lines - } - - normalized := make([]Line, len(lines)) - copy(normalized, lines) - - for i := range normalized { - if len(normalized[i].Cue) > 0 { - normalized[i].Cue = slices.Clone(normalized[i].Cue) - } - - var fallbackEnd *int64 - if normalized[i].End != nil { - v := *normalized[i].End - fallbackEnd = &v - } else if i+1 < len(normalized) && normalized[i+1].Start != nil { - v := *normalized[i+1].Start - fallbackEnd = &v - } - - normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) - } - - return normalized -} - -func NormalizeLineTiming(line Line) Line { - if len(line.Cue) == 0 { - return line - } - - var earliestStart *int64 - var latestEnd *int64 - for i := range line.Cue { - token := line.Cue[i] - if token.Start != nil { - if earliestStart == nil || *token.Start < *earliestStart { - v := *token.Start - earliestStart = &v - } - } - - candidateEnd := token.End - if candidateEnd == nil { - candidateEnd = token.Start - } - if candidateEnd != nil { - if latestEnd == nil || *candidateEnd > *latestEnd { - v := *candidateEnd - latestEnd = &v - } - } - } - - if line.Start == nil && earliestStart != nil { - v := *earliestStart - line.Start = &v - } - if line.End == nil && latestEnd != nil { - v := *latestEnd - line.End = &v - } - return line -} - -func normalizeCueLine(line Line, fallbackEnd *int64) Line { - if len(line.Cue) == 0 { - return line - } - line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) - return NormalizeLineTiming(line) -} - -// NormalizeCueEnds resolves missing cue end times within a single ordered cue -// group: each end is filled from the next cue's start, then from fallbackEnd, -// and is clamped so it never precedes the cue's own start nor overruns the next -// cue. End times are all-or-none — if any cue still lacks an end afterwards, all -// ends in the group are cleared. The input slice is never mutated. -func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { - if len(cues) == 0 { - return cues - } - - out := slices.Clone(cues) - for i := range out { - end := out[i].End - if end == nil { - if i+1 < len(out) && out[i+1].Start != nil { - end = out[i+1].Start - } else { - end = fallbackEnd - } - } - if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { - end = out[i+1].Start - } - if end != nil && out[i].Start != nil && *end < *out[i].Start { - end = out[i].Start - } - out[i].End = gg.Clone(end) - } - - for i := range out { - if out[i].End == nil { - for j := range out { - out[j].End = nil - } - break - } - } - return out -} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go new file mode 100644 index 000000000..0de2549e7 --- /dev/null +++ b/model/lyrics_benchmark_test.go @@ -0,0 +1,45 @@ +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert +// Burns, 1788) rendered into every supported format, so the numbers reflect +// realistic content and sizing. The same song across formats makes per-format +// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. +func loadLyricFixture(b *testing.B, name string) []byte { + b.Helper() + contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) + if err != nil { + b.Fatal(err) + } + return contents +} + +func benchmarkParse(b *testing.B, suffix, fixture string) { + contents := loadLyricFixture(b, fixture) + b.ReportAllocs() + b.SetBytes(int64(len(contents))) + for b.Loop() { + if _, err := ParseLyrics(suffix, "eng", contents); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } +func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } +func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } + +// Content-sniff path (empty suffix) — what embedded tags and plugins hit. +func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } +func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } +func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } +func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } +func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_embedded.go b/model/lyrics_embedded.go deleted file mode 100644 index 7b412556e..000000000 --- a/model/lyrics_embedded.go +++ /dev/null @@ -1,55 +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) - } - - 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_lrc.go b/model/lyrics_lrc.go new file mode 100644 index 000000000..6fde0c8f5 --- /dev/null +++ b/model/lyrics_lrc.go @@ -0,0 +1,350 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] +const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` + +var ( + // Should either be at the beginning of file, or beginning of line + syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) + timeRegex = regexp.MustCompile(timeRegexString) + lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + + // Enhanced LRC: inline word-level timing markers like <00:12.34> + enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` + enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) +) + +func parseLRC(language, text string) (*Lyrics, error) { + text = str.SanitizeText(text) + + lines := strings.Split(text, "\n") + structuredLines := make([]Line, 0, len(lines)*2) + + artist := "" + title := "" + var offset *int64 = nil + + synced := syncRegex.MatchString(text) + priorLine := "" + validLine := false + repeated := false + var timestamps []int64 + + for _, line := range lines { + line := strings.TrimSpace(line) + if line == "" { + if validLine { + priorLine += "\n" + } + continue + } + var text string + var time *int64 = nil + + if synced { + idTag := lrcIdRegex.FindStringSubmatch(line) + if idTag != nil { + switch idTag[1] { + case "ar": + artist = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "lang": + language = str.SanitizeText(strings.TrimSpace(idTag[2])) + case "offset": + { + off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) + if err != nil { + log.Warn("Error parsing offset", "offset", idTag[2], "error", err) + } else { + offset = &off + } + } + case "ti": + title = str.SanitizeText(strings.TrimSpace(idTag[2])) + } + + continue + } + + times := timeRegex.FindAllStringSubmatchIndex(line, -1) + if len(times) > 1 { + repeated = true + } + + // The second condition is for when there is a timestamp in the middle of + // a line (after any text) + if len(times) == 0 || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + } + + end := 0 + + // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] + for _, match := range times { + // for multiple matches, we need to check that later matches are not + // in the middle of the string + if end != 0 { + middle := strings.TrimSpace(line[end:match[0]]) + if middle != "" { + break + } + } + + end = match[1] + timeInMillis, err := parseTime(line, match) + if err != nil { + return nil, err + } + + timestamps = append(timestamps, timeInMillis) + } + + if end >= len(line) { + priorLine = "" + } else { + priorLine = strings.TrimSpace(line[end:]) + } + + validLine = true + } else { + text = line + structuredLines = append(structuredLines, Line{ + Start: time, + Value: text, + }) + } + } + + if validLine { + value, baseCues := parseEnhancedLine(priorLine) + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), + }) + } + } + + // If there are repeated values, there is no guarantee that they are in order + // In this, case, sort the lyrics by start time + if repeated { + slices.SortFunc(structuredLines, func(a, b Line) int { + return cmp.Compare(*a.Start, *b.Start) + }) + } + + lyrics := Lyrics{ + DisplayArtist: artist, + DisplayTitle: title, + Lang: language, + Line: normalizeCueLines(structuredLines), + Offset: offset, + Synced: synced, + } + return &lyrics, 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) { + matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return strings.TrimSpace(text), nil + } + + type segment struct { + start int64 + rawStart int + rawEnd int + } + + segments := make([]segment, 0, len(matches)) + var rawValue strings.Builder + for i, match := range matches { + timeMs, err := parseTime( + // Rewrite <...> as [...] so parseTime can handle it with the same logic + "["+text[match[0]+1:match[1]-1]+"]", + // Adjust match indices to point into our rewritten string (need start/end pairs for each group) + []int{ + 0, match[1] - match[0], + adjustGroup(match, 2), adjustGroup(match, 3), + adjustGroup(match, 4), adjustGroup(match, 5), + adjustGroup(match, 6), adjustGroup(match, 7), + adjustGroup(match, 8), adjustGroup(match, 9), + }, + ) + if err != nil { + continue + } + + // Text runs from after this marker to the start of the next marker (or end of string) + textStart := match[1] + var textEnd int + if i+1 < len(matches) { + textEnd = matches[i+1][0] + } else { + textEnd = len(text) + } + + word := text[textStart:textEnd] + if word == "" { + continue + } + + rawStart := rawValue.Len() + rawValue.WriteString(word) + segments = append(segments, segment{ + start: timeMs, + rawStart: rawStart, + rawEnd: rawValue.Len(), + }) + } + + if len(segments) == 0 { + return strings.TrimSpace(stripEnhancedMarkers(text)), nil + } + + finalRaw := rawValue.String() + leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) + rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) + trimmedEnd := len(finalRaw) - rightTrimBytes + if trimmedEnd < leftTrimBytes { + trimmedEnd = leftTrimBytes + } + + cues := make([]Cue, 0, len(segments)) + for _, seg := range segments { + start := seg.start + byteStart := max(seg.rawStart, leftTrimBytes) + byteEnd := min(seg.rawEnd, trimmedEnd) + if byteStart >= byteEnd { + continue + } + + cues = append(cues, Cue{ + Start: &start, + Value: finalRaw[byteStart:byteEnd], + ByteStart: byteStart - leftTrimBytes, + ByteEnd: byteEnd - leftTrimBytes - 1, + }) + } + + return strings.TrimSpace(finalRaw), cues +} + +// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. +// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. +func adjustGroup(match []int, groupIdx int) int { + orig := match[groupIdx] + if orig == -1 { + return -1 + } + // Offset is: original position minus the position of '<' in the original, plus 1 for '[' + return orig - match[0] +} + +// stripEnhancedMarkers removes all inline markers from text, +// returning the plain lyric text. +func stripEnhancedMarkers(text string) string { + return enhancedLRCRegex.ReplaceAllString(text, "") +} + +// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End +// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute +// timestamps anchored at the line's first occurrence, so repeated-line LRC +// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the +// second occurrence to point at the correct moment. Returned *int64 pointers +// are freshly allocated so the input slice is never aliased into the result. +func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { + if len(baseCues) == 0 { + return nil + } + out := make([]Cue, len(baseCues)) + for i, c := range baseCues { + out[i] = c + if c.Start != nil { + s := *c.Start + offsetMs + out[i].Start = &s + } + if c.End != nil { + e := *c.End + offsetMs + out[i].End = &e + } + } + return out +} + +func parseTime(line string, match []int) (int64, error) { + var hours, millis int64 + var err error + + hourStart := match[2] + if hourStart != -1 { + // subtract 1 because group has : at the end + hourEnd := match[3] - 1 + hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) + if err != nil { + return 0, err + } + } + + minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) + if err != nil { + return 0, err + } + + sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) + if err != nil { + return 0, err + } + + msStart := match[8] + if msStart != -1 { + msEnd := match[9] + // +1 offset since this capture group contains . + millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) + if err != nil { + return 0, err + } + + length := msEnd - msStart + + if length == 3 { + millis *= 10 + } else if length == 2 { + millis *= 100 + } + } + + timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis + return timeInMillis, nil +} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go new file mode 100644 index 000000000..38f03587a --- /dev/null +++ b/model/lyrics_lrc_test.go @@ -0,0 +1,219 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseLRC", func() { + It("should parse tags with spaces", func() { + 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()) + Expect(lyrics.DisplayArtist).To(Equal("An artist")) + Expect(lyrics.DisplayTitle).To(Equal("A title")) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) + }) + + It("Should ignore bad offset", func() { + 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 := 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{ + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + 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{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + 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{ + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + 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{ + {Value: "This could [00:00:00] be a synced file"}, + })) + }) + + It("Allows timestamp in middle of line if also at beginning", func() { + 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{ + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + 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{ + {Start: new(int64(0)), Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + 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{ + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + 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{ + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, + })) + }) + + It("should parse Enhanced LRC with word-level timing", func() { + 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)) + + t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) + + line0 := lyrics.Line[0] + Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) + Expect(line0.Value).To(Equal("Some lyrics here")) + Expect(line0.Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, + {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, + })) + + line1 := lyrics.Line[1] + Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) + Expect(line1.Value).To(Equal("More words")) + Expect(line1.Cue).To(Equal([]Cue{ + {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + + Expect(line1.Cue[1].End).To(BeNil()) + }) + + It("should not parse malformed Enhanced LRC timing markers", func() { + 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{ + {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, + })) + }) + + It("should handle mixed Enhanced and plain LRC lines", func() { + 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)) + + t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) + + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) + + Expect(lyrics.Line[1].Cue).To(BeNil()) + Expect(lyrics.Line[1].Value).To(Equal("Plain line")) + + Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ + {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, + {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, + })) + Expect(lyrics.Line[2].Value).To(Equal("More words")) + }) + + It("should preserve byte offsets for Enhanced LRC cues", func() { + 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)) + + t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Oh love me tonight")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, + {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, + {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, + {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, + })) + }) + + It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { + 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)) + + t10000 := int64(10000) + t10100 := int64(10100) + t10500 := int64(10500) + t30000 := int64(30000) + t30100 := int64(30100) + t30500 := int64(30500) + + Expect(lyrics.Line[0].Start).To(Equal(&t10000)) + Expect(lyrics.Line[0].End).To(Equal(&t30000)) + Expect(lyrics.Line[0].Value).To(Equal("Hello world")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + + Expect(lyrics.Line[1].Start).To(Equal(&t30000)) + Expect(lyrics.Line[1].End).To(Equal(&t30500)) + Expect(lyrics.Line[1].Value).To(Equal("Hello world")) + Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ + {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, + {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, + })) + }) +}) diff --git a/model/lyricsfile.go b/model/lyrics_lyricsfile.go similarity index 91% rename from model/lyricsfile.go rename to model/lyrics_lyricsfile.go index b2b123256..49d416f3f 100644 --- a/model/lyricsfile.go +++ b/model/lyrics_lyricsfile.go @@ -1,6 +1,7 @@ package model import ( + "bytes" "fmt" "strings" @@ -8,7 +9,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,9 +20,9 @@ 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(lang string, contents []byte) (LyricList, error) { var doc lyricsfileDocument - dec := yaml.NewDecoder(strings.NewReader(text)) + dec := yaml.NewDecoder(bytes.NewReader(contents)) dec.KnownFields(false) if err := dec.Decode(&doc); err != nil { return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) @@ -31,10 +32,16 @@ func ParseLyricsfile(text string) (LyricList, error) { return nil, nil } + // Fall back to the caller's language when the document omits its own, matching + // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. + docLang := doc.Metadata.Language + if strings.TrimSpace(docLang) == "" { + docLang = lang + } lyrics := Lyrics{ DisplayArtist: str.SanitizeText(doc.Metadata.Artist), DisplayTitle: str.SanitizeText(doc.Metadata.Title), - Lang: normalizeLyricLang(doc.Metadata.Language), + Lang: normalizeLyricLang(docLang), Kind: LyricKindMain, } if doc.Metadata.OffsetMs != 0 { @@ -43,7 +50,7 @@ func ParseLyricsfile(text string) (LyricList, error) { } if doc.Metadata.Instrumental { - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } if len(doc.Lines) == 0 { @@ -52,14 +59,14 @@ func ParseLyricsfile(text string) (LyricList, error) { return nil, nil } lyrics.Line = lines - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } lines, agents := buildLyricsfileLines(doc.Lines) lyrics.Line = lines lyrics.Agents = agents lyrics.Synced = true - return LyricList{NormalizeLyrics(lyrics)}, nil + return LyricList{normalizeLyrics(lyrics)}, nil } const lyricsfileVersion = "1.0" diff --git a/model/lyricsfile_test.go b/model/lyrics_lyricsfile_test.go similarity index 85% rename from model/lyricsfile_test.go rename to model/lyrics_lyricsfile_test.go index a3588a2ea..45899cda7 100644 --- a/model/lyricsfile_test.go +++ b/model/lyrics_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("", []byte(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("", []byte("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("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -67,6 +66,24 @@ lines: Expect(l.Line[1].Cue).To(BeNil()) }) + DescribeTable("resolves the lyric language", + func(metaLanguage, callerLang, want string) { + input := "version: '1.0'\nmetadata:\n title: 'T'\n" + if metaLanguage != "" { + input += " language: '" + metaLanguage + "'\n" + } + input += "lines:\n - text: \"line\"\n start_ms: 0\n" + + lyrics, err := parseLyricsfile(callerLang, []byte(input)) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + Expect(lyrics[0].Lang).To(Equal(want)) + }, + Entry("prefers the document's own language", "eng", "deu", "eng"), + Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), + Entry("uses xxx when neither is provided", "", "", "xxx"), + ) + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { input := `version: '1.0' metadata: @@ -80,7 +97,7 @@ plain: | Second line ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -116,7 +133,7 @@ lines: start_ms: 1500 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -168,7 +185,7 @@ lines: start_ms: 3000 end_ms: 3500 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -207,7 +224,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -246,7 +263,7 @@ metadata: language: 'eng' instrumental: true ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) @@ -270,7 +287,7 @@ lines: start_ms: 2000 end_ms: 3000 ` - lyrics, err := ParseLyricsfile(input) + lyrics, err := parseLyricsfile("", []byte(input)) Expect(err).ToNot(HaveOccurred()) Expect(lyrics).To(HaveLen(1)) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go new file mode 100644 index 000000000..276aa4da3 --- /dev/null +++ b/model/lyrics_normalize.go @@ -0,0 +1,134 @@ +package model + +import ( + "slices" + + "github.com/navidrome/navidrome/utils/gg" +) + +func normalizeLyrics(lyrics Lyrics) Lyrics { + lyrics.Line = normalizeCueLines(lyrics.Line) + if len(lyrics.Agents) == 0 { + lyrics.Agents = nil + } + return lyrics +} + +func normalizeCueLines(lines []Line) []Line { + if len(lines) == 0 { + return lines + } + + normalized := make([]Line, len(lines)) + copy(normalized, lines) + + for i := range normalized { + if len(normalized[i].Cue) > 0 { + normalized[i].Cue = slices.Clone(normalized[i].Cue) + } + + var fallbackEnd *int64 + if normalized[i].End != nil { + v := *normalized[i].End + fallbackEnd = &v + } else if i+1 < len(normalized) && normalized[i+1].Start != nil { + v := *normalized[i+1].Start + fallbackEnd = &v + } + + normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) + } + + return normalized +} + +func normalizeLineTiming(line Line) Line { + if len(line.Cue) == 0 { + return line + } + + var earliestStart *int64 + var latestEnd *int64 + for i := range line.Cue { + token := line.Cue[i] + if token.Start != nil { + if earliestStart == nil || *token.Start < *earliestStart { + v := *token.Start + earliestStart = &v + } + } + + candidateEnd := token.End + if candidateEnd == nil { + candidateEnd = token.Start + } + if candidateEnd != nil { + if latestEnd == nil || *candidateEnd > *latestEnd { + v := *candidateEnd + latestEnd = &v + } + } + } + + if line.Start == nil && earliestStart != nil { + v := *earliestStart + line.Start = &v + } + if line.End == nil && latestEnd != nil { + v := *latestEnd + line.End = &v + } + return line +} + +func normalizeCueLine(line Line, fallbackEnd *int64) Line { + if len(line.Cue) == 0 { + return line + } + line.Cue = NormalizeCueEnds(line.Cue, fallbackEnd) + return normalizeLineTiming(line) +} + +// NormalizeCueEnds resolves missing cue end times within a single ordered cue +// group: each end is filled from the next cue's start, then from fallbackEnd, +// and is clamped so it never precedes the cue's own start nor overruns the next +// cue. End times are all-or-none — if any cue still lacks an end afterwards, all +// ends in the group are cleared. The input slice is never mutated. +// +// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends +// per agent group while building the response; all other normalization is +// package-internal. +func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { + if len(cues) == 0 { + return cues + } + + out := slices.Clone(cues) + for i := range out { + end := out[i].End + if end == nil { + if i+1 < len(out) && out[i+1].Start != nil { + end = out[i+1].Start + } else { + end = fallbackEnd + } + } + if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { + end = out[i+1].Start + } + if end != nil && out[i].Start != nil && *end < *out[i].Start { + end = out[i].Start + } + out[i].End = gg.Clone(end) + } + + for i := range out { + if out[i].End == nil { + for j := range out { + out[j].End = nil + } + break + } + } + return out +} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go new file mode 100644 index 000000000..24faffd54 --- /dev/null +++ b/model/lyrics_normalize_test.go @@ -0,0 +1,120 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("normalizeCueLines", func() { + It("should not mutate caller cue slices when filling missing cue end times", func() { + start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) + lines := []Line{ + { + Start: &start0, + Value: "Some lyrics", + Cue: []Cue{ + {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + }, + }, + { + Start: &nextLineStart, + Value: "Next line", + }, + } + + normalized := normalizeCueLines(lines) + + Expect(normalized[0].Cue[0].End).To(Equal(&start1)) + Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) + Expect(lines[0].Cue[0].End).To(BeNil()) + Expect(lines[0].Cue[1].End).To(BeNil()) + }) +}) + +var _ = Describe("NormalizeCueEnds", func() { + // p returns a fresh pointer so cases don't share *int64 state. + p := func(v int64) *int64 { return &v } + + // endsOf extracts the resolved end times (nil-safe) for compact assertions. + endsOf := func(cues []Cue) []*int64 { + out := make([]*int64, len(cues)) + for i := range cues { + out[i] = cues[i].End + } + return out + } + + It("returns the input as-is when empty", func() { + Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) + Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) + }) + + It("fills a missing end from the next cue's start", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) + }) + + It("fills the last cue's missing end from fallbackEnd", func() { + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) + }) + + It("clamps an end that overruns the next cue's start", func() { + cues := []Cue{ + {Start: p(1000), End: p(9999)}, + {Start: p(1500), End: p(2000)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) + }) + + It("clamps an end that precedes the cue's own start", func() { + cues := []Cue{ + {Start: p(1000), End: p(500)}, + } + + out := NormalizeCueEnds(cues, p(3000)) + + Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) + }) + + It("clears all ends when any cue still lacks one (all-or-none)", func() { + // The last cue has no end and there is no fallback, so it stays nil and + // every end in the group is cleared. + cues := []Cue{ + {Start: p(1000), End: p(1200)}, + {Start: p(1500)}, + } + + out := NormalizeCueEnds(cues, nil) + + Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) + }) + + It("does not mutate the input slice", func() { + cues := []Cue{ + {Start: p(1000)}, + {Start: p(1500)}, + } + + _ = NormalizeCueEnds(cues, p(3000)) + + Expect(cues[0].End).To(BeNil()) + Expect(cues[1].End).To(BeNil()) + }) +}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go new file mode 100644 index 000000000..4bfaa29e8 --- /dev/null +++ b/model/lyrics_parse.go @@ -0,0 +1,73 @@ +package model + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/log" +) + +// lyricParser returns an empty list (not an error) when the input is not its +// format, so parsers can be tried in order. lang is the default for formats that +// do not carry their own. +type lyricParser func(lang string, contents []byte) (LyricList, error) + +// lyricFormats is the structured formats in content-sniff probe order; each +// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. +var lyricFormats = []struct { + suffixes []string + parse lyricParser +}{ + {[]string{".ttml"}, parseTTML}, + {[]string{".srt"}, parseSRT}, + {[]string{".yaml", ".yml"}, parseLyricsfile}, +} + +// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes +// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, +// a structured parser that does not match falls back to the LRC/plain-text floor. +func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) { + contents = stripBOM(contents) + suffix = strings.ToLower(suffix) + sniff := suffix == "" || suffix == "auto" + + // Sniffing tries every format in order; a known suffix selects just its own. + // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. + candidates := make([]lyricParser, 0, len(lyricFormats)) + for _, f := range lyricFormats { + if sniff || slices.Contains(f.suffixes, suffix) { + candidates = append(candidates, f.parse) + } + } + return parseFirstMatch(lang, contents, candidates...) +} + +func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { + for _, parse := range candidates { + list, err := parse(lang, contents) + if err == nil && len(list) > 0 { + return list, nil + } + if err != nil { + log.Warn("Error parsing lyrics, falling back to plain text", "error", err) + } + } + return plainLRC(lang, contents) +} + +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 +} + +func stripBOM(contents []byte) []byte { + return bytes.TrimPrefix(contents, []byte("\ufeff")) +} diff --git a/model/lyrics_embedded_test.go b/model/lyrics_parse_test.go similarity index 60% rename from model/lyrics_embedded_test.go rename to model/lyrics_parse_test.go index 77f17973a..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)) @@ -157,4 +198,16 @@ Another subtitle line` } Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) }) + + 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 := ParseLyrics("", "eng", []byte(yaml)) + + Expect(err).ToNot(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Synced).To(BeTrue()) + Expect(list[0].Line).To(HaveLen(1)) + Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) + }) }) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go index 928fc45d9..319a59961 100644 --- a/model/lyrics_srt.go +++ b/model/lyrics_srt.go @@ -1,7 +1,6 @@ package model import ( - "bytes" "regexp" "strconv" "strings" @@ -14,11 +13,7 @@ var ( srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) ) -func ParseSRT(contents []byte) (LyricList, error) { - return parseSRTWithLanguage(contents, "xxx") -} - -func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) { +func parseSRT(language string, contents []byte) (LyricList, error) { raw := strings.ReplaceAll(string(contents), "\r\n", "\n") raw = strings.ReplaceAll(raw, "\r", "\n") @@ -39,7 +34,7 @@ func parseSRTWithLanguage(contents []byte, language string) (LyricList, error) { return nil, nil } - lyrics := NormalizeLyrics(Lyrics{ + lyrics := normalizeLyrics(Lyrics{ Lang: normalizeLyricLang(language), Line: lines, Synced: true, @@ -65,14 +60,10 @@ func splitSRTBlocks(raw string) []string { } func parseSRTBlock(block string) (Line, bool, error) { - scanner := bytes.Split([]byte(block), []byte("\n")) - if len(scanner) == 0 { - return Line{}, false, nil - } - - lines := make([]string, 0, len(scanner)) - for _, line := range scanner { - lines = append(lines, strings.TrimSpace(string(line))) + rawLines := strings.Split(block, "\n") + lines := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + lines = append(lines, strings.TrimSpace(line)) } if len(lines) == 0 { diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go new file mode 100644 index 000000000..2c0ab2242 --- /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("xxx", 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("xxx", []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..fd954ad26 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,251 +1,10 @@ -package model_test +package model import ( - . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ToLyrics", 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(new(int64(1551)))) - }) - - It("Should ignore bad offset", func() { - lyrics, err := ToLyrics("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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) - }) - - 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]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := ToLyrics("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{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) - - line0 := lyrics.Line[0] - Expect(line0.Start).To(Equal(&t1000)) - Expect(line0.End).To(Equal(&t3000)) - Expect(line0.Value).To(Equal("Some lyrics here")) - Expect(line0.Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, - {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, - })) - - line1 := lyrics.Line[1] - Expect(line1.Start).To(Equal(&t3000)) - Expect(line1.End).To(Equal(&t3500)) - Expect(line1.Value).To(Equal("More words")) - Expect(line1.Cue).To(Equal([]Cue{ - {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - - Expect(line1.Cue[1].End).To(BeNil()) - }) - - It("should not parse malformed Enhanced LRC timing markers", func() { - lyrics, err := ToLyrics("xxx", "[00:01.00]<00:01a50>Not a marker") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(3)) - - t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) - t3000 := int64(3000) - - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - })) - Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) - Expect(lyrics.Line[0].End).To(Equal(&t3000)) - - Expect(lyrics.Line[1].Cue).To(BeNil()) - Expect(lyrics.Line[1].Value).To(Equal("Plain line")) - - Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ - {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - Expect(lyrics.Line[2].Value).To(Equal("More words")) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(1)) - - t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Oh love me tonight")) - Expect(line.Cue).To(Equal([]Cue{ - {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, - {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, - {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, - {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, - })) - }) - - 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") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(2)) - - t10000 := int64(10000) - t10100 := int64(10100) - t10500 := int64(10500) - t30000 := int64(30000) - t30100 := int64(30100) - t30500 := int64(30500) - - Expect(lyrics.Line[0].Start).To(Equal(&t10000)) - Expect(lyrics.Line[0].End).To(Equal(&t30000)) - Expect(lyrics.Line[0].Value).To(Equal("Hello world")) - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - - Expect(lyrics.Line[1].Start).To(Equal(&t30000)) - Expect(lyrics.Line[1].End).To(Equal(&t30500)) - Expect(lyrics.Line[1].Value).To(Equal("Hello world")) - Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ - {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - }) -}) - -var _ = Describe("NormalizeCueLines", func() { - It("should not mutate caller cue slices when filling missing cue end times", func() { - start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) - lines := []Line{ - { - Start: &start0, - Value: "Some lyrics", - Cue: []Cue{ - {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - }, - }, - { - Start: &nextLineStart, - Value: "Next line", - }, - } - - normalized := NormalizeCueLines(lines) - - Expect(normalized[0].Cue[0].End).To(Equal(&start1)) - Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) - Expect(lines[0].Cue[0].End).To(BeNil()) - Expect(lines[0].Cue[1].End).To(BeNil()) - }) -}) - var _ = Describe("Lyrics.EffectiveKind", func() { It("defaults a blank kind to main", func() { Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go index fe3a547d5..95aab3485 100644 --- a/model/lyrics_ttml.go +++ b/model/lyrics_ttml.go @@ -102,13 +102,29 @@ type ttmlParser struct { metadataSeq int } -func ParseTTML(contents []byte) (LyricList, error) { - return parseTTMLWithDefaultLang(contents, "xxx") +func isTTMLDocument(contents []byte) bool { + decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok { + return strings.EqualFold(start.Name.Local, "tt") + } + } } -func parseTTMLWithDefaultLang(contents []byte, defaultLang string) (LyricList, error) { +func parseTTML(defaultLang string, contents []byte) (LyricList, error) { contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) + // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain + // text — isTTMLDocument does a cheap decode that stops at the first element. + // Checked after the encoding fixup so UTF-16-declared documents are recognized. + if !isTTMLDocument(contents) { + return nil, nil + } + p := ttmlParser{ decoder: xml.NewDecoder(bytes.NewReader(contents)), params: ttmlTimingParams{ @@ -184,7 +200,7 @@ func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingConte if len(tokens) > 0 { parsedLine.Cue = tokens } - parsedLine = NormalizeLineTiming(parsedLine) + parsedLine = normalizeLineTiming(parsedLine) lineKey, _ := attrValue(start.Attr, "key") p.addMainLine(ctx.lang, lineKey, parsedLine) @@ -327,7 +343,7 @@ func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTiming if len(tokens) > 0 { line.Cue = tokens } - line = NormalizeLineTiming(line) + line = normalizeLineTiming(line) if line.Value == "" && len(line.Cue) == 0 { return ttmlMetadataEntry{}, false, nil @@ -615,7 +631,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie endMs := *ref.line.End line.End = &endMs } - line = NormalizeLineTiming(line) + line = normalizeLineTiming(line) if line.Value == "" && len(line.Cue) == 0 { continue @@ -657,7 +673,7 @@ func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entrie func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) - return NormalizeLyrics(lyrics) + return normalizeLyrics(lyrics) } func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go index ef882fdcd..a175eef0e 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", 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("xxx", content) Expect(err).ToNot(HaveOccurred()) var pronunciation *Lyrics diff --git a/model/mediafile.go b/model/mediafile.go index 6a489bcd5..d93060dba 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -138,7 +138,7 @@ func (mf MediaFile) AlbumCoverArtID() ArtworkID { } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - lyrics := LyricList{} + var lyrics LyricList err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err 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 c66b027d7..12d84f60d 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -31,8 +31,8 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses -// using model.ToLyrics. +// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response +// 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), @@ -50,13 +50,15 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode if lang == "" { lang = "xxx" } - parsed, err := model.ToLyrics(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 } - if parsed != nil && !parsed.IsEmpty() { - result = append(result, *parsed) + for _, lyric := range parsed { + if !lyric.IsEmpty() { + result = append(result, lyric) + } } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index a1a6c1809..6e82dbfab 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -83,6 +83,32 @@ var _ = Describe("LyricsPlugin", Ordered, func() { _, err := p.GetLyrics(GinkgoT().Context(), track) Expect(err).To(HaveOccurred()) }) + + // Each DescribeTable entry proves that the adapter's content-sniffing routes + // the plugin's rich payload to the right parser rather than mangling it as plain text. + DescribeTable("content-sniffs plugin responses across all supported formats", + func(format string, wantSynced bool, wantLine string) { + manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-lyrics": {"format": format}, + }, "test-lyrics"+PackageExtension) + + p, ok := manager.LoadLyricsProvider("test-lyrics") + Expect(ok).To(BeTrue()) + + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + result, err := p.GetLyrics(GinkgoT().Context(), track) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) + Expect(result[0].Line).To(HaveLen(1)) + Expect(result[0].Line[0].Value).To(Equal(wantLine)) + }, + Entry("ttml", "ttml", true, "plugin ttml line"), + Entry("srt", "srt", true, "plugin srt line"), + Entry("yaml", "yaml", true, "plugin yaml line"), + Entry("lrc", "lrc", true, "plugin lrc line"), + Entry("plain", "plain", false, "plugin plain line"), + ) }) Describe("PluginNames", func() { diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 0e485ceba..2ee2dabbf 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,12 +15,47 @@ func init() { type testLyrics struct{} func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - // Check for configured error errMsg, hasErr := pdk.GetConfig("error") if hasErr && errMsg != "" { return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) } + // Config-selected format lets tests exercise the adapter's content-sniffing per format. + format, hasFormat := pdk.GetConfig("format") + if hasFormat { + var text string + var lang string + switch format { + case "ttml": + lang = "eng" + text = ` + + +
+

plugin ttml line

+
+ +
` + case "srt": + lang = "eng" + text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" + case "yaml": + lang = "eng" + text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" + case "lrc": + lang = "eng" + text = "[00:01.00]plugin lrc line" + case "plain": + lang = "eng" + text = "plugin plain line" + } + if text != "" { + return lyrics.GetLyricsResponse{ + Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, + }, nil + } + } + // Check if we should omit language (to test default language handling) noLang, hasNoLang := pdk.GetConfig("no_lang") lang := "eng" diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 3ccbd8961..8cde586b9 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -205,13 +205,14 @@ func (t Tags) Lyrics() string { basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") for _, value := range basicLyrics { - lyrics, err := model.ToLyrics("xxx", value) + parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } for tag, value := range t.Tags { @@ -223,13 +224,14 @@ func (t Tags) Lyrics() string { } for _, text := range value { - lyrics, err := model.ToLyrics(language, text) + parsed, err := model.ParseLyrics(".lrc", language, []byte(text)) if err != nil { log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) continue } - - lyricList = append(lyricList, *lyrics) + if main, ok := parsed.Main(); ok { + lyricList = append(lyricList, main) + } } } } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 0403306a6..ac4aaa5f2 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -70,6 +70,20 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) +// lyricFixture reads a public-domain lyric fixture (the same files the parser +// benchmarks use) so the e2e fixtures stay in sync with real-world content, +// including the word-level timing carried by the .elrc and .yaml variants. +func lyricFixture(name string) string { + // tests.Init chdirs to the project root, so reference fixtures from there. + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) + Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) + return string(data) +} + +// firstFixtureLine is the opening lyric line shared by every auld-lang-syne +// fixture; tests assert against it regardless of source format. +const firstFixtureLine = "Should auld acquaintance be forgot," + // Shared test state var ( ctx context.Context @@ -128,6 +142,9 @@ func buildTestFS() storagetest.FakeFS { // Template for diverse-format transcode test tracks tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable + lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -177,6 +194,31 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), + // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). + // Content comes from tests/fixtures/lyrics (the same public-domain files the + // parser benchmarks use); the .elrc and .yaml variants carry word-level + // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. + // + // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. + // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic + // since ELRC is still valid LRC. + "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", + _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), + "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", + _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), + "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", + _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), + + // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. + // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile + // entries is safe: they are visible to the fake FS but invisible to the scanner. + "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), + "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, + "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), + "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, + "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), + "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -409,6 +451,7 @@ var _ = BeforeSuite(func() { // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" + conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false db.Init(ctx) diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d41d17dbc..6d32a3c88 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,15 +27,16 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats + Expect(albums).To(HaveLen(8)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Pop")) - Expect(albums[6].Title).To(Equal("Transcode Formats")) + Expect(albums[5].Title).To(Equal("Lyrics")) + Expect(albums[6].Title).To(Equal("Pop")) + Expect(albums[7].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -43,23 +44,24 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ + // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Test Artist")) - Expect(albums[5].Artist).To(Equal("Various")) - Expect(albums[6].Artist).To(Equal("シートベルツ")) + Expect(albums[3].Artist).To(Equal("Lyric Tester")) + Expect(albums[4].Artist).To(Equal("Miles Davis")) + Expect(albums[5].Artist).To(Equal("Test Artist")) + Expect(albums[6].Artist).To(Equal("Various")) + Expect(albums[7].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) }) It("type=byGenre filters by genre parameter", func() { @@ -190,7 +192,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(7)) + Expect(albums).To(HaveLen(8)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -201,7 +203,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(7)) + Expect(resp.AlbumList2.Album).To(HaveLen(8)) }) }) diff --git a/server/e2e/subsonic_lyrics_test.go b/server/e2e/subsonic_lyrics_test.go new file mode 100644 index 000000000..da8d513a3 --- /dev/null +++ b/server/e2e/subsonic_lyrics_test.go @@ -0,0 +1,124 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + // songID resolves a track title to its Subsonic ID via search3. + songID := func(title string) string { + resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") + Expect(resp.Status).To(Equal("ok")) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) + return resp.SearchResult3.Song[0].Id + } + + // firstLyric extracts the first StructuredLyric from a LyricsList response. + firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { + Expect(list).ToNot(BeNil()) + Expect(list.StructuredLyrics).ToNot(BeEmpty()) + return list.StructuredLyrics[0] + } + + // songLyrics extension v1: getLyricsBySongId without the enhanced parameter + // returns line-level structured lyrics (line[], lang, synced) and must NOT + // emit any v2/enhanced fields — no cueLine, kind, or agents — even for + // formats that carry word-level timing (ELRC, Lyricsfile YAML). + Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { + DescribeTable("returns line-level lyrics without enhanced fields", + func(title string, wantSynced bool, wantLang string) { + resp := doReq("getLyricsBySongId", "id", songID(title)) + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Synced).To(Equal(wantSynced)) + Expect(got.Lang).To(Equal(wantLang)) + Expect(got.Line).ToNot(BeEmpty()) + Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) + + // v1 must not expose any enhanced (v2) data. + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Kind).To(BeEmpty()) + Expect(got.Agents).To(BeEmpty()) + }, + // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc + // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets + // language: eng, while .srt carries no language and the embedded plain + // text has none — so each format exercises a different language path. + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), + Entry("embedded plain text", "Embedded Plain", false, "xxx"), + Entry("embedded TTML", "Embedded TTML", true, "eng"), + Entry("LRC sidecar", "Sidecar LRC", true, "eng"), + Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), + ) + }) + + // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to + // word/syllable-level timing (cueLine) and the kind classification. Every + // format gains kind="main" for a single untyped lyric layer; only formats + // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) + // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. + Describe("getLyricsBySongId v2 (enhanced)", func() { + DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", + func(title string, wantCueLine bool) { + resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") + Expect(resp.Status).To(Equal("ok")) + got := firstLyric(resp.LyricsList) + + Expect(got.Kind).To(Equal("main")) + if wantCueLine { + Expect(got.CueLine).ToNot(BeEmpty()) + // The first line has one cue per word: "Should auld acquaintance be forgot,". + Expect(got.CueLine[0].Cue).To(HaveLen(5)) + Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) + } else { + Expect(got.CueLine).To(BeEmpty()) + Expect(got.Line).ToNot(BeEmpty()) + } + }, + Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), + Entry("embedded TTML (word-level spans)", "Embedded TTML", true), + Entry("YAML sidecar (word-level)", "Sidecar YAML", true), + Entry("embedded plain text (no timing)", "Embedded Plain", false), + Entry("LRC sidecar (line-level)", "Sidecar LRC", false), + Entry("SRT sidecar (line-level)", "Sidecar SRT", false), + ) + }) + + // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up + // by artist/title and returns the main lyric flattened to plain text — every + // line's Value joined by newlines, with all timing/markup dropped. Synced and + // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. + Describe("getLyrics (legacy artist/title)", func() { + DescribeTable("returns the main lyric as plain text across formats and sources", + func(title string) { + resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) + Expect(resp.Status).To(Equal("ok")) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) + Expect(resp.Lyrics.Title).To(Equal(title)) + Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) + + // No timing markup leaks into the plain-text value, regardless of the + // source format: no LRC brackets/word markers, SRT arrows, or XML tags. + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) + Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) + }, + Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), + Entry("embedded plain text", "Embedded Plain"), + Entry("embedded TTML", "Embedded TTML"), + Entry("LRC sidecar", "Sidecar LRC"), + Entry("SRT sidecar", "Sidecar SRT"), + Entry("YAML sidecar", "Sidecar YAML"), + ) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index a837da124..e652cf492 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -142,7 +142,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(7)) + Expect(resp.AlbumList.Album).To(HaveLen(8)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index e348bc6b9..00b60ad6f 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(6)) - Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(14)) + Expect(resp.SearchResult3.Artist).To(HaveLen(7)) + Expect(resp.SearchResult3.Album).To(HaveLen(8)) + Expect(resp.SearchResult3.Song).To(HaveLen(20)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index a83f2f0eb..485daca58 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,9 +1,13 @@ package subsonic import ( + "io/fs" "testing" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +19,16 @@ func TestSubsonicApi(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } + +// newLocalStorage fatals if the default extractor is not registered. +// Register a no-op so storage.For works in sidecar-lyrics tests. +var _ = BeforeSuite(func() { + local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { + return &subsonicNoopExtractor{} + }) +}) + +type subsonicNoopExtractor struct{} + +func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } +func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index e0f291b70..f4ba6208a 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -2,6 +2,7 @@ package subsonic import ( "encoding/json" + "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -99,10 +100,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 +158,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{ @@ -199,13 +203,16 @@ var _ = Describe("GetLyricsBySongId", func() { conf.Server.LyricsPriority = ".ttml,embedded" r := newGetRequest("id=1") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - ID: "1", - Path: "tests/fixtures/test.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", + ID: "1", + LibraryPath: fixturesDir, + Path: "test.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", }, }) @@ -252,13 +259,16 @@ var _ = Describe("GetLyricsBySongId", func() { conf.Server.LyricsPriority = ".ttml,embedded" r := newGetRequest("id=1&enhanced=true") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - ID: "1", - Path: "tests/fixtures/test-metadata.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", + ID: "1", + LibraryPath: fixturesDir, + Path: "test-metadata.mp3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", }, }) diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 60deda208..228427d5a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,6 +8,7 @@ import ( "errors" "io" "net/http/httptest" + "path/filepath" "slices" "time" @@ -112,9 +113,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()) @@ -163,12 +165,15 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + fixturesDir, err := filepath.Abs("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) mockRepo.SetData(model.MediaFiles{ { - Path: "tests/fixtures/test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + LibraryPath: fixturesDir, + Path: "test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", }, }) response, err := router.GetLyrics(r) diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc new file mode 100644 index 000000000..41342d910 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.elrc @@ -0,0 +1,27 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[lang:eng] +[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, +[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? +[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, +[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? +[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, +[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, +[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, +[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. +[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, +[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, +[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, +[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. +[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, +[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, +[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, +[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. +[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, +[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, +[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd +[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. +[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, +[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, +[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, +[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc new file mode 100644 index 000000000..56021870a --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.lrc @@ -0,0 +1,28 @@ +[ar:Robert Burns] +[ti:Auld Lang Syne] +[al:Traditional] +[lang:eng] +[00:00.00]Should auld acquaintance be forgot, +[00:04.50]And never brought to mind? +[00:09.00]Should auld acquaintance be forgot, +[00:13.50]And auld lang syne? +[00:18.00]For auld lang syne, my dear, +[00:22.50]For auld lang syne, +[00:27.00]We'll tak a cup o' kindness yet, +[00:31.50]For auld lang syne. +[00:36.00]And surely ye'll be your pint-stowp, +[00:40.50]And surely I'll be mine, +[00:45.00]And we'll tak a cup o' kindness yet, +[00:49.50]For auld lang syne. +[00:54.00]We twa hae run about the braes, +[00:58.50]And pou'd the gowans fine, +[01:03.00]But we've wander'd mony a weary fit, +[01:07.50]Sin auld lang syne. +[01:12.00]We twa hae paidl'd in the burn, +[01:16.50]Frae morning sun till dine, +[01:21.00]But seas between us braid hae roar'd +[01:25.50]Sin auld lang syne. +[01:30.00]And there's a hand, my trusty fiere, +[01:34.50]And gie's a hand o' thine, +[01:39.00]And we'll tak a right gude-willie waught, +[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt new file mode 100644 index 000000000..116bec0bf --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.srt @@ -0,0 +1,95 @@ +1 +00:00:00,000 --> 00:00:04,500 +Should auld acquaintance be forgot, + +2 +00:00:04,500 --> 00:00:09,000 +And never brought to mind? + +3 +00:00:09,000 --> 00:00:13,500 +Should auld acquaintance be forgot, + +4 +00:00:13,500 --> 00:00:18,000 +And auld lang syne? + +5 +00:00:18,000 --> 00:00:22,500 +For auld lang syne, my dear, + +6 +00:00:22,500 --> 00:00:27,000 +For auld lang syne, + +7 +00:00:27,000 --> 00:00:31,500 +We'll tak a cup o' kindness yet, + +8 +00:00:31,500 --> 00:00:36,000 +For auld lang syne. + +9 +00:00:36,000 --> 00:00:40,500 +And surely ye'll be your pint-stowp, + +10 +00:00:40,500 --> 00:00:45,000 +And surely I'll be mine, + +11 +00:00:45,000 --> 00:00:49,500 +And we'll tak a cup o' kindness yet, + +12 +00:00:49,500 --> 00:00:54,000 +For auld lang syne. + +13 +00:00:54,000 --> 00:00:58,500 +We twa hae run about the braes, + +14 +00:00:58,500 --> 00:01:03,000 +And pou'd the gowans fine, + +15 +00:01:03,000 --> 00:01:07,500 +But we've wander'd mony a weary fit, + +16 +00:01:07,500 --> 00:01:12,000 +Sin auld lang syne. + +17 +00:01:12,000 --> 00:01:16,500 +We twa hae paidl'd in the burn, + +18 +00:01:16,500 --> 00:01:21,000 +Frae morning sun till dine, + +19 +00:01:21,000 --> 00:01:25,500 +But seas between us braid hae roar'd + +20 +00:01:25,500 --> 00:01:30,000 +Sin auld lang syne. + +21 +00:01:30,000 --> 00:01:34,500 +And there's a hand, my trusty fiere, + +22 +00:01:34,500 --> 00:01:39,000 +And gie's a hand o' thine, + +23 +00:01:39,000 --> 00:01:43,500 +And we'll tak a right gude-willie waught, + +24 +00:01:43,500 --> 00:01:48,000 +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml new file mode 100644 index 000000000..a08be29e2 --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.ttml @@ -0,0 +1,31 @@ + + + +
+

Should auld acquaintance be forgot,

+

And never brought to mind?

+

Should auld acquaintance be forgot,

+

And auld lang syne?

+

For auld lang syne, my dear,

+

For auld lang syne,

+

We'll tak a cup o' kindness yet,

+

For auld lang syne.

+

And surely ye'll be your pint-stowp,

+

And surely I'll be mine,

+

And we'll tak a cup o' kindness yet,

+

For auld lang syne.

+

We twa hae run about the braes,

+

And pou'd the gowans fine,

+

But we've wander'd mony a weary fit,

+

Sin auld lang syne.

+

We twa hae paidl'd in the burn,

+

Frae morning sun till dine,

+

But seas between us braid hae roar'd

+

Sin auld lang syne.

+

And there's a hand, my trusty fiere,

+

And gie's a hand o' thine,

+

And we'll tak a right gude-willie waught,

+

For auld lang syne.

+
+ +
diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt new file mode 100644 index 000000000..42ab8330e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.txt @@ -0,0 +1,24 @@ +Should auld acquaintance be forgot, +And never brought to mind? +Should auld acquaintance be forgot, +And auld lang syne? +For auld lang syne, my dear, +For auld lang syne, +We'll tak a cup o' kindness yet, +For auld lang syne. +And surely ye'll be your pint-stowp, +And surely I'll be mine, +And we'll tak a cup o' kindness yet, +For auld lang syne. +We twa hae run about the braes, +And pou'd the gowans fine, +But we've wander'd mony a weary fit, +Sin auld lang syne. +We twa hae paidl'd in the burn, +Frae morning sun till dine, +But seas between us braid hae roar'd +Sin auld lang syne. +And there's a hand, my trusty fiere, +And gie's a hand o' thine, +And we'll tak a right gude-willie waught, +For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml new file mode 100644 index 000000000..ca2a3d32e --- /dev/null +++ b/tests/fixtures/lyrics/auld-lang-syne.yaml @@ -0,0 +1,95 @@ +version: '1.0' +metadata: + title: 'Auld Lang Syne' + artist: 'Robert Burns' + album: 'Traditional' + language: 'eng' +lines: + - text: "Should auld acquaintance be forgot," + start_ms: 0 + end_ms: 4500 + words: + - text: "Should " + start_ms: 0 + end_ms: 900 + - text: "auld " + start_ms: 900 + end_ms: 1800 + - text: "acquaintance " + start_ms: 1800 + end_ms: 2700 + - text: "be " + start_ms: 2700 + end_ms: 3600 + - text: "forgot," + start_ms: 3600 + end_ms: 4500 + - text: "And never brought to mind?" + start_ms: 4500 + end_ms: 9000 + - text: "Should auld acquaintance be forgot," + start_ms: 9000 + end_ms: 13500 + - text: "And auld lang syne?" + start_ms: 13500 + end_ms: 18000 + - text: "For auld lang syne, my dear," + start_ms: 18000 + end_ms: 22500 + - text: "For auld lang syne," + start_ms: 22500 + end_ms: 27000 + - text: "We'll tak a cup o' kindness yet," + start_ms: 27000 + end_ms: 31500 + - text: "For auld lang syne." + start_ms: 31500 + end_ms: 36000 + - text: "And surely ye'll be your pint-stowp," + start_ms: 36000 + end_ms: 40500 + - text: "And surely I'll be mine," + start_ms: 40500 + end_ms: 45000 + - text: "And we'll tak a cup o' kindness yet," + start_ms: 45000 + end_ms: 49500 + - text: "For auld lang syne." + start_ms: 49500 + end_ms: 54000 + - text: "We twa hae run about the braes," + start_ms: 54000 + end_ms: 58500 + - text: "And pou'd the gowans fine," + start_ms: 58500 + end_ms: 63000 + - text: "But we've wander'd mony a weary fit," + start_ms: 63000 + end_ms: 67500 + - text: "Sin auld lang syne." + start_ms: 67500 + end_ms: 72000 + - text: "We twa hae paidl'd in the burn," + start_ms: 72000 + end_ms: 76500 + - text: "Frae morning sun till dine," + start_ms: 76500 + end_ms: 81000 + - text: "But seas between us braid hae roar'd" + start_ms: 81000 + end_ms: 85500 + - text: "Sin auld lang syne." + start_ms: 85500 + end_ms: 90000 + - text: "And there's a hand, my trusty fiere," + start_ms: 90000 + end_ms: 94500 + - text: "And gie's a hand o' thine," + start_ms: 94500 + end_ms: 99000 + - text: "And we'll tak a right gude-willie waught," + start_ms: 99000 + end_ms: 103500 + - text: "For auld lang syne." + start_ms: 103500 + end_ms: 108000