From 794e024e027cfdd9542a1036f745ac2f497af56d Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 6 Jun 2026 05:15:13 +0300 Subject: [PATCH] fix: handle lyricsfile edge cases --- README.md | 2 +- core/lyrics/lyrics_test.go | 24 +++++++++++ model/lyricsfile.go | 74 ++++++++++++++++++++++++++-------- model/lyricsfile_test.go | 81 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index efecfdbcb..c3a6becc1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata - - Supports lyrics from sidecar **.ttml**, **.elrc**, **.lrc**, **.srt**, **.txt** files and embedded **TTML**, **Enhanced LRC**, **LRC**, **SRT**, and plain-text tags (via `lyricspriority`) + - Supports lyrics from sidecar **.ttml**, **.yaml/.yml** Lyricsfile, **.elrc**, **.lrc**, **.srt**, **.txt** files and embedded **TTML**, **Enhanced LRC**, **LRC**, **SRT**, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 68a7f844d..f064458ae 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -224,6 +224,30 @@ var _ = Describe("sources", func() { })) }) + It("falls through generic YAML sidecars that are not Lyricsfile documents", func() { + dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0644)).To(Succeed()) + + conf.Server.LyricsPriority = ".yaml,.lrc" + svc := lyrics.NewLyrics(nil) + list, err := svc.GetLyrics(ctx, &model.MediaFile{ + LibraryPath: dir, + Path: "song.mp3", + }) + + Expect(err).To(BeNil()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Line).To(Equal([]model.Line{ + {Start: ptr(int64(1000)), Value: "Fallback line"}, + })) + }) + Context("Errors", func() { var RegularUserContext = XContext var isRegularUser = os.Getuid() != 0 diff --git a/model/lyricsfile.go b/model/lyricsfile.go index 781d8c5c8..16715b60f 100644 --- a/model/lyricsfile.go +++ b/model/lyricsfile.go @@ -11,8 +11,8 @@ import ( // 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 look like a -// Lyricsfile (no version, no metadata, no lines, no instrumental flag) +// (nil, nil) when the input parses as YAML but does not declare Lyricsfile +// version 1.0. // // When the source contains per-word timing via lines[].words[], each word // becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and @@ -27,7 +27,7 @@ func ParseLyricsfile(text string) (LyricList, error) { return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) } - if doc.Version == "" && doc.Metadata.isEmpty() && len(doc.Lines) == 0 { + if strings.TrimSpace(doc.Version) != lyricsfileVersion { return nil, nil } @@ -42,7 +42,16 @@ func ParseLyricsfile(text string) (LyricList, error) { lyrics.Offset = &off } - if doc.Metadata.Instrumental || len(doc.Lines) == 0 { + if doc.Metadata.Instrumental { + return LyricList{NormalizeLyrics(lyrics)}, nil + } + + if len(doc.Lines) == 0 { + lines := buildPlainLyricsfileLines(doc.Plain) + if len(lines) == 0 { + return nil, nil + } + lyrics.Line = lines return LyricList{NormalizeLyrics(lyrics)}, nil } @@ -53,7 +62,10 @@ func ParseLyricsfile(text string) (LyricList, error) { return LyricList{NormalizeLyrics(lyrics)}, nil } -const lyricsfileKindMain = "main" +const ( + lyricsfileVersion = "1.0" + lyricsfileKindMain = "main" +) type lyricsfileDocument struct { Version string `yaml:"version"` @@ -72,11 +84,6 @@ type lyricsfileMetadata struct { Instrumental bool `yaml:"instrumental"` } -func (m lyricsfileMetadata) isEmpty() bool { - return m.Title == "" && m.Artist == "" && m.Album == "" && - m.DurationMs == 0 && m.OffsetMs == 0 && m.Language == "" && !m.Instrumental -} - type lyricsfileLineEntry struct { Text string `yaml:"text"` StartMs int64 `yaml:"start_ms"` @@ -101,17 +108,17 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { return nil, nil } - // Resolved end timestamps per entry: explicit end_ms if present, otherwise - // the next entry's start. The last entry's end stays nil. + // Resolved end timestamps per entry: explicit end_ms, final word end_ms, + // then the next entry's start. The last entry's end stays nil when no + // explicit or word-level end is available. ends := make([]*int64, len(entries)) for i := range entries { - if entries[i].EndMs != nil { - v := *entries[i].EndMs - ends[i] = &v - } else if i+1 < len(entries) { + var nextStart *int64 + if i+1 < len(entries) { v := entries[i+1].StartMs - ends[i] = &v + nextStart = &v } + ends[i] = lyricsfileLineEnd(entries[i], nextStart) } active := map[int]int64{} @@ -186,6 +193,39 @@ func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { return lines, agents } +func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { + if entry.EndMs != nil { + v := *entry.EndMs + return &v + } + if len(entry.Words) > 0 { + lastWord := entry.Words[len(entry.Words)-1] + if lastWord.EndMs != nil { + v := *lastWord.EndMs + return &v + } + } + if nextStart != nil { + v := *nextStart + return &v + } + return nil +} + +func buildPlainLyricsfileLines(plain string) []Line { + plain = str.SanitizeText(plain) + rawLines := strings.Split(plain, "\n") + lines := make([]Line, 0, len(rawLines)) + for _, raw := range rawLines { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + lines = append(lines, Line{Value: value}) + } + return lines +} + // wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue // entries with inclusive UTF-8 byte offsets into the reconstructed line // value. The line value is built from cue text concatenation rather than diff --git a/model/lyricsfile_test.go b/model/lyricsfile_test.go index 48cce55d4..c883fc99e 100644 --- a/model/lyricsfile_test.go +++ b/model/lyricsfile_test.go @@ -13,6 +13,18 @@ var _ = Describe("ParseLyricsfile", func() { Expect(lyrics).To(BeNil()) }) + It("returns nil,nil for Lyricsfile-shaped YAML without the version marker", func() { + input := `metadata: + title: 'Looks close' +lines: + - text: "But should not be claimed" + start_ms: 1000 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(BeNil()) + }) + It("returns an error for invalid YAML", func() { _, err := ParseLyricsfile("not: valid: yaml: [") Expect(err).To(HaveOccurred()) @@ -58,6 +70,37 @@ lines: Expect(l.Line[1].Cue).To(BeNil()) }) + It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { + input := `version: '1.0' +metadata: + title: 'Plain Track' + artist: 'Plain Artist' + language: 'en' +lines: [] +plain: | + [Verse 1] + First line + + Second line +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Kind).To(Equal("main")) + Expect(l.Lang).To(Equal("en")) + Expect(l.DisplayArtist).To(Equal("Plain Artist")) + Expect(l.DisplayTitle).To(Equal("Plain Track")) + Expect(l.Synced).To(BeFalse()) + Expect(l.Agents).To(BeNil()) + Expect(l.Line).To(Equal([]Line{ + {Value: "[Verse 1]"}, + {Value: "First line"}, + {Value: "Second line"}, + })) + }) + It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { input := `version: '1.0' metadata: @@ -106,6 +149,44 @@ lines: Expect(line.Cue[1].AgentID).To(Equal("")) }) + It("prefers final word end_ms over next line start when inferring line end", func() { + input := `version: '1.0' +metadata: + title: 'Overlap From Words' +lines: + - text: "Long vocal" + start_ms: 1000 + words: + - text: "Long " + start_ms: 1000 + end_ms: 2000 + - text: "vocal" + start_ms: 2000 + end_ms: 4000 + - text: "echo" + start_ms: 3000 + end_ms: 3500 + words: + - text: "echo" + start_ms: 3000 + end_ms: 3500 +` + lyrics, err := ParseLyricsfile(input) + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(1)) + + l := lyrics[0] + Expect(l.Agents).To(Equal([]Agent{ + {ID: "voice-0", Role: "main"}, + {ID: "voice-1", Role: "voice"}, + })) + Expect(l.Line).To(HaveLen(2)) + Expect(l.Line[0].End).ToNot(BeNil()) + Expect(*l.Line[0].End).To(Equal(int64(4000))) + Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) + Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) + }) + It("synthesises voice agents for overlapping lines and attributes per-cue", func() { input := `version: '1.0' metadata: