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