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
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openLyricsTestDB(t *testing.T) (*sql.DB, string) {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
folder, err := CreateMusicFolder(db, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := UpsertSong(db, UpsertSongInput{MediaFileID: mf.ID, Title: "Song"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db, song.ID
|
||||
}
|
||||
|
||||
func TestInsertAndGetPlainLyrics(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
lines := []LyricsLineInput{{Text: "Line one"}, {Text: "Line two"}}
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, lines); err != nil {
|
||||
t.Fatalf("InsertLyrics: %v", err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSongStructuredLyrics: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %v, want 1", entries)
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Synced || e.Source != "embedded" || e.Language != "xxx" {
|
||||
t.Errorf("entry = %+v", e)
|
||||
}
|
||||
if len(e.Lines) != 2 || e.Lines[0].Text != "Line one" {
|
||||
t.Fatalf("lines = %+v", e.Lines)
|
||||
}
|
||||
if e.Lines[0].StartMs != nil {
|
||||
t.Error("plain lyrics line should have nil StartMs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAndGetSyncedLyricsWithWordCues(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
start0 := 1000
|
||||
end0 := 1500
|
||||
lines := []LyricsLineInput{
|
||||
{
|
||||
StartMs: &start0,
|
||||
Text: "Hello world",
|
||||
Words: []LyricsWordInput{
|
||||
{StartMs: 1000, EndMs: &end0, ByteStart: 0, ByteEnd: 5, Text: "Hello"},
|
||||
{StartMs: 1500, ByteStart: 6, ByteEnd: 11, Text: "world"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := InsertLyrics(db, songID, "external", "en", true, lines); err != nil {
|
||||
t.Fatalf("InsertLyrics: %v", err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSongStructuredLyrics: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || !entries[0].Synced {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
line := entries[0].Lines[0]
|
||||
if line.StartMs == nil || *line.StartMs != 1000 {
|
||||
t.Fatalf("line.StartMs = %v, want 1000", line.StartMs)
|
||||
}
|
||||
if len(line.Words) != 2 {
|
||||
t.Fatalf("words = %+v, want 2", line.Words)
|
||||
}
|
||||
if line.Words[0].EndMs == nil || *line.Words[0].EndMs != 1500 {
|
||||
t.Errorf("Words[0].EndMs = %v, want 1500", line.Words[0].EndMs)
|
||||
}
|
||||
if line.Words[1].EndMs != nil {
|
||||
t.Errorf("Words[1].EndMs = %v, want nil (not provided)", line.Words[1].EndMs)
|
||||
}
|
||||
if line.Words[0].ByteStart != 0 || line.Words[0].ByteEnd != 5 {
|
||||
t.Errorf("Words[0] byte offsets = %d,%d, want 0,5", line.Words[0].ByteStart, line.Words[0].ByteEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleLyricsEntriesPerSong(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, []LyricsLineInput{{Text: "Plain"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := 0
|
||||
if _, err := InsertLyrics(db, songID, "external", "en", true, []LyricsLineInput{{StartMs: &start, Text: "Synced"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %v, want 2 (multiple lyric sets preserved)", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSongLyricsCascades(t *testing.T) {
|
||||
db, songID := openLyricsTestDB(t)
|
||||
if _, err := InsertLyrics(db, songID, "embedded", "xxx", false, []LyricsLineInput{{Text: "Plain"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ClearSongLyrics(db, songID); err != nil {
|
||||
t.Fatalf("ClearSongLyrics: %v", err)
|
||||
}
|
||||
entries, err := GetSongStructuredLyrics(db, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("entries = %v, want 0 after clear", entries)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// idempotent: rerunning it for the same file converges to the same rows,
|
||||
// reusing existing artist/album/genre IDs via their natural keys so IDs
|
||||
// stay stable across rescans.
|
||||
func IndexFile(db *sql.DB, mediaFile *database.MediaFile, tags *metadata.Tags) (*database.Song, error) {
|
||||
func IndexFile(db *sql.DB, mediaFile *database.MediaFile, absPath string, tags *metadata.Tags) (*database.Song, error) {
|
||||
title := tags.Title
|
||||
if title == "" {
|
||||
title = mediaFile.RelativePath
|
||||
@@ -114,7 +114,7 @@ func IndexFile(db *sql.DB, mediaFile *database.MediaFile, tags *metadata.Tags) (
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetSongLyrics(db, song.ID, "xxx", tags.EmbeddedLyrics); err != nil {
|
||||
if err := IndexLyrics(db, song.ID, absPath, tags.EmbeddedLyrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestIndexFileCreatesArtistAlbumSong(t *testing.T) {
|
||||
Year: 2020,
|
||||
}
|
||||
|
||||
song, err := IndexFile(db, mf, tags)
|
||||
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile: %v", err)
|
||||
}
|
||||
@@ -87,11 +87,11 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
||||
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
|
||||
tags := &metadata.Tags{Title: "Song", Album: "Album", AlbumArtist: "Artist", Artists: []string{"Artist"}}
|
||||
|
||||
song1, err := IndexFile(db, mf, tags)
|
||||
song1, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile (1st): %v", err)
|
||||
}
|
||||
song2, err := IndexFile(db, mf, tags)
|
||||
song2, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile (2nd): %v", err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func TestIndexFileIsIdempotent(t *testing.T) {
|
||||
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
mf := makeMediaFile(t, db, "Unknown/Track.flac")
|
||||
song, err := IndexFile(db, mf, &metadata.Tags{})
|
||||
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, &metadata.Tags{})
|
||||
if err != nil {
|
||||
t.Fatalf("IndexFile: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/lyrics"
|
||||
)
|
||||
|
||||
// IndexLyrics discovers and stores every lyrics source available for a
|
||||
// scanned file: embedded plain-text lyrics from tags (already extracted
|
||||
// into metadata.Tags by the scanner), and a sidecar .lrc file next to
|
||||
// the media file if one exists — synced, and word-level cued if the
|
||||
// .lrc is an Enhanced LRC file. Both can be present at once and are
|
||||
// stored as separate lyrics entries; nothing here invents timing that
|
||||
// wasn't actually in the source.
|
||||
func IndexLyrics(db *sql.DB, songID, mediaAbsPath, embeddedPlainText string) error {
|
||||
if err := database.ClearSongLyrics(db, songID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if embeddedPlainText != "" {
|
||||
lines := make([]database.LyricsLineInput, 0)
|
||||
for _, l := range strings.Split(strings.ReplaceAll(embeddedPlainText, "\r\n", "\n"), "\n") {
|
||||
lines = append(lines, database.LyricsLineInput{Text: l})
|
||||
}
|
||||
if _, err := database.InsertLyrics(db, songID, "embedded", "xxx", false, lines); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if lrcPath := findSidecarLRC(mediaAbsPath); lrcPath != "" {
|
||||
content, err := os.ReadFile(lrcPath)
|
||||
if err == nil {
|
||||
if err := indexParsedLRC(db, songID, content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func indexParsedLRC(db *sql.DB, songID string, content []byte) error {
|
||||
parsed := lyrics.Parse(string(content))
|
||||
if len(parsed.Lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
lang := parsed.Lang
|
||||
if lang == "" {
|
||||
lang = "xxx"
|
||||
}
|
||||
|
||||
lines := make([]database.LyricsLineInput, 0, len(parsed.Lines))
|
||||
for _, l := range parsed.Lines {
|
||||
start := l.StartMs
|
||||
line := database.LyricsLineInput{StartMs: &start, Text: l.Text}
|
||||
if len(l.Words) > 0 {
|
||||
line.Words = wordsWithByteOffsets(l.Words)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
_, err := database.InsertLyrics(db, songID, "external", lang, true, lines)
|
||||
return err
|
||||
}
|
||||
|
||||
// wordsWithByteOffsets computes each word's zero-based inclusive UTF-8
|
||||
// byte offsets into lineText, matching how internal/lyrics reconstructs
|
||||
// lineText (words joined by single spaces) so the offsets are exact.
|
||||
func wordsWithByteOffsets(words []lyrics.Word) []database.LyricsWordInput {
|
||||
out := make([]database.LyricsWordInput, 0, len(words))
|
||||
pos := 0
|
||||
for _, w := range words {
|
||||
start := pos
|
||||
end := start + len(w.Text) - 1 // inclusive, per the OpenSubsonic Cue schema
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
out = append(out, database.LyricsWordInput{
|
||||
StartMs: w.StartMs, ByteStart: start, ByteEnd: end, Text: w.Text,
|
||||
})
|
||||
pos = end + 2 // one past the inclusive end, plus the joining space
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findSidecarLRC looks for a same-basename .lrc file next to a media
|
||||
// file — the standard convention lyric-fetching tools and taggers use.
|
||||
func findSidecarLRC(mediaAbsPath string) string {
|
||||
dir := filepath.Dir(mediaAbsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaAbsPath), filepath.Ext(mediaAbsPath))
|
||||
candidate := filepath.Join(dir, base+".lrc")
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
song, err := library.IndexFile(s.DB, mf, absPath, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- lyrics_lines needs its own synced start time: a line can be synced
|
||||
-- (has a start time) without necessarily having word-level cues (plain
|
||||
-- LRC vs Enhanced LRC), so the line's timing can't always be derived
|
||||
-- from its cues.
|
||||
ALTER TABLE lyrics_lines ADD COLUMN start_ms INTEGER;
|
||||
Reference in New Issue
Block a user