Implement songLyrics extension: getLyricsBySongId with real word/syllable cues

Adds getLyricsBySongId (versions 1 and 2 of the songLyrics extension,
gated by the enhanced param): returns every stored structuredLyrics
entry for a song, including line-level start times for synced lyrics
and, only when a source actually provided word/syllable timing (an
Enhanced LRC sidecar) and enhanced=true was requested, cueLine/cue
data with the precomputed UTF-8 byte offsets. Plain and non-enhanced
synced lyrics never get fabricated cue data.

Removes the now-dead SetSongLyrics writer (superseded by
database.InsertLyrics); SongLyricsPlainText keeps serving the legacy
getLyrics endpoint unchanged.

Adds a scanner integration test proving a sidecar .lrc (with an
Enhanced LRC line) is discovered and indexed during a real scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
2026-08-23 08:19:41 -05:00
co-authored by Claude Sonnet 5
parent e48f063f03
commit ec28053d36
5 changed files with 363 additions and 53 deletions
+226
View File
@@ -0,0 +1,226 @@
package subsonic
import (
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func TestGetLyricsBySongIDPlain(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{
{Text: "Héllo wörld"},
{Text: "Second line"},
}); err != nil {
t.Fatal(err)
}
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
j := decodeJSON(t, resp)
list, ok := j["lyricsList"].(map[string]any)
if !ok {
t.Fatalf("lyricsList missing: %v", j)
}
entries, ok := list["structuredLyrics"].([]any)
if !ok || len(entries) != 1 {
t.Fatalf("structuredLyrics = %v, want 1 entry", list)
}
e := entries[0].(map[string]any)
if e["synced"] != false {
t.Errorf("synced = %v, want false", e["synced"])
}
lines, ok := e["line"].([]any)
if !ok || len(lines) != 2 {
t.Fatalf("lines = %v, want 2", e["line"])
}
first := lines[0].(map[string]any)
if first["value"] != "Héllo wörld" {
t.Errorf("line[0].value = %v", first["value"])
}
if _, hasStart := first["start"]; hasStart {
t.Error("plain (unsynced) line should not have a start time")
}
if _, hasCue := e["cueLine"]; hasCue {
t.Error("plain lyrics must never have cueLine data")
}
}
func TestGetLyricsBySongIDSyncedNotEnhanced(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
start0 := 1000
end0 := 1500
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{
{
StartMs: &start0,
Text: "Hello world",
Words: []database.LyricsWordInput{
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 4, Text: "Hello"},
{StartMs: 1500, ByteStart: 6, ByteEnd: 10, Text: "world"},
},
},
}); err != nil {
t.Fatal(err)
}
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
j := decodeJSON(t, resp)
list := j["lyricsList"].(map[string]any)
entries := list["structuredLyrics"].([]any)
e := entries[0].(map[string]any)
if e["synced"] != true {
t.Errorf("synced = %v, want true", e["synced"])
}
lines := e["line"].([]any)
first := lines[0].(map[string]any)
if first["start"] != float64(1000) {
t.Errorf("line[0].start = %v, want 1000", first["start"])
}
// enhanced=false (default): cueLine data must be withheld even though
// word-level cues actually exist in storage.
if _, hasCue := e["cueLine"]; hasCue {
t.Error("cueLine must be omitted when enhanced=false")
}
if _, hasKind := e["kind"]; hasKind {
t.Error("kind must be omitted when enhanced=false")
}
}
func TestGetLyricsBySongIDEnhancedWordCues(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
start0 := 1000
end0 := 1500
end1 := 2200
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{
{
StartMs: &start0,
Text: "Hello wörld",
Words: []database.LyricsWordInput{
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 4, Text: "Hello"},
{StartMs: 1500, EndMs: &end1, ByteStart: 6, ByteEnd: 12, Text: "wörld"},
},
},
}); err != nil {
t.Fatal(err)
}
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("enhanced", "true")
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
j := decodeJSON(t, resp)
list := j["lyricsList"].(map[string]any)
entries := list["structuredLyrics"].([]any)
e := entries[0].(map[string]any)
if e["kind"] != "main" {
t.Errorf("kind = %v, want main", e["kind"])
}
cueLines, ok := e["cueLine"].([]any)
if !ok || len(cueLines) != 1 {
t.Fatalf("cueLine = %v, want 1 entry", e["cueLine"])
}
cl := cueLines[0].(map[string]any)
if cl["value"] != "Hello wörld" {
t.Errorf("cueLine.value = %v", cl["value"])
}
if cl["end"] != float64(2200) {
t.Errorf("cueLine.end = %v, want 2200 (last cue's end)", cl["end"])
}
cues, ok := cl["cue"].([]any)
if !ok || len(cues) != 2 {
t.Fatalf("cue = %v, want 2", cl["cue"])
}
c0 := cues[0].(map[string]any)
if c0["byteStart"] != float64(0) || c0["byteEnd"] != float64(4) || c0["value"] != "Hello" {
t.Errorf("cue[0] = %v", c0)
}
c1 := cues[1].(map[string]any)
// "wörld" starts at byte 6 in the UTF-8 encoded "Hello wörld" and its
// byte offsets were computed against that exact string, not a naive
// rune count, so this exercises the UTF-8-safe path end to end.
if c1["byteStart"] != float64(6) || c1["byteEnd"] != float64(12) || c1["value"] != "wörld" {
t.Errorf("cue[1] = %v", c1)
}
}
func TestGetLyricsBySongIDMultipleEntriesPreserved(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
if _, err := database.InsertLyrics(db, song.ID, "embedded", "xxx", false, []database.LyricsLineInput{{Text: "Plain"}}); err != nil {
t.Fatal(err)
}
start := 0
if _, err := database.InsertLyrics(db, song.ID, "external", "en", true, []database.LyricsLineInput{{StartMs: &start, Text: "Synced"}}); err != nil {
t.Fatal(err)
}
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
j := decodeJSON(t, resp)
list := j["lyricsList"].(map[string]any)
entries := list["structuredLyrics"].([]any)
if len(entries) != 2 {
t.Fatalf("entries = %v, want 2 (embedded + synced both preserved)", entries)
}
}
func TestGetLyricsBySongIDNoLyrics(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getLyricsBySongId.view", q)
j := decodeJSON(t, resp)
list, ok := j["lyricsList"].(map[string]any)
if !ok {
t.Fatalf("lyricsList missing: %v", j)
}
if _, has := list["structuredLyrics"]; has {
t.Errorf("expected no structuredLyrics entries, got %v", list["structuredLyrics"])
}
}
func TestGetLyricsBySongIDUnknownSong(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("id", "sg_doesnotexist")
resp := getPath(t, srv.URL, "/rest/getLyricsBySongId.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70, got %+v", x)
}
}