From ec28053d3615a0e2e8ee73b1cb30bc80e972c61f Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:19:41 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv --- internal/database/subsonic_helpers2.go | 52 ----- internal/scanner/scanner_test.go | 42 ++++ internal/subsonic/lyrics.go | 94 ++++++++ internal/subsonic/lyrics_test.go | 226 ++++++++++++++++++++ internal/subsonic/opensubsonic_core_test.go | 2 +- 5 files changed, 363 insertions(+), 53 deletions(-) create mode 100644 internal/subsonic/lyrics.go create mode 100644 internal/subsonic/lyrics_test.go diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index 11c43b5..b4afdf5 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -143,58 +143,6 @@ func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Al return scanAlbums(rows) } -// SetSongLyrics replaces a song's stored (unsynced) lyrics with -// plainText, split into lines. Called by the scanner when a file has -// embedded lyrics. -func SetSongLyrics(db *sql.DB, songID, language, plainText string) error { - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - if _, err := tx.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID); err != nil { - return err - } - if plainText == "" { - return tx.Commit() - } - lyricsID := idgen.New("ly") - if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, 'embedded', 0)`, - lyricsID, songID, language); err != nil { - return err - } - for i, line := range splitLines(plainText) { - if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text) VALUES (?, ?, ?, ?)`, - idgen.New("ll"), lyricsID, i, line); err != nil { - return err - } - } - return tx.Commit() -} - -func splitLines(s string) []string { - var lines []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - line := s[start:i] - line = trimCR(line) - lines = append(lines, line) - start = i + 1 - } - } - lines = append(lines, trimCR(s[start:])) - return lines -} - -func trimCR(s string) string { - if len(s) > 0 && s[len(s)-1] == '\r' { - return s[:len(s)-1] - } - return s -} - // SongLyricsPlainText returns a song's stored lyrics as plain text // (lines joined with "\n"), and whether any were found. func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index c3cac65..78eebec 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -237,3 +237,45 @@ func TestScanFolderRejectsConcurrentScan(t *testing.T) { t.Fatal("expected error scanning a folder that is already being scanned") } } + +func TestScanFolderIndexesSidecarLRC(t *testing.T) { + s, folder, dir := newTestScanner(t) + genFile(t, dir, "song1.mp3", "Song One", "Artist One") + + lrc := "[ar:Artist One]\n[00:01.00]Hello world\n[00:02.50]<00:02.50>Second <00:02.90>line\n" + if err := os.WriteFile(filepath.Join(dir, "song1.lrc"), []byte(lrc), 0o644); err != nil { + t.Fatalf("write sidecar lrc: %v", err) + } + + if err := s.ScanFolder(context.Background(), folder.ID); err != nil { + t.Fatalf("ScanFolder: %v", err) + } + + songs, err := database.ListSongs(s.DB, 10, 0) + if err != nil || len(songs) != 1 { + t.Fatalf("ListSongs: %v %v", songs, err) + } + + entries, err := database.GetSongStructuredLyrics(s.DB, songs[0].ID) + if err != nil { + t.Fatalf("GetSongStructuredLyrics: %v", err) + } + var synced *database.StructuredLyricsRow + for i := range entries { + if entries[i].Synced { + synced = &entries[i] + } + } + if synced == nil { + t.Fatalf("expected a synced structuredLyrics entry from the sidecar .lrc, got %+v", entries) + } + if len(synced.Lines) != 2 || synced.Lines[0].Text != "Hello world" { + t.Fatalf("lines = %+v", synced.Lines) + } + if synced.Lines[0].StartMs == nil || *synced.Lines[0].StartMs != 1000 { + t.Errorf("line[0].StartMs = %v, want 1000", synced.Lines[0].StartMs) + } + if len(synced.Lines[1].Words) != 2 { + t.Fatalf("expected word-level cues from Enhanced LRC line, got %+v", synced.Lines[1]) + } +} diff --git a/internal/subsonic/lyrics.go b/internal/subsonic/lyrics.go new file mode 100644 index 0000000..8b3d028 --- /dev/null +++ b/internal/subsonic/lyrics.go @@ -0,0 +1,94 @@ +package subsonic + +import ( + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + registerExtension("songLyrics", 1, 2) + register("getLyricsBySongId", handleGetLyricsBySongID) +} + +// handleGetLyricsBySongID implements the songLyrics extension. It never +// fakes cue-level timing: cueLine data is only emitted for entries that +// were actually parsed from an Enhanced-LRC-style source with word/ +// syllable cues, and only when the client asked for enhanced=true. +func handleGetLyricsBySongID(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + enhanced := rc.Params.Bool("enhanced", false) + + db := rc.Server.DB + if _, err := database.GetSong(db, id); err != nil { + return errNotFound("Song") + } + + rows, err := database.GetSongStructuredLyrics(db, id) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + + list := &LyricsList{} + for _, row := range rows { + kind := "main" // every entry VibeSonic currently stores/ingests is a primary lyric layer, never a translation or pronunciation. + if !enhanced && kind != "main" { + continue + } + + sl := StructuredLyrics{ + Lang: row.Language, + Synced: row.Synced, + Line: make([]Line, 0, len(row.Lines)), + } + if enhanced { + sl.Kind = kind + } + + hasCues := false + for _, l := range row.Lines { + line := Line{Value: l.Text} + if row.Synced && l.StartMs != nil { + start := *l.StartMs + line.Start = &start + } + sl.Line = append(sl.Line, line) + if len(l.Words) > 0 { + hasCues = true + } + } + + if enhanced && row.Synced && hasCues { + sl.CueLine = make([]CueLine, 0, len(row.Lines)) + for i, l := range row.Lines { + if len(l.Words) == 0 { + continue + } + cl := CueLine{Index: i, Value: l.Text} + if l.StartMs != nil { + cl.Start = *l.StartMs + } + cl.Cue = make([]Cue, 0, len(l.Words)) + for _, w := range l.Words { + cue := Cue{Start: w.StartMs, ByteStart: w.ByteStart, ByteEnd: w.ByteEnd, Value: w.Text} + if w.EndMs != nil { + end := *w.EndMs + cue.End = &end + } + cl.Cue = append(cl.Cue, cue) + } + if last := l.Words[len(l.Words)-1]; last.EndMs != nil { + cl.End = *last.EndMs + } + sl.CueLine = append(sl.CueLine, cl) + } + } + + list.StructuredLyrics = append(list.StructuredLyrics, sl) + } + + rc.Response = newResponse() + rc.Response.LyricsList = list + return nil +} diff --git a/internal/subsonic/lyrics_test.go b/internal/subsonic/lyrics_test.go new file mode 100644 index 0000000..34750ad --- /dev/null +++ b/internal/subsonic/lyrics_test.go @@ -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) + } +} diff --git a/internal/subsonic/opensubsonic_core_test.go b/internal/subsonic/opensubsonic_core_test.go index 66735e2..ae634b5 100644 --- a/internal/subsonic/opensubsonic_core_test.go +++ b/internal/subsonic/opensubsonic_core_test.go @@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) { // set once Phase 3 is done. for _, want := range []string{ "apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset", - "indexBasedQueue", "playbackReport", + "indexBasedQueue", "playbackReport", "songLyrics", } { if !names[want] { t.Errorf("extension %q not advertised: %v", want, names)