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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user