Files
minstrel/internal/library/moved_test.go
T
bvandeusenandClaude Opus 5 c8bf9dc929
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
refactor(library): move detection matches on the audio hash, not size and duration (M400 #3914)
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
2026-09-11 17:31:42 -04:00

306 lines
10 KiB
Go

package library
import (
"bytes"
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// fakeAdopter answers the audio-hash lookup only for the hash it holds, the way
// the real query does. A fake that returned its rows for any hash could not tell
// adoption by identity apart from adoption by coincidence.
type fakeAdopter struct {
byMbid []dbq.FindMissingTrackByMbidRow
hash []byte
byHash []dbq.FindMissingTrackByAudioHashRow
mbidErr error
hashErr error
adoptErr error
adoptRows int64
mbidQueried []string
hashQueried [][]byte
adopted []dbq.AdoptTrackPathParams
}
func (f *fakeAdopter) FindMissingTrackByMbid(_ context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error) {
f.mbidQueried = append(f.mbidQueried, mbid)
return f.byMbid, f.mbidErr
}
func (f *fakeAdopter) FindMissingTrackByAudioHash(
_ context.Context, audioStreamSha256 []byte,
) ([]dbq.FindMissingTrackByAudioHashRow, error) {
f.hashQueried = append(f.hashQueried, audioStreamSha256)
if f.hashErr != nil {
return nil, f.hashErr
}
if !bytes.Equal(audioStreamSha256, f.hash) {
return nil, nil
}
return f.byHash, nil
}
func (f *fakeAdopter) AdoptTrackPath(_ context.Context, arg dbq.AdoptTrackPathParams) (int64, error) {
f.adopted = append(f.adopted, arg)
if f.adoptErr != nil {
return 0, f.adoptErr
}
return f.adoptRows, nil
}
// The narrowed interface must not drift from the real queries.
var _ trackAdopter = (*dbq.Queries)(nil)
func mbidRow(n byte, path string) dbq.FindMissingTrackByMbidRow {
return dbq.FindMissingTrackByMbidRow{ID: testUUID(n), FilePath: path}
}
func hashRow(n byte, path string) dbq.FindMissingTrackByAudioHashRow {
return dbq.FindMissingTrackByAudioHashRow{ID: testUUID(n), FilePath: path}
}
func audioHash(b byte) []byte { return bytes.Repeat([]byte{b}, 32) }
const (
oldPath = "/music/Linkin Park/Minutes to Midnight/02 - Bleed It Out.mp3"
newPath = "/music/Linkin Park/Minutes to Midnight/04 - Bleed It Out.mp3"
)
func TestAdoptMovedTrack_MatchesByMbid(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(7, oldPath)}, adoptRows: 1}
if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(1), "rec-mbid") {
t.Fatal("expected the moved track to be adopted")
}
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(7) || q.adopted[0].FilePath != newPath {
t.Fatalf("adopted = %+v, want row 7 at %q", q.adopted, newPath)
}
// MBID matched, so the weaker signal should not have been consulted.
if len(q.hashQueried) != 0 {
t.Errorf("queried the audio hash despite an MBID match")
}
}
func TestAdoptMovedTrack_FallsBackToAudioHash(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{hash: audioHash(3), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(3, oldPath)}, adoptRows: 1}
// No MBID: an untagged file, which is exactly what the fallback is for.
if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(3), "") {
t.Fatal("expected adoption via the audio hash")
}
if len(q.mbidQueried) != 0 {
t.Errorf("queried by MBID with no MBID available")
}
if len(q.hashQueried) != 1 || !bytes.Equal(q.hashQueried[0], audioHash(3)) {
t.Fatalf("hash queried = %x, want exactly the file's hash", q.hashQueried)
}
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(3) {
t.Errorf("adopted = %+v, want row 3", q.adopted)
}
}
// The case the old (file_size, duration_ms) pair got wrong: an unrelated file
// that happens to share a size and a duration with a missing track. Size and
// duration are no longer inputs at all; only the audio itself can match, and a
// different recording has a different hash.
func TestAdoptMovedTrack_DifferentAudioIsNotAdopted(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{hash: audioHash(0xAA), byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(1, oldPath)}, adoptRows: 1}
if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(0xBB), "") {
t.Fatal("adopted a missing track whose audio differs")
}
if len(q.adopted) != 0 {
t.Errorf("adopted = %+v, want none", q.adopted)
}
}
// Two missing rows carrying the same recording MBID means real duplicates.
// Adopting one arbitrarily would attach this file's future history to a coin
// flip, so it must insert fresh instead.
func TestAdoptMovedTrack_RefusesAmbiguousMbidMatch(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{
mbidRow(1, "/music/a.mp3"),
mbidRow(2, "/music/b.mp3"),
}, adoptRows: 1}
if s.adoptMovedTrack(context.Background(), q, newPath, nil, "rec-mbid") {
t.Fatal("expected refusal on an ambiguous MBID match")
}
if len(q.adopted) != 0 {
t.Errorf("adopted despite ambiguity: %+v", q.adopted)
}
}
// An ambiguous MBID may still be resolvable by the audio hash, which is a
// narrower signal — so falling through is allowed to succeed.
func TestAdoptMovedTrack_AmbiguousMbidFallsThroughToAudioHash(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{
byMbid: []dbq.FindMissingTrackByMbidRow{
mbidRow(1, "/music/a.mp3"),
mbidRow(2, "/music/b.mp3"),
},
hash: audioHash(2),
byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(2, "/music/b.mp3")},
adoptRows: 1,
}
if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(2), "rec-mbid") {
t.Fatal("expected the audio hash to disambiguate")
}
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(2) {
t.Errorf("adopted = %+v, want row 2", q.adopted)
}
}
// Two missing tracks with identical audio are duplicates of each other; a new
// file matching both cannot be assigned to either.
func TestAdoptMovedTrack_RefusesAmbiguousAudioHashMatch(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{hash: audioHash(5), byHash: []dbq.FindMissingTrackByAudioHashRow{
hashRow(1, "/music/a.mp3"),
hashRow(2, "/music/b.mp3"),
}, adoptRows: 1}
if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(5), "") {
t.Fatal("expected refusal on an ambiguous audio-hash match")
}
if len(q.adopted) != 0 {
t.Errorf("adopted despite ambiguity: %+v", q.adopted)
}
}
// No hash means it could not be taken. An empty hash must never be looked up:
// every unhashable file would pair with every other.
func TestAdoptMovedTrack_SkipsAudioHashWhenAbsent(t *testing.T) {
for name, hash := range map[string][]byte{"nil": nil, "empty": {}} {
t.Run(name, func(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{hash: hash, byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(1, oldPath)}, adoptRows: 1}
if s.adoptMovedTrack(context.Background(), q, newPath, hash, "") {
t.Error("adopted without an audio hash")
}
if len(q.hashQueried) != 0 {
t.Error("looked up an absent audio hash")
}
})
}
}
func TestAdoptMovedTrack_NoCandidates(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{adoptRows: 1}
if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(9), "rec-mbid") {
t.Fatal("expected no adoption when nothing matches")
}
if len(q.adopted) != 0 {
t.Errorf("adopted with no candidates: %+v", q.adopted)
}
}
// The row's mark was cleared between lookup and update — another file adopted it
// first. AdoptTrackPath's `missing_since IS NOT NULL` predicate reports 0 rows.
func TestAdoptMovedTrack_LostRaceReportsNotAdopted(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{
byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(5, oldPath)},
adoptRows: 0,
}
if s.adoptMovedTrack(context.Background(), q, newPath, audioHash(5), "rec-mbid") {
t.Fatal("expected not-adopted when the update matched no rows")
}
}
// Failing to detect a move must never fail the file: the caller falls back to
// inserting a fresh row, which is the pre-#2528 behaviour.
func TestAdoptMovedTrack_ToleratesQueryErrors(t *testing.T) {
sentinel := errors.New("db down")
tests := []struct {
name string
q *fakeAdopter
}{
{"mbid lookup fails", &fakeAdopter{mbidErr: sentinel}},
{"audio hash lookup fails", &fakeAdopter{hashErr: sentinel}},
{"adopt fails", &fakeAdopter{
byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(1, oldPath)},
adoptErr: sentinel,
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := testScanner(t)
if s.adoptMovedTrack(context.Background(), tc.q, newPath, audioHash(1), "rec-mbid") {
t.Error("reported adoption despite a query error")
}
})
}
}
// A failed MBID lookup must not stop the audio hash from being tried.
func TestAdoptMovedTrack_MbidErrorStillTriesAudioHash(t *testing.T) {
s := testScanner(t)
q := &fakeAdopter{
mbidErr: errors.New("db hiccup"),
hash: audioHash(9),
byHash: []dbq.FindMissingTrackByAudioHashRow{hashRow(9, oldPath)},
adoptRows: 1,
}
if !s.adoptMovedTrack(context.Background(), q, newPath, audioHash(9), "rec-mbid") {
t.Fatal("expected the audio hash to be tried after an MBID lookup error")
}
if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(9) {
t.Errorf("adopted = %+v, want row 9", q.adopted)
}
}
func TestUniqueMatch(t *testing.T) {
s := testScanner(t)
if _, ok := s.uniqueMatch(nil, newPath, "mbid"); ok {
t.Error("empty candidate set matched")
}
c, ok := s.uniqueMatch([]candidate{{id: testUUID(4), filePath: oldPath}}, newPath, "mbid")
if !ok {
t.Fatal("single candidate did not match")
}
if c.id != testUUID(4) || c.filePath != oldPath {
t.Errorf("candidate = %+v, want id 4 at %q", c, oldPath)
}
if _, ok := s.uniqueMatch([]candidate{
{id: testUUID(1)}, {id: testUUID(2)},
}, newPath, "mbid"); ok {
t.Error("multiple candidates matched")
}
}
func TestRowConverters(t *testing.T) {
got := rowsFromMbid([]dbq.FindMissingTrackByMbidRow{mbidRow(1, "/a"), mbidRow(2, "/b")})
if len(got) != 2 || got[0].id != testUUID(1) || got[1].filePath != "/b" {
t.Errorf("rowsFromMbid = %+v", got)
}
got = rowsFromAudioHash([]dbq.FindMissingTrackByAudioHashRow{hashRow(3, "/c")})
if len(got) != 1 || got[0].id != testUUID(3) || got[0].filePath != "/c" {
t.Errorf("rowsFromAudioHash = %+v", got)
}
}
// pgtype.UUID zero value must not be mistaken for a real id.
func TestUniqueMatch_ZeroUUIDNotValid(t *testing.T) {
var zero pgtype.UUID
if zero.Valid {
t.Fatal("zero pgtype.UUID should not be Valid")
}
}