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