release / web (push) Successful in 2m21s
release / go (push) Successful in 2m34s
release / govulncheck (push) Successful in 40s
release / integration (push) Successful in 6m17s
release / android (push) Successful in 6m36s
release / Build signed APK (releases and dev) (push) Successful in 6m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m19s
release / Verify release artifacts (tag releases only) (push) Skipped
A track marked missing whose replacement is already on disk under another row — on the operator's library 355 of 451 missing tracks, nearly all Lidarr mp3 -> flac upgrades — is folded into the replacement: likes, plays, playlist entries and tags move across and the missing row goes. Move adoption could not catch these: the replacements were re-encodes (no shared audio hash) with no recording MBID at import. Pairs (ListMissingTrackPairs): the same recording MBID within the album group, or the same album row and title ignoring case. Each side must have exactly one candidate; conflicting MBIDs refuse a pair. No duration or track position gate: on the 142 pairs known to be one recording, 18% differed by over 2s and the poorly tagged set is where numbering is broken (spike #5274). Each pair folds in its own transaction after locking both rows and checking the pair still holds. Runs automatically (operator, 2026-10-07) after a full scan, after a watcher batch that added or updated tracks, and after an AcoustID pass that matched any track. The first scan after deploy repairs the existing rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
12 KiB
Go
315 lines
12 KiB
Go
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)
|
|
}
|
|
}
|