refactor(library): move detection matches on the audio hash, not size and duration (M400 #3914)
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
This commit is contained in:
2026-09-11 17:31:42 -04:00
co-authored by Claude Opus 5
parent 11ef044ef6
commit c8bf9dc929
6 changed files with 143 additions and 136 deletions
+13 -15
View File
@@ -41,7 +41,7 @@ import (
// match/ambiguity logic can be tested against a fake.
type trackAdopter interface {
FindMissingTrackByMbid(ctx context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error)
FindMissingTrackByFingerprint(ctx context.Context, arg dbq.FindMissingTrackByFingerprintParams) ([]dbq.FindMissingTrackByFingerprintRow, error)
FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]dbq.FindMissingTrackByAudioHashRow, error)
AdoptTrackPath(ctx context.Context, arg dbq.AdoptTrackPathParams) (int64, error)
}
@@ -53,10 +53,10 @@ type trackAdopter interface {
// pre-#2528 behaviour.
func (s *Scanner) adoptMovedTrack(
ctx context.Context, q trackAdopter, newPath string,
fileSize int64, durationMs int32, recordingMBID string,
audioHash []byte, recordingMBID string,
) bool {
// MBID first. It identifies the recording rather than the bytes, so it
// survives a re-encode that the fingerprint cannot.
// survives a re-encode that the audio hash cannot.
if recordingMBID != "" {
rows, err := q.FindMissingTrackByMbid(ctx, recordingMBID)
if err != nil {
@@ -67,19 +67,17 @@ func (s *Scanner) adoptMovedTrack(
}
}
// Fingerprint fallback for untagged files. Both components must be real:
// duration_ms is 0 when ffprobe failed, and matching 0 against 0 would pair
// up unrelated broken files.
if fileSize > 0 && durationMs > 0 {
rows, err := q.FindMissingTrackByFingerprint(ctx, dbq.FindMissingTrackByFingerprintParams{
FileSize: fileSize,
DurationMs: durationMs,
})
// Audio-hash fallback for untagged files (#3914): the encoded audio itself,
// which a rename, a move or a retag leaves unchanged. Absent when the hash
// could not be taken, and then nothing is matched — an empty hash must never
// be looked up, or every unhashable file would pair with every other.
if len(audioHash) > 0 {
rows, err := q.FindMissingTrackByAudioHash(ctx, audioHash)
if err != nil {
s.logger.Warn("library scan: move lookup by fingerprint failed",
s.logger.Warn("library scan: move lookup by audio hash failed",
"path", newPath, "err", err)
} else if c, ok := s.uniqueMatch(rowsFromFingerprint(rows), newPath, "fingerprint"); ok {
return s.adopt(ctx, q, c, newPath, "fingerprint")
} else if c, ok := s.uniqueMatch(rowsFromAudioHash(rows), newPath, "audio_hash"); ok {
return s.adopt(ctx, q, c, newPath, "audio_hash")
}
}
@@ -100,7 +98,7 @@ func rowsFromMbid(rows []dbq.FindMissingTrackByMbidRow) []candidate {
return out
}
func rowsFromFingerprint(rows []dbq.FindMissingTrackByFingerprintRow) []candidate {
func rowsFromAudioHash(rows []dbq.FindMissingTrackByAudioHashRow) []candidate {
out := make([]candidate, 0, len(rows))
for _, r := range rows {
out = append(out, candidate{id: r.ID, filePath: r.FilePath})