Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
847 lines
20 KiB
Go
847 lines
20 KiB
Go
package library
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/metadata"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure helper tests — no database dependency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetRecordingName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib := &Library{} // getRecordingName uses only tags + filePath
|
|
|
|
tests := []struct {
|
|
name string
|
|
title string
|
|
filePath string
|
|
want string
|
|
}{
|
|
{
|
|
name: "title present",
|
|
title: "Bohemian Rhapsody",
|
|
filePath: "/music/queen/bohemian.mp3",
|
|
want: "Bohemian Rhapsody",
|
|
},
|
|
{
|
|
name: "title empty falls back to filename sans extension",
|
|
title: "",
|
|
filePath: "/music/song.mp3",
|
|
want: "song",
|
|
},
|
|
{
|
|
name: "title empty with complex filename",
|
|
title: "",
|
|
filePath: "/music/Artist - Track.flac",
|
|
want: "Artist - Track",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tags := &metadata.TrackMetadata{Title: tt.title}
|
|
got := lib.getRecordingName(tags, tt.filePath)
|
|
|
|
if got != tt.want {
|
|
t.Errorf("getRecordingName() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToNullInt64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input int
|
|
want sql.NullInt64
|
|
}{
|
|
{
|
|
name: "zero is null",
|
|
input: 0,
|
|
want: sql.NullInt64{},
|
|
},
|
|
{
|
|
name: "positive is valid",
|
|
input: 5,
|
|
want: sql.NullInt64{Int64: 5, Valid: true},
|
|
},
|
|
{
|
|
name: "negative is valid",
|
|
input: -1,
|
|
want: sql.NullInt64{Int64: -1, Valid: true},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := toNullInt64(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("toNullInt64(%d) = %+v, want %+v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToNullString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want sql.NullString
|
|
}{
|
|
{
|
|
name: "empty is null",
|
|
input: "",
|
|
want: sql.NullString{},
|
|
},
|
|
{
|
|
name: "non-empty is valid",
|
|
input: "rock",
|
|
want: sql.NullString{String: "rock", Valid: true},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := toNullString(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("toNullString(%q) = %+v, want %+v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitGenres(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "empty string returns nil",
|
|
input: "",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "single genre",
|
|
input: "Rock",
|
|
want: []string{"Rock"},
|
|
},
|
|
{
|
|
name: "multiple genres",
|
|
input: "Rock||Jazz||Blues",
|
|
want: []string{"Rock", "Jazz", "Blues"},
|
|
},
|
|
{
|
|
name: "two genres",
|
|
input: "Electronic||Ambient",
|
|
want: []string{"Electronic", "Ambient"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := splitGenres(tt.input)
|
|
|
|
if tt.want == nil {
|
|
if got != nil {
|
|
t.Errorf("splitGenres(%q) = %v, want nil", tt.input, got)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("splitGenres(%q) length = %d, want %d", tt.input, len(got), len(tt.want))
|
|
}
|
|
|
|
for i, v := range got {
|
|
if v != tt.want[i] {
|
|
t.Errorf("splitGenres(%q)[%d] = %q, want %q", tt.input, i, v, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTrackFromRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
track := trackFromRow(sqlcgen.TrackMetadatum{
|
|
FilePath: "/music/queen/bohemian.flac",
|
|
LengthMilliseconds: 180000,
|
|
Title: "Bohemian Rhapsody",
|
|
ArtistName: "Queen",
|
|
TrackNumber: sql.NullInt64{Int64: 1, Valid: true},
|
|
DiscNumber: sql.NullInt64{Int64: 1, Valid: true},
|
|
Album: "A Night at the Opera",
|
|
Genre: "Rock||Progressive Rock",
|
|
Year: 1975,
|
|
Composer: "Freddie Mercury",
|
|
FileType: ".flac",
|
|
SampleRate: 44100,
|
|
BitDepth: 16,
|
|
Channels: 2,
|
|
Bitrate: 1411,
|
|
FileSize: 35000000,
|
|
})
|
|
|
|
if track.TrackName != "Bohemian Rhapsody" {
|
|
t.Errorf("TrackName = %q, want %q", track.TrackName, "Bohemian Rhapsody")
|
|
}
|
|
|
|
if track.ArtistName != "Queen" {
|
|
t.Errorf("ArtistName = %q, want %q", track.ArtistName, "Queen")
|
|
}
|
|
|
|
if track.TrackLength != "180000" {
|
|
t.Errorf("TrackLength = %q, want %q", track.TrackLength, "180000")
|
|
}
|
|
|
|
if len(track.Genre) != 2 || track.Genre[0] != "Rock" ||
|
|
track.Genre[1] != "Progressive Rock" {
|
|
t.Errorf("Genre = %v, want [Rock, Progressive Rock]", track.Genre)
|
|
}
|
|
|
|
if track.Year != 1975 {
|
|
t.Errorf("Year = %d, want %d", track.Year, 1975)
|
|
}
|
|
|
|
if track.SampleRate != 44100 {
|
|
t.Errorf("SampleRate = %d, want %d", track.SampleRate, 44100)
|
|
}
|
|
|
|
if track.BitDepth != 16 {
|
|
t.Errorf("BitDepth = %d, want %d", track.BitDepth, 16)
|
|
}
|
|
|
|
if track.Channels != 2 {
|
|
t.Errorf("Channels = %d, want %d", track.Channels, 2)
|
|
}
|
|
|
|
if track.Bitrate != 1411 {
|
|
t.Errorf("Bitrate = %d, want %d", track.Bitrate, 1411)
|
|
}
|
|
|
|
if track.FileSize != 35000000 {
|
|
t.Errorf("FileSize = %d, want %d", track.FileSize, 35000000)
|
|
}
|
|
|
|
// A NULL track/disc number yields 0, not a panic.
|
|
trackNull := trackFromRow(sqlcgen.TrackMetadatum{
|
|
FilePath: "/music/unknown.mp3",
|
|
Title: "Test",
|
|
ArtistName: "Artist",
|
|
})
|
|
|
|
if trackNull.TrackNumber != 0 {
|
|
t.Errorf("null TrackNumber = %d, want 0", trackNull.TrackNumber)
|
|
}
|
|
|
|
if trackNull.DiscNumber != 0 {
|
|
t.Errorf("null DiscNumber = %d, want 0", trackNull.DiscNumber)
|
|
}
|
|
|
|
if trackNull.Genre != nil {
|
|
t.Errorf("empty Genre = %v, want nil", trackNull.Genre)
|
|
}
|
|
}
|
|
|
|
func setupTestLibrary(t *testing.T) (*Library, *database.DB) {
|
|
t.Helper()
|
|
|
|
db := database.NewTestDB(t)
|
|
|
|
// Construct Library directly (internal test) — avoids Config.Validate
|
|
// calling os.Stat on the directory. Entity cache functions only need
|
|
// l.ctx and l.db; they have no Wails runtime dependency.
|
|
lib := &Library{
|
|
ctx: t.Context(),
|
|
logger: slog.Default(),
|
|
conf: &Config{},
|
|
db: db,
|
|
}
|
|
|
|
return lib, db
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entity cache tests — DB-backed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCachedUpsertArtist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
q := lib.db.Queries
|
|
|
|
first := lib.cachedUpsertArtist(q, cache, "Queen", "")
|
|
if first.ID == 0 {
|
|
t.Fatal("expected non-zero artist ID")
|
|
}
|
|
|
|
// Second call is a cache hit and returns the same row.
|
|
if second := lib.cachedUpsertArtist(q, cache, "Queen", ""); second.ID != first.ID {
|
|
t.Errorf("cache miss: got ID %d, want %d", second.ID, first.ID)
|
|
}
|
|
|
|
if other := lib.cachedUpsertArtist(q, cache, "Beyonce", ""); other.ID == first.ID {
|
|
t.Errorf("different name returned same ID %d", other.ID)
|
|
}
|
|
|
|
if len(cache.artists) != 2 {
|
|
t.Errorf("cache entries = %d, want 2", len(cache.artists))
|
|
}
|
|
|
|
// An MBID arriving on a later file is written to the cached row -
|
|
// the first file of an album often has no MBID and a later one does.
|
|
withMBID := lib.cachedUpsertArtist(q, cache, "Queen", "mbid-queen")
|
|
if !withMBID.Mbid.Valid || withMBID.Mbid.String != "mbid-queen" {
|
|
t.Errorf("artist mbid = %v, want mbid-queen", withMBID.Mbid)
|
|
}
|
|
|
|
// An empty name is not a missing row: it becomes "Unknown Artist",
|
|
// because a file with no artist tag still has to belong somewhere.
|
|
unknown := lib.cachedUpsertArtist(q, cache, "", "")
|
|
if unknown.Name != "Unknown Artist" {
|
|
t.Errorf("empty artist name = %q, want %q", unknown.Name, "Unknown Artist")
|
|
}
|
|
}
|
|
|
|
func TestCachedUpsertAlbum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
q := lib.db.Queries
|
|
|
|
first := lib.cachedUpsertAlbum(q, cache, albumParams{
|
|
name: "A Night at the Opera",
|
|
credit: "Queen",
|
|
})
|
|
if first.ID == 0 {
|
|
t.Fatal("expected non-zero album ID")
|
|
}
|
|
|
|
same := lib.cachedUpsertAlbum(q, cache, albumParams{
|
|
name: "A Night at the Opera",
|
|
credit: "Queen",
|
|
})
|
|
if same.ID != first.ID {
|
|
t.Errorf("cache miss: got ID %d, want %d", same.ID, first.ID)
|
|
}
|
|
|
|
// Album identity is (name, credit), so the same title by someone
|
|
// else is a different album.
|
|
other := lib.cachedUpsertAlbum(q, cache, albumParams{
|
|
name: "A Night at the Opera",
|
|
credit: "Blind Guardian",
|
|
})
|
|
if other.ID == first.ID {
|
|
t.Error("same album name by a different artist collapsed into one album")
|
|
}
|
|
}
|
|
|
|
func TestCachedUpsertAlbum_FillsCoverArtLater(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
q := lib.db.Queries
|
|
|
|
album := lib.cachedUpsertAlbum(q, cache, albumParams{name: "Art", credit: "A"})
|
|
|
|
ca, err := q.UpsertCoverArt(lib.ctx, sqlcgen.UpsertCoverArtParams{
|
|
FilePath: "/covers/art.jpg",
|
|
MimeType: "image/jpeg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upsert cover art: %v", err)
|
|
}
|
|
|
|
// The first file of an album often carries no embedded art and a
|
|
// later one does; the album has to pick it up.
|
|
withArt := lib.cachedUpsertAlbum(q, cache, albumParams{
|
|
name: "Art",
|
|
credit: "A",
|
|
coverArtID: sql.NullInt64{Int64: ca.ID, Valid: true},
|
|
})
|
|
|
|
if withArt.ID != album.ID {
|
|
t.Fatalf("album ID changed: got %d, want %d", withArt.ID, album.ID)
|
|
}
|
|
|
|
if !withArt.CoverArtID.Valid || withArt.CoverArtID.Int64 != ca.ID {
|
|
t.Errorf("cover art = %v, want %d", withArt.CoverArtID, ca.ID)
|
|
}
|
|
}
|
|
|
|
func TestCachedUpsertGenre(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
q := lib.db.Queries
|
|
|
|
first, err := lib.cachedUpsertGenre(q, cache, "Rock")
|
|
if err != nil {
|
|
t.Fatalf("cachedUpsertGenre: %v", err)
|
|
}
|
|
|
|
second, err := lib.cachedUpsertGenre(q, cache, "Rock")
|
|
if err != nil {
|
|
t.Fatalf("cachedUpsertGenre (cached): %v", err)
|
|
}
|
|
|
|
if second.ID != first.ID {
|
|
t.Errorf("cache miss: got ID %d, want %d", second.ID, first.ID)
|
|
}
|
|
}
|
|
|
|
// TestPruneEmptyEntities is what is left of four orphan-sweep tests.
|
|
//
|
|
// Three of the tables they covered are gone, and with them the bug they
|
|
// were guarding: a file used to create a recording, a credit, a
|
|
// credit-artist link and a release-group link, none of which were
|
|
// deleted when the file was, so a real library accumulated 812
|
|
// recordings, 216 release groups and 260 artists with nothing behind
|
|
// them. Two tables can still be left empty by a removal, and this is
|
|
// that.
|
|
func TestPruneEmptyEntities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, db := setupTestLibrary(t)
|
|
|
|
kept := database.InsertTestTrack(t, db, database.TestTrack{
|
|
FilePath: "/music/kept.mp3",
|
|
Title: "Kept",
|
|
Artist: "Kept Artist",
|
|
Album: "Kept Album",
|
|
Genres: []string{"Kept Genre"},
|
|
})
|
|
gone := database.InsertTestTrack(t, db, database.TestTrack{
|
|
FilePath: "/music/gone.mp3",
|
|
Title: "Gone",
|
|
Artist: "Gone Artist",
|
|
Album: "Gone Album",
|
|
Genres: []string{"Gone Genre"},
|
|
})
|
|
|
|
_ = kept
|
|
|
|
if err := db.Queries.DeleteAudioFile(lib.ctx, gone); err != nil {
|
|
t.Fatalf("delete audio file: %v", err)
|
|
}
|
|
|
|
lib.pruneEmptyEntities()
|
|
|
|
for _, c := range []struct {
|
|
table string
|
|
name string
|
|
want int
|
|
}{
|
|
{"albums", "Gone Album", 0},
|
|
{"albums", "Kept Album", 1},
|
|
{"artists", "Gone Artist", 0},
|
|
{"artists", "Kept Artist", 1},
|
|
{"genres", "Gone Genre", 0},
|
|
{"genres", "Kept Genre", 1},
|
|
} {
|
|
var n int
|
|
if err := db.QueryRowWriter(
|
|
"SELECT COUNT(*) FROM "+c.table+" WHERE name = ?", c.name,
|
|
).Scan(&n); err != nil {
|
|
t.Fatalf("count %s %q: %v", c.table, c.name, err)
|
|
}
|
|
|
|
if n != c.want {
|
|
t.Errorf("%s %q rows = %d, want %d", c.table, c.name, n, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommitBatch_TaggingItemsBookkeeping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, db := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
metrics := newScanMetrics()
|
|
|
|
var added, updated, skipped atomic.Int64
|
|
|
|
batch := []importResult{
|
|
{
|
|
absolutePath: "/music/Artist/Album 1/01.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 200000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "A1T1", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album 1", TrackNumber: 1, DiscNumber: 0,
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
{
|
|
absolutePath: "/music/Artist/Album 1/02.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 210000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "A1T2", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album 1", TrackNumber: 2, DiscNumber: 0,
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
{
|
|
absolutePath: "/music/Artist/Album 2 [Disc 1]/01.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 220000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "A2D1T1", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album 2", TrackNumber: 1, DiscNumber: 1,
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
{
|
|
absolutePath: "/music/Artist/Album 2 [Disc 2]/01.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 230000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "A2D2T1", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album 2", TrackNumber: 1, DiscNumber: 2,
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
{
|
|
absolutePath: "/music/Orphan/singleton.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 100000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "Orphan", Artist: "Solo", AlbumArtist: "Solo",
|
|
Album: "", TrackNumber: 0, DiscNumber: 0,
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
}
|
|
|
|
if err := lib.commitBatch(batch, cache, metrics, &added, &updated, &skipped, nil); err != nil {
|
|
t.Fatalf("commitBatch: %v", err)
|
|
}
|
|
|
|
if added.Load() != int64(len(batch)) {
|
|
for _, w := range metrics.Warnings {
|
|
t.Logf("warning: path=%s phase=%s err=%s", w.FilePath, w.Phase, w.Err)
|
|
}
|
|
|
|
t.Fatalf("added = %d, want %d (skipped=%d)", added.Load(), len(batch), skipped.Load())
|
|
}
|
|
|
|
groupCount := queryInt(t, db, "SELECT COUNT(*) FROM tagging_items")
|
|
|
|
if groupCount != 4 {
|
|
t.Errorf("tagging_items count = %d, want 4", groupCount)
|
|
}
|
|
|
|
// Each group should carry the expected track_count.
|
|
wantCounts := map[[3]any]int64{
|
|
{int64(0), "Album 1", int64(0)}: 2,
|
|
{int64(0), "Album 2", int64(1)}: 1,
|
|
{int64(0), "Album 2", int64(2)}: 1,
|
|
{int64(0), "", int64(0)}: 1,
|
|
}
|
|
|
|
for key, want := range wantCounts {
|
|
libID, _ := key[0].(int64)
|
|
album, _ := key[1].(string)
|
|
disc, _ := key[2].(int64)
|
|
|
|
rows, err := db.QueryContext(
|
|
`SELECT track_count FROM tagging_items
|
|
WHERE library_id = ? AND album_name = ? AND disc_number = ?`,
|
|
libID, album, disc,
|
|
)
|
|
if err != nil {
|
|
t.Errorf("query track_count for %v: %v", key, err)
|
|
|
|
continue
|
|
}
|
|
|
|
var got int64
|
|
if rows.Next() {
|
|
if scanErr := rows.Scan(&got); scanErr != nil {
|
|
t.Errorf("scan track_count for %v: %v", key, scanErr)
|
|
}
|
|
}
|
|
|
|
_ = rows.Close()
|
|
|
|
if got != want {
|
|
t.Errorf("track_count for %v = %d, want %d", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommitBatch_AlbumTagChangeKeepsGroup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Folder-based grouping: if a track stays in the same folder
|
|
// but its album tag changes (very common — autotag itself
|
|
// rewrites album tags), the group_key should NOT change. Test
|
|
// guards against the old behavior where any album-tag drift
|
|
// would split the album into multiple groups.
|
|
lib, db := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
metrics := newScanMetrics()
|
|
|
|
var added, updated, skipped atomic.Int64
|
|
|
|
initial := []importResult{
|
|
{
|
|
absolutePath: "/music/Artist/Album Folder/01.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 200000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Old Album",
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
}
|
|
|
|
if err := lib.commitBatch(
|
|
initial,
|
|
cache,
|
|
metrics,
|
|
&added,
|
|
&updated,
|
|
&skipped,
|
|
nil,
|
|
); err != nil {
|
|
t.Fatalf("initial commitBatch: %v", err)
|
|
}
|
|
|
|
var (
|
|
fileID int64
|
|
originalGroup string
|
|
)
|
|
|
|
rows, err := db.QueryContext(
|
|
`SELECT id, group_key FROM audio_files WHERE file_path = ?`,
|
|
"/music/Artist/Album Folder/01.mp3",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("lookup file: %v", err)
|
|
}
|
|
|
|
if rows.Next() {
|
|
if scanErr := rows.Scan(&fileID, &originalGroup); scanErr != nil {
|
|
t.Fatalf("scan file: %v", scanErr)
|
|
}
|
|
}
|
|
|
|
_ = rows.Close()
|
|
|
|
update := []importResult{
|
|
{
|
|
absolutePath: "/music/Artist/Album Folder/01.mp3",
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 200000,
|
|
existingFileID: fileID,
|
|
needsUpdate: true,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Albums Canonical Name (Remastered 2024)",
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
}
|
|
|
|
if err := lib.commitBatch(update, cache, metrics, &added, &updated, &skipped, nil); err != nil {
|
|
t.Fatalf("update commitBatch: %v", err)
|
|
}
|
|
|
|
groupCount := queryInt(t, db, `SELECT COUNT(*) FROM tagging_items`)
|
|
if groupCount != 1 {
|
|
t.Errorf("expected exactly 1 tagging_items row, got %d", groupCount)
|
|
}
|
|
|
|
rows2, err := db.QueryContext(
|
|
`SELECT group_key FROM audio_files WHERE id = ?`, fileID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("lookup post-update: %v", err)
|
|
}
|
|
|
|
var afterGroup string
|
|
if rows2.Next() {
|
|
if scanErr := rows2.Scan(&afterGroup); scanErr != nil {
|
|
t.Fatalf("scan post-update: %v", scanErr)
|
|
}
|
|
}
|
|
|
|
_ = rows2.Close()
|
|
|
|
if afterGroup != originalGroup {
|
|
t.Errorf("group_key changed across album tag edit: %q → %q", originalGroup, afterGroup)
|
|
}
|
|
}
|
|
|
|
func TestCommitBatch_RescanPromotesTagStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Only the insert path stamps tag_status, so a file another
|
|
// tagger stamped with MBIDs after import used to keep 'untagged'
|
|
// for ever — and its folder kept asking to be tagged, since that
|
|
// column is what the autotag queue reads.
|
|
lib, db := setupTestLibrary(t)
|
|
cache := newEntityCache()
|
|
metrics := newScanMetrics()
|
|
|
|
var added, updated, skipped atomic.Int64
|
|
|
|
const path = "/music/Artist/Album Folder/01.mp3"
|
|
|
|
initial := []importResult{
|
|
{
|
|
absolutePath: path,
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 200000,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album",
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
}
|
|
|
|
if err := lib.commitBatch(
|
|
initial, cache, metrics, &added, &updated, &skipped, nil,
|
|
); err != nil {
|
|
t.Fatalf("initial commitBatch: %v", err)
|
|
}
|
|
|
|
fileID := queryInt(t, db,
|
|
`SELECT id FROM audio_files WHERE file_path = ?`, path,
|
|
)
|
|
|
|
if got := queryString(t, db,
|
|
`SELECT tag_status FROM audio_files WHERE id = ?`, fileID,
|
|
); got != "untagged" {
|
|
t.Fatalf("tag_status after import = %q, want %q", got, "untagged")
|
|
}
|
|
|
|
update := []importResult{
|
|
{
|
|
absolutePath: path,
|
|
fileType: metadata.MP3,
|
|
lengthMillis: 200000,
|
|
existingFileID: fileID,
|
|
needsUpdate: true,
|
|
tags: &metadata.TrackMetadata{
|
|
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
|
|
Album: "Album",
|
|
RecordingMBID: "11111111-2222-3333-4444-555555555555",
|
|
},
|
|
libraryID: 0,
|
|
},
|
|
}
|
|
|
|
if err := lib.commitBatch(
|
|
update, cache, metrics, &added, &updated, &skipped, nil,
|
|
); err != nil {
|
|
t.Fatalf("update commitBatch: %v", err)
|
|
}
|
|
|
|
if got := queryString(t, db,
|
|
`SELECT tag_status FROM audio_files WHERE id = ?`, fileID,
|
|
); got != "user_confirmed" {
|
|
t.Errorf("tag_status after rescan = %q, want %q", got, "user_confirmed")
|
|
}
|
|
|
|
// A deliberate "never ask me about this file again" outranks the
|
|
// promotion: the guard is on 'untagged', not on the MBID.
|
|
if _, err := db.ExecContext(
|
|
`UPDATE audio_files SET tag_status = 'user_skipped_permanent' WHERE id = ?`,
|
|
fileID,
|
|
); err != nil {
|
|
t.Fatalf("mark skipped: %v", err)
|
|
}
|
|
|
|
if err := lib.commitBatch(
|
|
update, cache, metrics, &added, &updated, &skipped, nil,
|
|
); err != nil {
|
|
t.Fatalf("second update commitBatch: %v", err)
|
|
}
|
|
|
|
if got := queryString(t, db,
|
|
`SELECT tag_status FROM audio_files WHERE id = ?`, fileID,
|
|
); got != "user_skipped_permanent" {
|
|
t.Errorf("tag_status = %q, want the skip to survive a rescan", got)
|
|
}
|
|
}
|
|
|
|
// queryString is queryInt's text counterpart.
|
|
func queryString(t *testing.T, db *database.DB, query string, args ...any) string {
|
|
t.Helper()
|
|
|
|
rows, err := db.QueryContext(query, args...)
|
|
if err != nil {
|
|
t.Fatalf("query %q: %v", query, err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var out string
|
|
|
|
if rows.Next() {
|
|
if scanErr := rows.Scan(&out); scanErr != nil {
|
|
t.Fatalf("scan: %v", scanErr)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// queryInt runs a single-column scalar query and returns the first
|
|
// int64 result; fails the test on any error.
|
|
func queryInt(t *testing.T, db *database.DB, query string, args ...any) int64 {
|
|
t.Helper()
|
|
|
|
rows, err := db.QueryContext(query, args...)
|
|
if err != nil {
|
|
t.Fatalf("query %q: %v", query, err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var got int64
|
|
if rows.Next() {
|
|
if err := rows.Scan(&got); err != nil {
|
|
t.Fatalf("scan %q: %v", query, err)
|
|
}
|
|
}
|
|
|
|
return got
|
|
}
|