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
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
package library
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
|
)
|
|
|
|
func openTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("database.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
|
|
func makeMediaFile(t *testing.T, db *sql.DB, relPath string) *database.MediaFile {
|
|
t.Helper()
|
|
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
|
if err != nil {
|
|
t.Fatalf("CreateMusicFolder: %v", err)
|
|
}
|
|
mf, err := database.CreateMediaFile(db, folder.ID, relPath, 1000, 0, "hash1", "flac", "song", 180, 900, 44100, 2)
|
|
if err != nil {
|
|
t.Fatalf("CreateMediaFile: %v", err)
|
|
}
|
|
return mf
|
|
}
|
|
|
|
func TestIndexFileCreatesArtistAlbumSong(t *testing.T) {
|
|
db := openTestDB(t)
|
|
mf := makeMediaFile(t, db, "Artist/Album/01 - Song.flac")
|
|
|
|
tags := &metadata.Tags{
|
|
Title: "Song One",
|
|
Album: "Great Album",
|
|
AlbumArtist: "Great Artist",
|
|
Artists: []string{"Great Artist", "Featured Person"},
|
|
Genres: []string{"Rock", "Indie"},
|
|
TrackNumber: 1,
|
|
Year: 2020,
|
|
}
|
|
|
|
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
|
if err != nil {
|
|
t.Fatalf("IndexFile: %v", err)
|
|
}
|
|
if song.Title != "Song One" {
|
|
t.Errorf("Title = %q", song.Title)
|
|
}
|
|
if !song.AlbumID.Valid {
|
|
t.Fatal("expected song to have an album")
|
|
}
|
|
|
|
album, err := database.GetAlbum(db, song.AlbumID.String)
|
|
if err != nil {
|
|
t.Fatalf("GetAlbum: %v", err)
|
|
}
|
|
if album.Title != "Great Album" {
|
|
t.Errorf("Album title = %q", album.Title)
|
|
}
|
|
|
|
names, err := database.ListSongArtistNames(db, song.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSongArtistNames: %v", err)
|
|
}
|
|
if len(names) != 2 || names[0] != "Great Artist" || names[1] != "Featured Person" {
|
|
t.Errorf("song artists = %v", names)
|
|
}
|
|
|
|
genres, err := database.ListGenres(db)
|
|
if err != nil {
|
|
t.Fatalf("ListGenres: %v", err)
|
|
}
|
|
if len(genres) != 2 {
|
|
t.Errorf("genres = %v, want 2", genres)
|
|
}
|
|
}
|
|
|
|
func TestIndexFileIsIdempotent(t *testing.T) {
|
|
db := openTestDB(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, "/music/"+mf.RelativePath, tags)
|
|
if err != nil {
|
|
t.Fatalf("IndexFile (1st): %v", err)
|
|
}
|
|
song2, err := IndexFile(db, mf, "/music/"+mf.RelativePath, tags)
|
|
if err != nil {
|
|
t.Fatalf("IndexFile (2nd): %v", err)
|
|
}
|
|
if song1.ID != song2.ID {
|
|
t.Errorf("song ID changed across reindex: %s -> %s", song1.ID, song2.ID)
|
|
}
|
|
if song1.AlbumID.String != song2.AlbumID.String {
|
|
t.Errorf("album ID changed across reindex: %s -> %s", song1.AlbumID.String, song2.AlbumID.String)
|
|
}
|
|
|
|
artists, err := database.ListSongArtistNames(db, song1.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSongArtistNames: %v", err)
|
|
}
|
|
if len(artists) != 1 {
|
|
t.Errorf("reindexing duplicated song_artists rows: %v", artists)
|
|
}
|
|
}
|
|
|
|
func TestIndexFileUsesFilenameWhenNoTitle(t *testing.T) {
|
|
db := openTestDB(t)
|
|
mf := makeMediaFile(t, db, "Unknown/Track.flac")
|
|
song, err := IndexFile(db, mf, "/music/"+mf.RelativePath, &metadata.Tags{})
|
|
if err != nil {
|
|
t.Fatalf("IndexFile: %v", err)
|
|
}
|
|
if song.Title != mf.RelativePath {
|
|
t.Errorf("Title = %q, want fallback to relative path %q", song.Title, mf.RelativePath)
|
|
}
|
|
}
|
|
|
|
func TestListDirectory(t *testing.T) {
|
|
db := openTestDB(t)
|
|
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
|
if err != nil {
|
|
t.Fatalf("CreateMusicFolder: %v", err)
|
|
}
|
|
for _, p := range []string{"ArtistA/Album1/01.flac", "ArtistA/Album1/02.flac", "ArtistA/Album2/01.flac", "ArtistB/Album3/01.flac"} {
|
|
if _, err := database.CreateMediaFile(db, folder.ID, p, 1, 0, "h"+p, "flac", "song", 1, 1, 1, 1); err != nil {
|
|
t.Fatalf("CreateMediaFile(%s): %v", p, err)
|
|
}
|
|
}
|
|
|
|
root, err := ListDirectory(db, folder.ID, "")
|
|
if err != nil {
|
|
t.Fatalf("ListDirectory(root): %v", err)
|
|
}
|
|
if len(root) != 2 {
|
|
t.Fatalf("root entries = %v, want 2 (ArtistA, ArtistB)", root)
|
|
}
|
|
for _, e := range root {
|
|
if !e.Dir {
|
|
t.Errorf("root entry %q should be a directory", e.Name)
|
|
}
|
|
}
|
|
|
|
sub, err := ListDirectory(db, folder.ID, "ArtistA")
|
|
if err != nil {
|
|
t.Fatalf("ListDirectory(ArtistA): %v", err)
|
|
}
|
|
if len(sub) != 2 {
|
|
t.Fatalf("ArtistA entries = %v, want 2 (Album1, Album2)", sub)
|
|
}
|
|
|
|
files, err := ListDirectory(db, folder.ID, "ArtistA/Album1")
|
|
if err != nil {
|
|
t.Fatalf("ListDirectory(ArtistA/Album1): %v", err)
|
|
}
|
|
if len(files) != 2 {
|
|
t.Fatalf("Album1 entries = %v, want 2 files", files)
|
|
}
|
|
for _, e := range files {
|
|
if e.Dir {
|
|
t.Errorf("entry %q should be a file", e.Name)
|
|
}
|
|
}
|
|
}
|