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:
Deluan 2026-06-19 17:34:24 -04:00
commit 1dba4064a8
2 changed files with 107 additions and 49 deletions

View file

@ -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()},

View file

@ -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"),
)
})
})