fix: handle lyricsfile edge cases

This commit is contained in:
ranokay 2026-06-06 05:15:13 +03:00
parent 59ece1d701
commit 794e024e02
No known key found for this signature in database
4 changed files with 163 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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