package library import ( "context" "crypto/sha256" "io" "log/slog" "os" "path/filepath" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) func TestPairStillHolds(t *testing.T) { missing := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} present := pgtype.UUID{Bytes: [16]byte{2}, Valid: true} marked := pgtype.Timestamptz{Time: time.Now(), Valid: true} row := func(id pgtype.UUID, missingSince pgtype.Timestamptz) dbq.LockTracksForPairingRow { return dbq.LockTracksForPairingRow{ID: id, MissingSince: missingSince} } cases := []struct { name string rows []dbq.LockTracksForPairingRow want bool }{ {"still a pair", []dbq.LockTracksForPairingRow{row(missing, marked), row(present, pgtype.Timestamptz{})}, true}, {"missing row restored", []dbq.LockTracksForPairingRow{row(missing, pgtype.Timestamptz{}), row(present, pgtype.Timestamptz{})}, false}, {"present row marked", []dbq.LockTracksForPairingRow{row(missing, marked), row(present, marked)}, false}, {"missing row gone", []dbq.LockTracksForPairingRow{row(present, pgtype.Timestamptz{})}, false}, {"present row gone", []dbq.LockTracksForPairingRow{row(missing, marked)}, false}, } for _, c := range cases { if got := pairStillHolds(c.rows, missing, present); got != c.want { t.Errorf("%s: pairStillHolds = %v, want %v", c.name, got, c.want) } } } // pairFixture seeds tracks straight into the tables: the pass reads rows, not // files, so no file needs to exist. type pairFixture struct { t *testing.T pool *pgxpool.Pool q *dbq.Queries artist dbq.Artist } func (f pairFixture) exec(sql string, args ...any) { f.t.Helper() if _, err := f.pool.Exec(context.Background(), sql, args...); err != nil { f.t.Fatalf("exec %q: %v", sql, err) } } func (f pairFixture) album(title, releaseGroup string) dbq.Album { f.t.Helper() a, err := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ Title: title, SortTitle: title, ArtistID: f.artist.ID, }) if err != nil { f.t.Fatalf("album %s: %v", title, err) } if releaseGroup != "" { f.exec(`UPDATE albums SET release_group_mbid = $2 WHERE id = $1`, a.ID, releaseGroup) } return a } // track adds a row; mbid "" leaves it without one, and missing marks it missing. func (f pairFixture) track(album dbq.Album, title, path, mbid string, missing bool) dbq.Track { f.t.Helper() tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ Title: title, AlbumID: album.ID, ArtistID: f.artist.ID, DurationMs: 200000, FilePath: path, FileSize: 100, FileFormat: filepath.Ext(path)[1:], }) if err != nil { f.t.Fatalf("track %s: %v", path, err) } if mbid != "" { f.exec(`UPDATE tracks SET mbid = $2, mbid_source = 'tag' WHERE id = $1`, tr.ID, mbid) } if missing { f.exec(`UPDATE tracks SET missing_since = now() - interval '1 day' WHERE id = $1`, tr.ID) } return tr } func (f pairFixture) exists(tr dbq.Track) bool { f.t.Helper() var n int if err := f.pool.QueryRow(context.Background(), `SELECT count(*) FROM tracks WHERE id = $1`, tr.ID).Scan(&n); err != nil { f.t.Fatalf("count: %v", err) } return n == 1 } func (f pairFixture) count(sql string, args ...any) int { f.t.Helper() var n int if err := f.pool.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { f.t.Fatalf("count %q: %v", sql, err) } return n } // The M485 proof on rows: each rule folds the pair it should, history moves onto // the replacement, and every shape the pass must leave alone is left alone. func TestPairMissingTracks_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Pair Artist", SortName: "Pair Artist"}) if err != nil { t.Fatalf("artist: %v", err) } f := pairFixture{t: t, pool: pool, q: q, artist: artist} albumA := f.album("Pair Album", "rg-pair") edition := f.album("Pair Album (Deluxe)", "rg-pair") // same release group as albumA other := f.album("Other Album", "rg-other") // Rule title: the mp3 went, the flac came, no MBID on either, and the title's // case differs as it does between old and new tags. oldMp3 := f.track(albumA, "Song One", "/m/a/01 - Song One.mp3", "", true) newFlac := f.track(albumA, "song one", "/m/a/01 - song one.flac", "", false) // Rule mbid: the same recording, retitled and filed under another edition of // the same release group. oldByMbid := f.track(albumA, "Old Name", "/m/a/02 - Old Name.mp3", "rec-2", true) newByMbid := f.track(edition, "New Name", "/m/d/02 - New Name.flac", "rec-2", false) // Ambiguous, missing side: two present copies could be its replacement. twice := f.track(albumA, "Twice", "/m/a/03 - Twice.mp3", "", true) f.track(albumA, "Twice", "/m/a/03 - Twice.flac", "", false) f.track(albumA, "Twice", "/m/a/03 - Twice (live).flac", "", false) // Ambiguous, present side: one present copy, two missing rows claiming it. echo1 := f.track(albumA, "Echo", "/m/a/04 - Echo.mp3", "", true) echo2 := f.track(albumA, "Echo", "/m/a/04 - Echo (1).mp3", "", true) f.track(albumA, "Echo", "/m/a/04 - Echo.flac", "", false) // Conflicting MBIDs: the tags say these are different recordings. clash := f.track(albumA, "Clash", "/m/a/05 - Clash.mp3", "rec-x", true) f.track(albumA, "Clash", "/m/a/05 - Clash.flac", "rec-y", false) // Negative controls across albums: a title match must stay inside one album // row, and an MBID match inside one release group. lonely := f.track(other, "Lonely", "/m/o/01 - Lonely.mp3", "", true) f.track(albumA, "Lonely", "/m/a/06 - Lonely.flac", "", false) cross := f.track(other, "Cross", "/m/o/02 - Cross.mp3", "rec-3", true) f.track(albumA, "Cross Over", "/m/a/07 - Cross Over.flac", "rec-3", false) // History on the row being folded. user, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "pair-alice", PasswordHash: "x", ApiTokenHash: "pair-alice-token", }) if err != nil { t.Fatalf("user: %v", err) } f.exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, oldMp3.ID) now := pgtype.Timestamptz{Time: time.Now(), Valid: true} session, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{UserID: user.ID, StartedAt: now}) if err != nil { t.Fatalf("session: %v", err) } for i := 0; i < 2; i++ { if _, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{ UserID: user.ID, TrackID: oldMp3.ID, SessionID: session.ID, StartedAt: now, }); err != nil { t.Fatalf("play event: %v", err) } } pl, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{UserID: user.ID, Name: "pair-mix"}) if err != nil { t.Fatalf("playlist: %v", err) } if _, err := q.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{PlaylistID: pl.ID, TrackID: oldMp3.ID}); err != nil { t.Fatalf("playlist entry: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) res, err := PairMissingTracks(ctx, pool, logger) if err != nil { t.Fatalf("PairMissingTracks: %v", err) } want := PairResult{ByMbid: 1, ByTitle: 1, Ambiguous: 3} if res != want { t.Errorf("result = %+v, want %+v", res, want) } if f.exists(oldMp3) || f.exists(oldByMbid) { t.Error("a folded missing row is still in the table") } if !f.exists(newFlac) || !f.exists(newByMbid) { t.Fatal("a replacement row was deleted") } for _, tr := range []dbq.Track{twice, echo1, echo2, clash, lonely, cross} { if !f.exists(tr) { t.Errorf("%s was folded; it should have been left alone", tr.FilePath) } } if n := f.count(`SELECT count(*) FROM general_likes WHERE track_id = $1`, newFlac.ID); n != 1 { t.Errorf("likes on the replacement = %d, want 1", n) } if n := f.count(`SELECT count(*) FROM play_events WHERE track_id = $1`, newFlac.ID); n != 2 { t.Errorf("plays on the replacement = %d, want 2", n) } if n := f.count(`SELECT count(*) FROM playlist_tracks WHERE playlist_id = $1 AND track_id = $2`, pl.ID, newFlac.ID); n != 1 { t.Errorf("playlist entries on the replacement = %d, want 1", n) } if n := f.count(`SELECT count(*) FROM library_changes WHERE entity_type = 'track' AND entity_id = $1 AND op = 'delete'`, syncpkg.FormatUUID(oldMp3.ID)); n != 1 { t.Errorf("track delete sync changes for the folded row = %d, want 1", n) } // Idempotent: a second pass has nothing left to fold. again, err := PairMissingTracks(ctx, pool, logger) if err != nil { t.Fatalf("second pass: %v", err) } if again.Folded() != 0 || again.Failed != 0 { t.Errorf("second pass = %+v, want nothing folded", again) } } // The case that made M485: a Lidarr quality upgrade. The mp3 is removed and a // re-encode of it arrives at a new path with no recording MBID, so move adoption // has nothing to match. The scan must still end with one row, carrying the old // row's history. func TestScanner_FoldsUpgradedFile_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) root := t.TempDir() oldPath := filepath.Join(root, "artistU/albumU/01 - Upgraded.mp3") tags := map[string]string{"TIT2": "Upgraded", "TPE1": "Artist U", "TALB": "Album U", "TRCK": "1"} writeTestMP3(t, oldPath, tags) // Filler so one removal stays under the mark cap. for i := 1; i <= 7; i++ { writeTestMP3(t, filepath.Join(root, "artistU/albumU/filler", string(rune('a'+i))+".mp3"), map[string]string{"TIT2": "Filler " + string(rune('0'+i)), "TPE1": "Artist U", "TALB": "Album U"}) } scanner := New(pool, logger, []string{root}, nil) // A re-encode never shares the audio hash, so each file gets its own: the // synthetic files would otherwise hash alike and adopt by hash instead. scanner.fingerprint = func(_ context.Context, path string, _ fingerprintOptions) fingerprintResult { sum := sha256.Sum256([]byte(path)) return fingerprintResult{streamSHA256: sum[:]} } if _, err := scanner.Scan(ctx, nil); err != nil { t.Fatalf("first scan: %v", err) } q := dbq.New(pool) before, err := q.GetTrackByPath(ctx, oldPath) if err != nil { t.Fatalf("track not indexed on first scan: %v", err) } user, err := q.CreateUser(ctx, dbq.CreateUserParams{ Username: dbtest.TestUserPrefix + "upgrade-bob", PasswordHash: "x", ApiTokenHash: "upgrade-bob-token", }) if err != nil { t.Fatalf("user: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, before.ID); err != nil { t.Fatalf("like: %v", err) } // The upgrade: the old file goes, the new one lands beside it. if err := os.Remove(oldPath); err != nil { t.Fatalf("remove: %v", err) } newPath := filepath.Join(root, "artistU/albumU/01 - Upgraded (lossless).mp3") writeTestMP3(t, newPath, tags) if _, err := scanner.Scan(ctx, nil); err != nil { t.Fatalf("second scan: %v", err) } after, err := q.GetTrackByPath(ctx, newPath) if err != nil { t.Fatalf("replacement not indexed: %v", err) } if after.ID == before.ID { t.Fatal("the replacement adopted the old row; this test needs the fold path, so adoption must not match") } if _, err := q.GetTrackByPath(ctx, oldPath); err == nil { t.Error("the old row is still there after the scan; it should have been folded") } var likes int if err := pool.QueryRow(ctx, `SELECT count(*) FROM general_likes WHERE track_id = $1`, after.ID).Scan(&likes); err != nil { t.Fatalf("likes: %v", err) } if likes != 1 { t.Errorf("likes on the replacement = %d, want 1", likes) } var total int if err := pool.QueryRow(ctx, `SELECT count(*) FROM tracks`).Scan(&total); err != nil { t.Fatalf("count: %v", err) } if total != 8 { t.Errorf("tracks = %d, want 8 — an upgrade must not leave a second row", total) } }