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
-52
View File
@@ -143,58 +143,6 @@ func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Al
return scanAlbums(rows) 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 // SongLyricsPlainText returns a song's stored lyrics as plain text
// (lines joined with "\n"), and whether any were found. // (lines joined with "\n"), and whether any were found.
func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) { func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) {
+42
View File
@@ -237,3 +237,45 @@ func TestScanFolderRejectsConcurrentScan(t *testing.T) {
t.Fatal("expected error scanning a folder that is already being scanned") 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])
}
}
+94
View File
@@ -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
}
+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)
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
// set once Phase 3 is done. // set once Phase 3 is done.
for _, want := range []string{ for _, want := range []string{
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset", "apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
"indexBasedQueue", "playbackReport", "indexBasedQueue", "playbackReport", "songLyrics",
} { } {
if !names[want] { if !names[want] {
t.Errorf("extension %q not advertised: %v", want, names) t.Errorf("extension %q not advertised: %v", want, names)