soulsync/tests/test_dead_file_cleaner_guard.py
BoulderBadgeDad 26368a80ab Dead File Cleaner: don't flag a whole library when paths just aren't reachable (#828)
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" even though the files exist and play in Plex. Root cause:
the DB stores paths as Plex reported them (/Volumes/Core/Music/...), which don't
exist inside SoulSync's container. resolve_library_file_path() returns None for
"couldn't find it at any known base dir" — and for a mis-mounted library that's
EVERY track, not a deletion. The job treated None as "file deleted" and created
a finding per track.

Fix mirrors the existing transfer-folder abort: collect unresolvable tracks, and
if at least max_unresolved_fraction (default 0.5) of the library is unresolvable
once it's past min_tracks_for_guard (default 25), treat it as a path-mapping/
mount problem — abort with an actionable message (Docker mount / Settings →
Library → Music Paths) and create ZERO findings. A small fraction unresolvable
is still reported as genuine dead files, and tiny libraries (< min) report as
before. Both thresholds are configurable per the job's settings.

Tests: mass-unresolvable aborts with no findings; a lone dead file among real
ones is still reported; a small all-dead library still reports; thresholds
configurable. 54 repair tests pass.
2026-06-09 13:12:50 -07:00

135 lines
4.1 KiB
Python

"""Dead File Cleaner — mass-false-positive guard (#828).
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" because their stored /Volumes/... paths don't exist inside
the container. The resolver returning None means "couldn't find it at any known
base dir" — for a mis-mounted library that's EVERY track, not a real deletion.
The job now refuses to flag when a large fraction is unresolvable (a path-mapping
problem) and reports it as such, mirroring the existing transfer-folder abort.
"""
from __future__ import annotations
from core.repair_jobs.base import JobContext
from core.repair_jobs.dead_file_cleaner import DeadFileCleanerJob
class _Cur:
def __init__(self, rows):
self._rows = rows
def execute(self, *a, **k):
pass
def fetchall(self):
return self._rows
def fetchone(self):
return [len(self._rows)]
def close(self):
pass
class _Conn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _Cur(self._rows)
def close(self):
pass
class _Db:
def __init__(self, rows):
self._rows = rows
def _get_connection(self):
return _Conn(self._rows)
class _Cfg:
def __init__(self, overrides=None):
self._o = overrides or {}
def get(self, key, default=None):
return self._o.get(key, default)
def _row(i, path):
# (track_id, title, artist, album, file_path, album_thumb, artist_thumb)
return (i, f"Track {i}", "Yellowcard", "Ocean Avenue", path, None, None)
def _run(rows, transfer_folder, cfg_overrides=None):
findings = []
cfg = _Cfg({'soulseek.download_path': '', **(cfg_overrides or {})})
ctx = JobContext(
db=_Db(rows),
transfer_folder=str(transfer_folder),
config_manager=cfg,
create_finding=lambda **kw: (findings.append(kw) or True),
)
res = DeadFileCleanerJob().scan(ctx)
return res, findings
def test_mass_unresolvable_aborts_without_findings(tmp_path):
# 30 tracks all pointing to a /Volumes path that doesn't exist in this env
# -> systemic path problem -> abort, zero findings, one error.
rows = [_row(i, f"/Volumes/Core/Music/Plex/Yellowcard/{i}.mp3") for i in range(30)]
res, findings = _run(rows, tmp_path)
assert findings == []
assert res.findings_created == 0
assert res.errors >= 1
assert res.scanned == 30
def test_few_unresolvable_creates_findings(tmp_path):
# 4 real (resolvable) files + 1 genuinely missing -> fraction 0.2 < 0.5 ->
# the one dead file IS reported.
rows = []
for i in range(4):
f = tmp_path / f"real_{i}.mp3"
f.write_text("x")
rows.append(_row(i, str(f)))
rows.append(_row(99, "/no/such/path/dead.mp3"))
res, findings = _run(rows, tmp_path,
{'repair.jobs.dead_file_cleaner.min_tracks_for_guard': 4})
assert res.findings_created == 1
assert len(findings) == 1
assert findings[0]['entity_id'] == '99'
assert res.errors == 0
def test_small_library_all_dead_still_reports(tmp_path):
# 3 dead tracks, below the default min_tracks_for_guard (25) -> guard doesn't
# apply -> all 3 reported (a tiny library can legitimately be all-dead).
rows = [_row(i, f"/no/such/{i}.mp3") for i in range(3)]
res, findings = _run(rows, tmp_path)
assert res.findings_created == 3
def test_guard_thresholds_configurable(tmp_path):
# Lower min to 4; all 4 dead -> fraction 1.0 >= 0.5 -> abort.
rows = [_row(i, f"/no/such/{i}.mp3") for i in range(4)]
res, findings = _run(rows, tmp_path,
{'repair.jobs.dead_file_cleaner.min_tracks_for_guard': 4})
assert res.findings_created == 0
assert res.errors >= 1
def test_healthy_library_no_abort_no_findings(tmp_path):
# 30 fully-resolvable tracks -> 0 dead -> neither aborts nor flags anything.
rows = []
for i in range(30):
f = tmp_path / f"ok_{i}.mp3"
f.write_text("x")
rows.append(_row(i, str(f)))
res, findings = _run(rows, tmp_path)
assert res.findings_created == 0
assert res.errors == 0
assert res.scanned == 30
assert findings == []