diff --git a/internal/database/lyrics.go b/internal/database/lyrics.go new file mode 100644 index 0000000..4613e8a --- /dev/null +++ b/internal/database/lyrics.go @@ -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() +} diff --git a/internal/database/lyrics_test.go b/internal/database/lyrics_test.go new file mode 100644 index 0000000..4bf0bf5 --- /dev/null +++ b/internal/database/lyrics_test.go @@ -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) + } +} diff --git a/internal/library/index.go b/internal/library/index.go index 29eca8e..5100666 100644 --- a/internal/library/index.go +++ b/internal/library/index.go @@ -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 } diff --git a/internal/library/index_test.go b/internal/library/index_test.go index ccf006e..c01e2bd 100644 --- a/internal/library/index_test.go +++ b/internal/library/index_test.go @@ -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) } diff --git a/internal/library/lyrics.go b/internal/library/lyrics.go new file mode 100644 index 0000000..97c4248 --- /dev/null +++ b/internal/library/lyrics.go @@ -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 "" +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 0f49b84..b5a403d 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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 } diff --git a/migrations/0004_lyrics_line_timing.sql b/migrations/0004_lyrics_line_timing.sql new file mode 100644 index 0000000..e38b45b --- /dev/null +++ b/migrations/0004_lyrics_line_timing.sql @@ -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;