test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m45s
release / Build signed APK (releases and dev) (push) Successful in 4m52s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
A file that comes back renamed or moved keeps its track row, and with it its likes and play history, by being matched to the missing row it replaces (#2528). Untagged files were matched on (file_size, duration_ms), which was never a fingerprint. It could pair two unrelated files that happened to share a byte count and a duration, and it missed a file retagged in place, whose size changes. The only defence was requiring a unique match and otherwise giving up. Now there is a real identity. FindMissingTrackByAudioHash matches a missing track by the SHA-256 of its encoded audio (track_fingerprints, #3906). That survives a rename, a move and a retag, and only an identical recording can match it. adoptMovedTrack takes the new file's hash, which the scan already computes before adoption. The size and duration query and fallback are removed outright, with no second path (rule 22). Unchanged: - MBID first: it identifies the recording and survives a re-encode that even the hash does not - a unique match is still required - an absent hash is never looked up, so unhashable files cannot pair with each other The test fake answers the hash lookup only for the hash it holds, so the tests can tell adoption by identity apart from adoption by coincidence. That includes the case the old pair got wrong: different audio of equal size and duration is not adopted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func TestReleaseDateFromYear(t *testing.T) {
|
|
cases := []struct {
|
|
in int
|
|
ok bool
|
|
label string
|
|
}{
|
|
{0, false, "missing tag"},
|
|
{-1, false, "negative"},
|
|
{1, true, "lower bound"},
|
|
{1999, true, "ordinary"},
|
|
{9999, true, "upper bound"},
|
|
{10000, false, "five digits"},
|
|
{99999999, false, "tag corruption"},
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := releaseDateFromYear(c.in)
|
|
if ok != c.ok {
|
|
t.Errorf("%s: ok = %v, want %v", c.label, ok, c.ok)
|
|
}
|
|
if ok && (!got.Valid || got.Time.Year() != c.in) {
|
|
t.Errorf("%s: date = %+v, want year=%d", c.label, got, c.in)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScanner_Integration exercises walk → tag-parse → upsert → incremental
|
|
// skip against a real Postgres. Gated on MINSTREL_TEST_DATABASE_URL.
|
|
func TestScanner_Integration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping scanner integration in -short mode")
|
|
}
|
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
ctx := context.Background()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
if err := db.Migrate(dsn, logger); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
writeTestMP3(t, filepath.Join(root, "artistX/albumA/01.mp3"), map[string]string{
|
|
"TIT2": "Track One", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "1", "TYER": "2020",
|
|
})
|
|
writeTestMP3(t, filepath.Join(root, "artistX/albumA/02.mp3"), map[string]string{
|
|
"TIT2": "Track Two", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "2", "TYER": "2020",
|
|
})
|
|
writeTestMP3(t, filepath.Join(root, "artistX/albumB/01.mp3"), map[string]string{
|
|
"TIT2": "B Song", "TPE1": "Artist X", "TALB": "Album B", "TRCK": "1",
|
|
})
|
|
writeTestMP3(t, filepath.Join(root, "artistY/01.mp3"), map[string]string{
|
|
"TIT2": "Solo", "TPE1": "The Artist Y", "TALB": "Y Album", "TRCK": "1",
|
|
})
|
|
|
|
scanner := New(pool, logger, []string{root})
|
|
stats, err := scanner.Scan(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("first scan: %v", err)
|
|
}
|
|
if stats.Scanned != 4 || stats.Added != 4 {
|
|
t.Errorf("first scan stats = %+v, want Scanned=4 Added=4", stats)
|
|
}
|
|
|
|
q := dbq.New(pool)
|
|
artists, err := q.ListArtists(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListArtists: %v", err)
|
|
}
|
|
if len(artists) != 2 {
|
|
t.Fatalf("artist rows = %d (%+v), want 2", len(artists), artists)
|
|
}
|
|
// "The Artist Y" should sort under "Artist Y" (article stripped).
|
|
if artists[0].SortName != "Artist X" || artists[1].SortName != "Artist Y" {
|
|
t.Errorf("artist sort = [%q, %q], want [Artist X, Artist Y]", artists[0].SortName, artists[1].SortName)
|
|
}
|
|
|
|
// The synthetic MP3s carry only an ID3 tag — no decodable audio —
|
|
// so ffprobe yields duration 0. The scanner deliberately refuses to
|
|
// skip zero-duration rows (it re-runs them so a later scan can
|
|
// backfill duration once probing works), which is orthogonal to the
|
|
// mtime-based incremental-skip this test covers. Simulate a
|
|
// normally-probed library so the skip path is actually exercised;
|
|
// updated_at is left untouched (still ≥ file mtime).
|
|
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
|
t.Fatalf("seed durations: %v", err)
|
|
}
|
|
|
|
stats2, err := scanner.Scan(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("second scan: %v", err)
|
|
}
|
|
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
|
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
|
|
}
|
|
|
|
// ScanFiles (watcher-driven targeted scan): a newly-added file is picked
|
|
// up by path without walking the whole tree, and its album ID is returned
|
|
// so the watcher can enrich just that album. A non-audio path in the batch
|
|
// is silently ignored.
|
|
newFile := filepath.Join(root, "artistX/albumA/03.mp3")
|
|
writeTestMP3(t, newFile, map[string]string{
|
|
"TIT2": "Track Three", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "3", "TYER": "2020",
|
|
})
|
|
notAudio := filepath.Join(root, "artistX/albumA/cover.txt")
|
|
if err := os.WriteFile(notAudio, []byte("nope"), 0o644); err != nil {
|
|
t.Fatalf("write non-audio: %v", err)
|
|
}
|
|
changed, err := scanner.ScanFiles(ctx, []string{newFile, notAudio})
|
|
if err != nil {
|
|
t.Fatalf("ScanFiles: %v", err)
|
|
}
|
|
if len(changed) != 1 || !changed[0].Valid {
|
|
t.Fatalf("ScanFiles changed albums = %+v, want exactly 1 valid", changed)
|
|
}
|
|
|
|
// The targeted scan upserts only the named file; the rest of the library
|
|
// is untouched (no full walk).
|
|
if _, err := q.GetTrackByPath(ctx, newFile); err != nil {
|
|
t.Fatalf("GetTrackByPath after ScanFiles: %v", err)
|
|
}
|
|
|
|
// Re-running ScanFiles on the now-unchanged file (duration probed) skips it,
|
|
// so no album is reported as changed.
|
|
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
|
t.Fatalf("seed durations (2): %v", err)
|
|
}
|
|
changed2, err := scanner.ScanFiles(ctx, []string{newFile})
|
|
if err != nil {
|
|
t.Fatalf("ScanFiles (rescan): %v", err)
|
|
}
|
|
if len(changed2) != 0 {
|
|
t.Errorf("ScanFiles on unchanged file changed = %+v, want none", changed2)
|
|
}
|
|
}
|
|
|
|
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
|
|
// bytes of MPEG-sync trailer. dhowden/tag reads tags without decoding audio,
|
|
// so this is enough for scanner round-trip tests without shipping binaries.
|
|
func writeTestMP3(t *testing.T, path string, frames map[string]string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var framesBuf bytes.Buffer
|
|
for id, value := range frames {
|
|
if len(id) != 4 {
|
|
t.Fatalf("frame id %q not 4 bytes", id)
|
|
}
|
|
payload := append([]byte{0x03}, []byte(value)...)
|
|
framesBuf.WriteString(id)
|
|
_ = binary.Write(&framesBuf, binary.BigEndian, uint32(len(payload)))
|
|
framesBuf.WriteByte(0x00)
|
|
framesBuf.WriteByte(0x00)
|
|
framesBuf.Write(payload)
|
|
}
|
|
total := framesBuf.Len()
|
|
header := []byte{
|
|
'I', 'D', '3', 0x03, 0x00, 0x00,
|
|
byte((total >> 21) & 0x7F),
|
|
byte((total >> 14) & 0x7F),
|
|
byte((total >> 7) & 0x7F),
|
|
byte(total & 0x7F),
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
if _, err := f.Write(header); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.Write(framesBuf.Bytes()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.Write([]byte{0xFF, 0xFB, 0x90, 0x00}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestScanner_AdoptsMovedFile_Integration is the #2528 proof: a renamed file
|
|
// must keep its existing tracks row — same id, so likes, play history and
|
|
// playlist memberships travel with it — rather than forking into a marked ghost
|
|
// plus a fresh zero-history row.
|
|
//
|
|
// Uses the MBID path. The synthetic MP3s here carry no real audio, so their audio
|
|
// stream hash cannot be relied on — which is why the recording MBID is the signal
|
|
// under test. The audio-hash path is covered by moved_test.go.
|
|
//
|
|
// Eight tracks with one rename keeps the marked fraction at 12.5%, under
|
|
// missingMarkMaxFraction. That is load-bearing: if the rename exceeded the cap,
|
|
// reconcile would refuse to mark, adoption could not fire, and the file would
|
|
// fork. See the "reconcile skipped" warning in Scan.
|
|
func TestScanner_AdoptsMovedFile_Integration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping scanner integration in -short mode")
|
|
}
|
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
ctx := context.Background()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
if err := db.Migrate(dsn, logger); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
const movedMBID = "11111111-2222-3333-4444-555555555555"
|
|
movedFrom := filepath.Join(root, "artistM/albumM/04 - Bleed It Out.mp3")
|
|
writeTestMP3(t, movedFrom, map[string]string{
|
|
"TIT2": "Bleed It Out", "TPE1": "Artist M", "TALB": "Album M", "TRCK": "4",
|
|
// dhowden surfaces TXXX as a Comm whose Description is the Picard tag
|
|
// name; "MusicBrainz Track Id" is mbz.Recording.
|
|
"TXXX": "MusicBrainz Track Id\x00" + movedMBID,
|
|
})
|
|
// Filler so one rename stays under the mark cap.
|
|
for i := 1; i <= 7; i++ {
|
|
writeTestMP3(t, filepath.Join(root, "artistM/albumM/filler", string(rune('a'+i))+".mp3"),
|
|
map[string]string{
|
|
"TIT2": "Filler " + string(rune('0'+i)), "TPE1": "Artist M", "TALB": "Album M",
|
|
})
|
|
}
|
|
|
|
scanner := New(pool, logger, []string{root})
|
|
if _, err := scanner.Scan(ctx, nil); err != nil {
|
|
t.Fatalf("first scan: %v", err)
|
|
}
|
|
|
|
q := dbq.New(pool)
|
|
before, err := q.GetTrackByPath(ctx, movedFrom)
|
|
if err != nil {
|
|
t.Fatalf("track not indexed on first scan: %v", err)
|
|
}
|
|
if before.Mbid == nil || *before.Mbid != movedMBID {
|
|
t.Fatalf("recording mbid not stored: %v", before.Mbid)
|
|
}
|
|
|
|
// Renumber the file, exactly as a tag editor would.
|
|
movedTo := filepath.Join(root, "artistM/albumM/02 - Bleed It Out.mp3")
|
|
if err := os.Rename(movedFrom, movedTo); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
|
|
if _, err := scanner.Scan(ctx, nil); err != nil {
|
|
t.Fatalf("second scan: %v", err)
|
|
}
|
|
|
|
after, err := q.GetTrackByPath(ctx, movedTo)
|
|
if err != nil {
|
|
t.Fatalf("track not found at its new path: %v", err)
|
|
}
|
|
if after.ID != before.ID {
|
|
t.Errorf("track id changed on rename: %v -> %v (history would be stranded)",
|
|
before.ID, after.ID)
|
|
}
|
|
if after.MissingSince.Valid {
|
|
t.Errorf("adopted row is still marked missing: %v", after.MissingSince)
|
|
}
|
|
|
|
// The old path must be gone entirely — not lingering as a marked ghost.
|
|
if _, err := q.GetTrackByPath(ctx, movedFrom); err == nil {
|
|
t.Error("old path still has a tracks row; the track forked instead of moving")
|
|
}
|
|
|
|
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 — a rename must not add a row", total)
|
|
}
|
|
}
|