Files
vibesonic/internal/database/lyrics.go
T
owenandClaude Sonnet 5 e48f063f03 Add structured lyrics storage and indexing (line/word timing, byte offsets)
Adds database.InsertLyrics/GetSongStructuredLyrics storing multiple
lyrics entries per song with per-line start times and, when the
source provides it, word-level cues carrying precomputed UTF-8 byte
offsets. library.IndexLyrics wires this into the scanner: embedded
plain-text tags are stored unsynced, and a same-basename sidecar .lrc
(plain or Enhanced) is parsed and stored synced, with word cues when
present. IndexFile now takes the media file's absolute path so lyrics
indexing can locate sidecar files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
2026-08-23 08:16:42 -05:00

195 lines
5.4 KiB
Go

package database
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// This file replaces the minimal plain-text-only lyrics helpers from
// Phase 2 (SetSongLyrics/SongLyricsPlainText in subsonic_helpers2.go,
// still kept for the legacy getLyrics endpoint's simple text need) with
// a structured form that preserves line timing and, when the source
// provides it, word-level cues with precomputed UTF-8 byte offsets —
// everything the OpenSubsonic songLyrics v2 extension needs, stored
// once at index time rather than re-derived per request.
// LyricsWordInput is one word/syllable-level cue to store, already
// carrying its UTF-8 byte offsets into the parent line's text (computed
// by the caller, which is the only place that still has the exact
// string the offsets are relative to).
type LyricsWordInput struct {
StartMs int
EndMs *int
ByteStart int
ByteEnd int
Text string
}
// LyricsLineInput is one line to store. StartMs is nil for unsynced
// lyrics.
type LyricsLineInput struct {
StartMs *int
Text string
Words []LyricsWordInput
}
// ClearSongLyrics removes every stored lyrics entry (all sources) for a
// song; lines and cues cascade-delete with it.
func ClearSongLyrics(db *sql.DB, songID string) error {
_, err := db.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID)
return err
}
// InsertLyrics stores one lyrics entry (one "structuredLyrics" in
// OpenSubsonic terms) for a song. Multiple entries per song are
// expected — e.g. an embedded plain-text tag alongside a synced sidecar
// .lrc file — and are not merged or deduplicated.
func InsertLyrics(db *sql.DB, songID, source, language string, synced bool, lines []LyricsLineInput) (string, error) {
tx, err := db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
lyricsID := idgen.New("ly")
if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, ?, ?)`,
lyricsID, songID, language, source, boolInt(synced)); err != nil {
return "", err
}
for i, line := range lines {
lineID := idgen.New("ll")
var startMs any
if line.StartMs != nil {
startMs = *line.StartMs
}
if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text, start_ms) VALUES (?, ?, ?, ?, ?)`,
lineID, lyricsID, i, line.Text, startMs); err != nil {
return "", err
}
for _, w := range line.Words {
var endMs any
if w.EndMs != nil {
endMs = *w.EndMs
}
if _, err := tx.Exec(`INSERT INTO lyrics_cues (id, lyrics_id, line_index, start_ms, end_ms, byte_start, byte_end, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
idgen.New("lc"), lyricsID, i, w.StartMs, endMs, w.ByteStart, w.ByteEnd, w.Text); err != nil {
return "", err
}
}
}
return lyricsID, tx.Commit()
}
// StructuredLyricsRow is everything stored for one lyrics entry.
type StructuredLyricsRow struct {
ID string
Language string
Source string
Synced bool
Lines []LyricsLineRow
}
type LyricsLineRow struct {
Text string
StartMs *int
Words []LyricsWordRow
}
type LyricsWordRow struct {
StartMs int
EndMs *int
ByteStart int
ByteEnd int
Text string
}
// GetSongStructuredLyrics returns every stored lyrics entry for a song,
// each with its lines and (if present) word-level cues.
func GetSongStructuredLyrics(db *sql.DB, songID string) ([]StructuredLyricsRow, error) {
rows, err := db.Query(`SELECT id, language, source, synced FROM lyrics WHERE song_id = ? ORDER BY created_at`, songID)
if err != nil {
return nil, err
}
var entries []StructuredLyricsRow
for rows.Next() {
var e StructuredLyricsRow
var synced int
if err := rows.Scan(&e.ID, &e.Language, &e.Source, &synced); err != nil {
rows.Close()
return nil, err
}
e.Synced = synced != 0
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close()
for i := range entries {
lines, err := lyricsLinesFor(db, entries[i].ID)
if err != nil {
return nil, err
}
entries[i].Lines = lines
}
return entries, nil
}
func lyricsLinesFor(db *sql.DB, lyricsID string) ([]LyricsLineRow, error) {
rows, err := db.Query(`SELECT line_index, text, start_ms FROM lyrics_lines WHERE lyrics_id = ? ORDER BY line_index`, lyricsID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LyricsLineRow
byIndex := map[int]*LyricsLineRow{}
for rows.Next() {
var idx int
var text string
var startMs sql.NullInt64
if err := rows.Scan(&idx, &text, &startMs); err != nil {
return nil, err
}
line := LyricsLineRow{Text: text}
if startMs.Valid {
v := int(startMs.Int64)
line.StartMs = &v
}
out = append(out, line)
byIndex[idx] = &out[len(out)-1]
}
if err := rows.Err(); err != nil {
return nil, err
}
cueRows, err := db.Query(`SELECT line_index, start_ms, end_ms, byte_start, byte_end, text FROM lyrics_cues WHERE lyrics_id = ? ORDER BY line_index, start_ms`, lyricsID)
if err != nil {
return nil, err
}
defer cueRows.Close()
for cueRows.Next() {
var idx int
var startMs int
var endMs sql.NullInt64
var byteStart, byteEnd int
var text string
if err := cueRows.Scan(&idx, &startMs, &endMs, &byteStart, &byteEnd, &text); err != nil {
return nil, err
}
line, ok := byIndex[idx]
if !ok {
continue
}
w := LyricsWordRow{StartMs: startMs, ByteStart: byteStart, ByteEnd: byteEnd, Text: text}
if endMs.Valid {
v := int(endMs.Int64)
w.EndMs = &v
}
line.Words = append(line.Words, w)
}
return out, cueRows.Err()
}