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) } }