mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
fix: handle lyricsfile edge cases
This commit is contained in:
parent
59ece1d701
commit
794e024e02
@ -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**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user