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
227 lines
6.8 KiB
Go
227 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|