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