downloads: stop AcoustID scan duplicating history rows / leaving verified tracks 'unverified' (#934)
the AcoustID scanner matched library_history rows by EXACT file_path, but that path is
frozen at import time while the file moves afterward (media-server import / reorganize) —
so tracks.file_path (what the scan reads) no longer equals it. two failures resulted, both
introduced in 37ea6604: verified status never reached the history row (verified tracks kept
showing 'unverified'), and a fresh acoustid_scan row was INSERTed every run (5551 rows for
3675 songs).
- new pure, tested matcher (core/downloads/history_match.py): exact path → filename guarded
by title; prefers a real download row over a synthetic scan row.
- _persist_status now HEALS the matched row's path + status (so future scans match cleanly),
DELETES synthetic acoustid_scan duplicates by exact path (collision-free, never a real row),
and inserts only when the file genuinely has no row.
- a full AcoustID job now self-cleans existing duplicates — no destructive bulk migration.
8 matcher + 4 real-DB heal/dedup/insert tests; existing scanner tests updated to the new
seam (heal vs insert). 1076 acoustid/verification/download tests green.
This commit is contained in:
parent
eddaea2f93
commit
3a92571c71
5 changed files with 282 additions and 23 deletions
62
core/downloads/history_match.py
Normal file
62
core/downloads/history_match.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Match a file back to its download-history row when its path has drifted (#934).
|
||||
|
||||
``library_history.file_path`` is frozen at import time, but the file moves afterward
|
||||
(media-server import, library reorganize) and ``tracks.file_path`` — what the AcoustID
|
||||
scanner reads — no longer equals it. Matching on the exact path alone then fails twice:
|
||||
the verification status never reaches the history row (verified tracks read "unverified"),
|
||||
and a fresh ``acoustid_scan`` row gets inserted every run (thousands of duplicates).
|
||||
|
||||
This module picks the canonical history row by exact path first, then by FILENAME guarded
|
||||
by a title check — so a shared filename ("01 - Intro.flac") can never heal the wrong song.
|
||||
Pure (no DB) so the matching rules are unit-testable; the caller does the SQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
def _norm_title(value) -> str:
|
||||
"""Alphanumeric-only lowercase form, so "Song (Remaster)" vs "song remaster"
|
||||
style drift between the download tag and the media-server tag still agrees."""
|
||||
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
|
||||
|
||||
|
||||
def like_filename_filter(basename: str) -> str:
|
||||
"""A ``LIKE ... ESCAPE '\\'`` pattern that coarsely matches rows whose path ends
|
||||
in ``basename``. Escapes the LIKE metacharacters (``%`` ``_`` ``\\``) — filenames
|
||||
routinely contain underscores. Callers MUST still confirm with an exact basename
|
||||
compare (``pick_history_row`` does), since ``'%name'`` also matches ``'xname'``."""
|
||||
esc = basename.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
return '%' + esc
|
||||
|
||||
|
||||
def pick_history_row(candidates: Sequence[Tuple], *, current_paths: Iterable[str],
|
||||
basename: str, title: str) -> Optional[int]:
|
||||
"""Return the id of the history row to update for this file, or None.
|
||||
|
||||
``candidates``: ``(id, file_path, title, download_source)`` rows the DB pre-filtered
|
||||
(exact path or filename LIKE). A row matches when its path equals the current path OR
|
||||
its filename matches AND its title agrees — the title guard prevents a shared filename
|
||||
("01 - Intro.flac") from healing a different song's row. Among matches a REAL download
|
||||
row is preferred over a synthetic ``acoustid_scan`` row, so the scanner heals the
|
||||
genuine record and the caller can delete the synthetic duplicate. None when nothing
|
||||
matches safely (caller then inserts a fresh row — the "file SoulSync never downloaded"
|
||||
intent)."""
|
||||
paths = {p for p in current_paths if p}
|
||||
want = _norm_title(title)
|
||||
matches: list = [] # (id, is_exact, is_real)
|
||||
for cid, cpath, ctitle, csource in candidates:
|
||||
is_real = csource != 'acoustid_scan'
|
||||
if cpath and cpath in paths:
|
||||
matches.append((cid, True, is_real))
|
||||
elif (basename and cpath and os.path.basename(cpath) == basename
|
||||
and (not want or not _norm_title(ctitle) or _norm_title(ctitle) == want)):
|
||||
matches.append((cid, False, is_real))
|
||||
if not matches:
|
||||
return None
|
||||
# Prefer a REAL download row over a synthetic acoustid_scan row; within that, prefer an
|
||||
# exact-path match over a filename match. Stable, so ties keep DB order (first/oldest id).
|
||||
matches.sort(key=lambda m: (m[2], m[1]), reverse=True)
|
||||
return matches[0][0]
|
||||
|
|
@ -389,14 +389,43 @@ class AcoustIDScannerJob(RepairJob):
|
|||
cur.execute(
|
||||
"UPDATE tracks SET verification_status = ? WHERE id = ?",
|
||||
(status, track_id))
|
||||
matched = 0
|
||||
exp = expected or {}
|
||||
# Find the canonical history row for this file. The stored path is frozen at
|
||||
# import time while the file has since moved (media-server import / reorganize),
|
||||
# so an exact-path match alone misses it — then the status never lands and a
|
||||
# duplicate row gets inserted every scan (#934). Match exact path first, then
|
||||
# filename guarded by title, and HEAL the row's path so future scans match cleanly.
|
||||
from core.downloads.history_match import pick_history_row, like_filename_filter
|
||||
current = fpath or db_path
|
||||
basename = os.path.basename(current) if current else ''
|
||||
clauses, params = [], []
|
||||
for p in {p for p in (fpath, db_path) if p}:
|
||||
clauses.append("file_path = ?")
|
||||
params.append(p)
|
||||
if basename:
|
||||
clauses.append("file_path LIKE ? ESCAPE '\\'")
|
||||
params.append(like_filename_filter(basename))
|
||||
row_id = None
|
||||
if clauses:
|
||||
cur.execute(
|
||||
"UPDATE library_history SET verification_status = ? WHERE file_path = ?",
|
||||
(status, p))
|
||||
matched += max(getattr(cur, 'rowcount', 0) or 0, 0)
|
||||
if status == 'unverified' and matched == 0:
|
||||
exp = expected or {}
|
||||
"SELECT id, file_path, title, download_source FROM library_history WHERE "
|
||||
+ " OR ".join(clauses),
|
||||
params)
|
||||
row_id = pick_history_row(
|
||||
cur.fetchall(),
|
||||
current_paths=(fpath, db_path),
|
||||
basename=basename, title=exp.get('title') or '')
|
||||
if row_id is not None:
|
||||
cur.execute(
|
||||
"UPDATE library_history SET verification_status = ?, file_path = ? WHERE id = ?",
|
||||
(status, current, row_id))
|
||||
# Drop synthetic scan-created duplicates for this exact file (the #934
|
||||
# leftovers). Exact path → collision-free; never touches a real download row.
|
||||
cur.execute(
|
||||
"DELETE FROM library_history WHERE id != ? AND download_source = 'acoustid_scan' "
|
||||
"AND file_path = ?",
|
||||
(row_id, current))
|
||||
elif status == 'unverified':
|
||||
cur.execute(
|
||||
"""INSERT INTO library_history
|
||||
(event_type, title, artist_name, album_name, file_path,
|
||||
|
|
|
|||
95
tests/test_acoustid_history_heal.py
Normal file
95
tests/test_acoustid_history_heal.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""#934: the AcoustID scanner heals the download-history row when the file moved,
|
||||
instead of leaving it stuck 'unverified' and inserting duplicate scan rows.
|
||||
|
||||
Seeds the exact bug shape (a real download row at the OLD import path + a synthetic
|
||||
'acoustid_scan' duplicate at the NEW library path) and drives the real _persist_status,
|
||||
asserting it collapses to one correct, verified row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from core.repair_jobs.acoustid_scanner import AcoustIDScannerJob
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return MusicDatabase(str(tmp_path / 'm.db'))
|
||||
|
||||
|
||||
def _scanner():
|
||||
# _persist_status uses only `context`, never instance state — bypass __init__.
|
||||
return AcoustIDScannerJob.__new__(AcoustIDScannerJob)
|
||||
|
||||
|
||||
def _rows(db):
|
||||
with db._get_connection() as conn:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT file_path, download_source, verification_status FROM library_history")]
|
||||
|
||||
|
||||
OLD = '/downloads/transfer/Artist/01 - Song.flac' # frozen import path in history
|
||||
NEW = '/music/Artist/Album/01 - Song.flac' # where the file lives now (tracks path)
|
||||
|
||||
|
||||
def test_verify_heals_drifted_row_and_drops_synthetic_dup(db):
|
||||
# the bug's leftover state: a stuck real row + a synthetic scan dup for the same song.
|
||||
db.add_library_history_entry('download', 'Song', file_path=OLD,
|
||||
download_source='soulseek', verification_status='unverified')
|
||||
db.add_library_history_entry('download', 'Song', file_path=NEW,
|
||||
download_source='acoustid_scan', verification_status='unverified')
|
||||
|
||||
_scanner()._persist_status(
|
||||
types.SimpleNamespace(db=db), track_id='t1', fpath=NEW, db_path=NEW,
|
||||
status='verified', write_tag=False, expected={'title': 'Song'})
|
||||
|
||||
rows = _rows(db)
|
||||
assert len(rows) == 1 # synthetic dup deleted, no new insert
|
||||
assert rows[0]['download_source'] == 'soulseek' # the REAL row survived
|
||||
assert rows[0]['verification_status'] == 'verified'
|
||||
assert rows[0]['file_path'] == NEW # path healed to current location
|
||||
|
||||
|
||||
def test_idempotent_no_growth_on_rescan(db):
|
||||
db.add_library_history_entry('download', 'Song', file_path=OLD,
|
||||
download_source='soulseek', verification_status='unverified')
|
||||
ctx = types.SimpleNamespace(db=db)
|
||||
for _ in range(3):
|
||||
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||
status='verified', write_tag=False, expected={'title': 'Song'})
|
||||
rows = _rows(db)
|
||||
assert len(rows) == 1 and rows[0]['verification_status'] == 'verified'
|
||||
|
||||
|
||||
def test_unknown_file_inserts_one_row_then_dedups(db):
|
||||
# a file SoulSync never downloaded → first scan inserts one review-queue row...
|
||||
ctx = types.SimpleNamespace(db=db)
|
||||
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||
status='unverified', write_tag=False,
|
||||
expected={'title': 'Song', 'artist': 'Artist'})
|
||||
assert len(_rows(db)) == 1
|
||||
# ...and a rescan matches it (no duplicate).
|
||||
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||
status='unverified', write_tag=False,
|
||||
expected={'title': 'Song', 'artist': 'Artist'})
|
||||
rows = _rows(db)
|
||||
assert len(rows) == 1 and rows[0]['download_source'] == 'acoustid_scan'
|
||||
|
||||
|
||||
def test_does_not_heal_wrong_song_with_same_filename(db):
|
||||
# different song, same filename, different title → must stay untouched (no false heal).
|
||||
db.add_library_history_entry('download', 'A Different Song', file_path='/other/01 - Song.flac',
|
||||
download_source='soulseek', verification_status='verified')
|
||||
_scanner()._persist_status(
|
||||
types.SimpleNamespace(db=db), track_id='t1', fpath=NEW, db_path=NEW,
|
||||
status='unverified', write_tag=False, expected={'title': 'Song'})
|
||||
rows = _rows(db)
|
||||
# the unrelated row is untouched, and the unknown file got its own new row.
|
||||
paths = {r['file_path'] for r in rows}
|
||||
assert '/other/01 - Song.flac' in paths and NEW in paths
|
||||
other = next(r for r in rows if r['file_path'] == '/other/01 - Song.flac')
|
||||
assert other['verification_status'] == 'verified' # not corrupted
|
||||
|
|
@ -4,8 +4,9 @@ from core.repair_jobs.acoustid_scanner import AcoustIDScannerJob
|
|||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, rows):
|
||||
def __init__(self, rows, lib_rows=None):
|
||||
self._rows = rows
|
||||
self._lib_rows = lib_rows or []
|
||||
self.executed = []
|
||||
|
||||
def execute(self, query, params=None):
|
||||
|
|
@ -13,6 +14,10 @@ class _FakeCursor:
|
|||
return self
|
||||
|
||||
def fetchall(self):
|
||||
# The #934 history-match SELECT gets its own (id, file_path, title, source)
|
||||
# rows; the tracks scan query gets the track rows.
|
||||
if self.executed and 'FROM library_history' in self.executed[-1][0]:
|
||||
return self._lib_rows
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
|
|
@ -20,8 +25,8 @@ class _FakeCursor:
|
|||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, rows):
|
||||
self._cursor = _FakeCursor(rows)
|
||||
def __init__(self, rows, lib_rows=None):
|
||||
self._cursor = _FakeCursor(rows, lib_rows)
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
|
@ -107,12 +112,13 @@ def test_scan_handles_mixed_track_id_types(monkeypatch):
|
|||
# and finds the primary artist at 100%, suppressing the false flag.
|
||||
|
||||
|
||||
def _make_finding_capturing_context(track_row, captured):
|
||||
def _make_finding_capturing_context(track_row, captured, lib_rows=None):
|
||||
"""Context that captures any create_finding calls into the
|
||||
`captured` list. Tests assert against this list to verify whether
|
||||
the scanner created a finding (false positive) or correctly
|
||||
skipped (multi-value match resolved)."""
|
||||
conn = _FakeConnection([track_row])
|
||||
skipped (multi-value match resolved). ``lib_rows`` seeds the
|
||||
library_history match SELECT (#934)."""
|
||||
conn = _FakeConnection([track_row], lib_rows)
|
||||
config_manager = SimpleNamespace(
|
||||
get=lambda key, default=None: default,
|
||||
set=lambda *args, **kwargs: None,
|
||||
|
|
@ -887,9 +893,10 @@ def test_human_verified_files_are_never_scanned(monkeypatch):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist):
|
||||
def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist, lib_rows=None):
|
||||
"""Drive one _scan_file call and return (status_updates, tag_writes) where
|
||||
status_updates is the list of (query, params) UPDATEs the scanner ran."""
|
||||
status_updates is the list of (query, params) UPDATEs the scanner ran.
|
||||
``lib_rows`` seeds the library_history match SELECT (#934)."""
|
||||
import core.repair_jobs.acoustid_scanner as scanner_mod
|
||||
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
|
||||
lambda name: [], raising=False)
|
||||
|
|
@ -905,7 +912,7 @@ def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_arti
|
|||
context = _make_finding_capturing_context(
|
||||
track_row=("9", "Call Your Name", expected_artist,
|
||||
"/music/cyn.flac", 1, "Album", None, None),
|
||||
captured=captured)
|
||||
captured=captured, lib_rows=lib_rows)
|
||||
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
|
||||
'best_score': 0.97,
|
||||
'recordings': [{'title': 'Call Your Name', 'artist': aid_artist}]})
|
||||
|
|
@ -920,29 +927,32 @@ def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_arti
|
|||
|
||||
|
||||
def test_scan_pass_backfills_verified_status(monkeypatch):
|
||||
# Untagged file + clean fingerprint PASS → the scan backfills 'verified'
|
||||
# into the tag, the tracks row AND library_history (review-queue feed).
|
||||
# Clean fingerprint PASS → the scan backfills 'verified' into the tag, the tracks
|
||||
# row AND the file's library_history row. The history row's path drifted since
|
||||
# download (file moved), so the scan heals it by id (#934) rather than missing it.
|
||||
updates, tag_writes, captured = _run_persistence_scan(
|
||||
monkeypatch, file_status=None,
|
||||
aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki')
|
||||
aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki',
|
||||
lib_rows=[(1, '/downloads/old/cyn.flac', 'Call Your Name', 'soulseek')])
|
||||
assert captured == []
|
||||
assert tag_writes == [('/music/cyn.flac', 'verified')]
|
||||
assert any('tracks' in q and p == ('verified', '9') for q, p in updates)
|
||||
assert any('library_history' in q and p == ('verified', '/music/cyn.flac')
|
||||
# healed by id, status set + path refreshed to the file's current location.
|
||||
assert any('library_history' in q and p == ('verified', '/music/cyn.flac', 1)
|
||||
for q, p in updates)
|
||||
|
||||
|
||||
def test_scan_skip_marks_untagged_file_unverified(monkeypatch):
|
||||
# Title matches but the artist is ambiguous (cover/collab band?) → SKIP.
|
||||
# An untagged file gets 'unverified' so it surfaces in the Downloads-page
|
||||
# review queue instead of silently passing or being deleted.
|
||||
# An untagged file SoulSync never downloaded (no history row) gets a fresh
|
||||
# 'unverified' row INSERTed so it surfaces in the Downloads-page review queue.
|
||||
updates, tag_writes, captured = _run_persistence_scan(
|
||||
monkeypatch, file_status=None,
|
||||
aid_artist='Mantilla', expected_artist='Metallica')
|
||||
aid_artist='Mantilla', expected_artist='Metallica') # no lib_rows → no existing row
|
||||
assert captured == []
|
||||
assert tag_writes == [('/music/cyn.flac', 'unverified')]
|
||||
assert any('tracks' in q and p == ('unverified', '9') for q, p in updates)
|
||||
assert any('library_history' in q and p == ('unverified', '/music/cyn.flac')
|
||||
assert any('INSERT INTO library_history' in q and p[-1] == 'unverified'
|
||||
for q, p in updates)
|
||||
|
||||
|
||||
|
|
|
|||
63
tests/test_history_match.py
Normal file
63
tests/test_history_match.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Pure matcher that re-links a moved file to its download-history row (#934)."""
|
||||
|
||||
from core.downloads.history_match import pick_history_row, like_filename_filter
|
||||
|
||||
|
||||
# (id, file_path, title, download_source)
|
||||
def _row(i, path, title='', source='soulseek'):
|
||||
return (i, path, title, source)
|
||||
|
||||
|
||||
def test_exact_current_path_wins():
|
||||
cands = [_row(1, '/old/song.flac', 'Song'), _row(2, '/lib/song.flac', 'Song')]
|
||||
assert pick_history_row(cands, current_paths=('/lib/song.flac', None),
|
||||
basename='song.flac', title='Song') == 2
|
||||
|
||||
|
||||
def test_falls_back_to_filename_when_path_drifted():
|
||||
# history has the OLD import path; scanner only knows the NEW library path.
|
||||
cands = [_row(7, '/downloads/transfer/Artist/01 - Song.flac', 'Song')]
|
||||
assert pick_history_row(cands, current_paths=('/music/Artist/Album/01 - Song.flac', None),
|
||||
basename='01 - Song.flac', title='Song') == 7
|
||||
|
||||
|
||||
def test_title_guard_blocks_shared_filename_collision():
|
||||
# two different songs both named "01 - Intro.flac" — must NOT heal the wrong one.
|
||||
cands = [_row(1, '/a/01 - Intro.flac', 'Album A Intro'),
|
||||
_row(2, '/b/01 - Intro.flac', 'Album B Intro')]
|
||||
got = pick_history_row(cands, current_paths=('/c/01 - Intro.flac', None),
|
||||
basename='01 - Intro.flac', title='Album B Intro')
|
||||
assert got == 2
|
||||
|
||||
|
||||
def test_title_drift_still_matches_after_normalization():
|
||||
cands = [_row(5, '/old/track.flac', 'Song (Remastered)')]
|
||||
assert pick_history_row(cands, current_paths=('/new/track.flac', None),
|
||||
basename='track.flac', title='song remastered') == 5
|
||||
|
||||
|
||||
def test_prefers_real_download_row_over_synthetic_scan_row():
|
||||
# the #934 collapse: a real download row (drifted path) + a synthetic scan dup at the
|
||||
# exact current path. The REAL row must win, so the synthetic one can be deleted.
|
||||
cands = [_row(10, '/old/song.flac', 'Song', source='soulseek'),
|
||||
_row(11, '/lib/song.flac', 'Song', source='acoustid_scan')]
|
||||
assert pick_history_row(cands, current_paths=('/lib/song.flac', None),
|
||||
basename='song.flac', title='Song') == 10
|
||||
|
||||
|
||||
def test_no_basename_match_returns_none():
|
||||
cands = [_row(1, '/x/other.flac', 'Other')]
|
||||
assert pick_history_row(cands, current_paths=('/x/wanted.flac', None),
|
||||
basename='wanted.flac', title='Wanted') is None
|
||||
|
||||
|
||||
def test_filename_only_substring_is_not_a_match():
|
||||
# '/x/mysong.flac' must NOT satisfy basename 'song.flac'
|
||||
cands = [_row(1, '/x/mysong.flac', 'My Song')]
|
||||
assert pick_history_row(cands, current_paths=('/x/song.flac', None),
|
||||
basename='song.flac', title='Song') is None
|
||||
|
||||
|
||||
def test_like_filter_escapes_metacharacters():
|
||||
# underscores/percents in filenames must not become LIKE wildcards
|
||||
assert like_filename_filter('a_b%c.flac') == r'%a\_b\%c.flac'
|
||||
Loading…
Reference in a new issue