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
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:
@@ -148,39 +148,36 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
||||
return i, err
|
||||
}
|
||||
|
||||
const findMissingTrackByFingerprint = `-- name: FindMissingTrackByFingerprint :many
|
||||
SELECT id, file_path FROM tracks
|
||||
WHERE missing_since IS NOT NULL
|
||||
AND file_size = $1
|
||||
AND duration_ms = $2
|
||||
const findMissingTrackByAudioHash = `-- name: FindMissingTrackByAudioHash :many
|
||||
SELECT t.id, t.file_path
|
||||
FROM tracks t
|
||||
JOIN track_fingerprints f ON f.track_id = t.id
|
||||
WHERE t.missing_since IS NOT NULL
|
||||
AND f.audio_stream_sha256 = $1
|
||||
LIMIT 2
|
||||
`
|
||||
|
||||
type FindMissingTrackByFingerprintParams struct {
|
||||
FileSize int64
|
||||
DurationMs int32
|
||||
}
|
||||
|
||||
type FindMissingTrackByFingerprintRow struct {
|
||||
type FindMissingTrackByAudioHashRow struct {
|
||||
ID pgtype.UUID
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// Move detection fallback for files with no MBID (#2528). Exact byte size AND
|
||||
// exact decoded duration is a strong pair: a plain move or rename preserves
|
||||
// both, while a re-encode changes at least one — and a re-encode genuinely is a
|
||||
// different file, so failing to match there is correct rather than a gap.
|
||||
// Move detection fallback for files with no MBID (#2528, #3914). The audio stream
|
||||
// hash identifies the encoded audio itself, so it survives a rename, a move and a
|
||||
// retag — anything short of a re-encode. It replaced (file_size, duration_ms),
|
||||
// which could pair two unrelated files that happened to share a byte count and a
|
||||
// duration, and missed a file retagged in place, whose size changes.
|
||||
//
|
||||
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
||||
func (q *Queries) FindMissingTrackByFingerprint(ctx context.Context, arg FindMissingTrackByFingerprintParams) ([]FindMissingTrackByFingerprintRow, error) {
|
||||
rows, err := q.db.Query(ctx, findMissingTrackByFingerprint, arg.FileSize, arg.DurationMs)
|
||||
func (q *Queries) FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]FindMissingTrackByAudioHashRow, error) {
|
||||
rows, err := q.db.Query(ctx, findMissingTrackByAudioHash, audioStreamSha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []FindMissingTrackByFingerprintRow
|
||||
var items []FindMissingTrackByAudioHashRow
|
||||
for rows.Next() {
|
||||
var i FindMissingTrackByFingerprintRow
|
||||
var i FindMissingTrackByAudioHashRow
|
||||
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user