Files
owenandClaude Sonnet 5 ec28053d36 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
2026-08-23 08:19:41 -05:00

95 lines
2.3 KiB
Go

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
}