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() }