mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 13:27:09 +02:00
test(lyrics): cover songLyrics v1 vs v2 with word-level fixtures
Correct and expand the e2e lyrics coverage to match the OpenSubsonic songLyrics extension contract: - v1 (getLyricsBySongId, no enhanced): line-level lyrics with no cueLine, kind, or agents — even for word-level formats (ELRC, Lyricsfile YAML). - v2 (getLyricsBySongId?enhanced=true): word-level cueLine surfaces for ELRC and YAML sources; kind="main" is set; a line-level source (SRT) still yields no cueLine even when enhanced. - legacy getLyrics (artist/title): the original Subsonic endpoint, flattening any format to plain text. A prior commit mislabeled this as the "v1 contract"; getLyrics predates OpenSubsonic and is unrelated to the extension versions. Drive these with the public-domain tests/fixtures/lyrics files (the same set the parser benchmarks use) so the e2e content stays in sync and actually carries the word-level timing needed to distinguish v1 from v2. The embedded "synced LRC" fixture is upgraded to ELRC (word-level); track counts are unchanged, so the rest of the suite is unaffected.
This commit is contained in:
parent
271eaf934c
commit
1dba4064a8
2 changed files with 107 additions and 49 deletions
|
|
@ -70,17 +70,19 @@ const (
|
|||
mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id
|
||||
)
|
||||
|
||||
// Lyric fixture payloads used in buildTestFS.
|
||||
const embeddedTTML = `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">embedded ttml line</p></div></body></tt>`
|
||||
// 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)
|
||||
}
|
||||
|
||||
const sidecarLyricsfileYAML = `version: "1.0"
|
||||
metadata:
|
||||
title: Sidecar YAML
|
||||
language: eng
|
||||
lines:
|
||||
- text: sidecar yaml line
|
||||
start_ms: 6000
|
||||
`
|
||||
// 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 (
|
||||
|
|
@ -192,24 +194,30 @@ 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)
|
||||
// Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time
|
||||
"Lyrics/Embedded/01 - Embedded Synced LRC.mp3": lyricsAlbum(track(1, "Embedded Synced LRC",
|
||||
_t{"lyrics": "[00:01.00]embedded lrc line one\n[00:02.00]embedded lrc line two"})),
|
||||
// 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": "plain embedded line one\nplain embedded line two"})),
|
||||
_t{"lyrics": lyricFixture("auld-lang-syne.txt")})),
|
||||
"Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML",
|
||||
_t{"lyrics": embeddedTTML})),
|
||||
_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("[00:03.00]sidecar lrc line"), ModTime: time.Now()},
|
||||
"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("1\n00:00:04,000 --> 00:00:05,000\nsidecar srt line\n"), ModTime: time.Now()},
|
||||
"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(sidecarLyricsfileYAML), ModTime: time.Now()},
|
||||
"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()},
|
||||
|
|
|
|||
|
|
@ -27,56 +27,106 @@ var _ = Describe("Lyrics endpoints", func() {
|
|||
return list.StructuredLyrics[0]
|
||||
}
|
||||
|
||||
Describe("getLyricsBySongId (v2 structured)", func() {
|
||||
DescribeTable("returns structured lyrics",
|
||||
func(title string, wantSynced bool, wantFirstLine, wantLang string) {
|
||||
// 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(wantFirstLine))
|
||||
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"
|
||||
Entry("synced LRC embedded", "Embedded Synced LRC", true, "embedded lrc line one", "xxx"),
|
||||
Entry("plain text embedded", "Embedded Plain", false, "plain embedded line one", "xxx"),
|
||||
Entry("TTML embedded", "Embedded TTML", true, "embedded ttml line", "xxx"),
|
||||
Entry("LRC sidecar", "Sidecar LRC", true, "sidecar lrc line", "xxx"),
|
||||
Entry("SRT sidecar", "Sidecar SRT", true, "sidecar srt line", "xxx"),
|
||||
// YAML sidecar fixture sets "language: eng"; verify Navidrome passes it through unchanged.
|
||||
Entry("YAML sidecar", "Sidecar YAML", true, "sidecar yaml line", "eng"),
|
||||
// "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. Formats
|
||||
// that carry word-level timing (ELRC, Lyricsfile YAML) surface cueLine; all
|
||||
// formats gain kind="main" for a single untyped lyric layer.
|
||||
Describe("getLyricsBySongId v2 (enhanced)", func() {
|
||||
It("exposes word-level cueLine for an enhanced-LRC source", func() {
|
||||
resp := doReq("getLyricsBySongId", "id", songID("Embedded Enhanced LRC"), "enhanced", "true")
|
||||
Expect(resp.Status).To(Equal("ok"))
|
||||
got := firstLyric(resp.LyricsList)
|
||||
|
||||
Expect(got.Kind).To(Equal("main"))
|
||||
Expect(got.CueLine).ToNot(BeEmpty())
|
||||
// The first line "Should auld acquaintance be forgot," has one cue per word.
|
||||
Expect(got.CueLine[0].Cue).To(HaveLen(5))
|
||||
Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should "))
|
||||
})
|
||||
|
||||
It("exposes word-level cueLine for a Lyricsfile YAML sidecar", func() {
|
||||
resp := doReq("getLyricsBySongId", "id", songID("Sidecar YAML"), "enhanced", "true")
|
||||
Expect(resp.Status).To(Equal("ok"))
|
||||
got := firstLyric(resp.LyricsList)
|
||||
|
||||
Expect(got.Kind).To(Equal("main"))
|
||||
Expect(got.CueLine).ToNot(BeEmpty())
|
||||
Expect(got.CueLine[0].Cue).ToNot(BeEmpty())
|
||||
Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should "))
|
||||
})
|
||||
|
||||
It("emits kind but no cueLine for a line-level (SRT) source", func() {
|
||||
resp := doReq("getLyricsBySongId", "id", songID("Sidecar SRT"), "enhanced", "true")
|
||||
Expect(resp.Status).To(Equal("ok"))
|
||||
got := firstLyric(resp.LyricsList)
|
||||
|
||||
// SRT has no word-level timing, so even enhanced yields no cueLine.
|
||||
Expect(got.Kind).To(Equal("main"))
|
||||
Expect(got.CueLine).To(BeEmpty())
|
||||
Expect(got.Line).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// 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() {
|
||||
// The legacy endpoint flattens the main lyric to plain text: it emits each
|
||||
// line's Value joined by newlines, dropping all timing/markup. This is the
|
||||
// v1 contract — synced structured formats (LRC/TTML/SRT/YAML) all "fall
|
||||
// back" to LRC-style plain text here.
|
||||
DescribeTable("returns the main lyric as plain text across formats and sources",
|
||||
func(title string, wantLines []string) {
|
||||
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))
|
||||
for _, line := range wantLines {
|
||||
Expect(resp.Lyrics.Value).To(ContainSubstring(line))
|
||||
}
|
||||
// v1 fallback: no timing markup leaks into the plain-text value,
|
||||
// regardless of the source format (LRC brackets, SRT arrows, XML
|
||||
// tags). The structured content is reduced to LRC-style plain text.
|
||||
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 synced LRC", "Embedded Synced LRC", []string{"embedded lrc line one", "embedded lrc line two"}),
|
||||
Entry("embedded plain text", "Embedded Plain", []string{"plain embedded line one", "plain embedded line two"}),
|
||||
Entry("embedded TTML", "Embedded TTML", []string{"embedded ttml line"}),
|
||||
Entry("LRC sidecar", "Sidecar LRC", []string{"sidecar lrc line"}),
|
||||
Entry("SRT sidecar", "Sidecar SRT", []string{"sidecar srt line"}),
|
||||
Entry("YAML sidecar", "Sidecar YAML", []string{"sidecar yaml line"}),
|
||||
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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue