The enhanced-tab "Sync" button's stale-removal phase deleted any track whose file wasn't on disk, with NO guard — so if the music storage was momentarily unavailable (sleeping NAS, dropped mount, unmounted Docker volume, WSL hiccup), os.path.exists returned False for EVERY file and one click wiped the whole artist (tracks + their now-"empty" albums) from the DB. The deep-scan path already had a 50%-stale safety net (#828); this endpoint never got one. - New core/library/stale_guard.py: is_implausible_stale_removal(missing, total) — a tested rule (skip removal when missing > 50% of a >=5-track set), centralised so every stale-removal site can share it. - sync_artist_library: if the guard trips, SKIP removal (delete nothing), return removal_skipped + warn; the frontend shows "storage may be offline — skipped" instead of silently deleting. Empty-album cleanup now also only runs on the non-skipped path and uses `album_id IS NOT NULL` (fixes the NOT IN-with-NULL no-op). Frontend also refreshes the view on additions, not just removals. - @admin_only on the endpoint — it deletes tracks + albums but was ungated, while the sibling delete_album endpoint is gated. Deep scan was already safe (different mechanism: server-diff + its own 50% guard). Tests: guard unit rules; endpoint skips removal when all files missing (keeps the tracks), removes only the genuinely-gone few otherwise, and 403s for non-admins. 7 new tests pass.
28 lines
1 KiB
Python
28 lines
1 KiB
Python
"""Storage-unreachable guard for library stale-removal (artist sync, #828 pattern)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.library.stale_guard import is_implausible_stale_removal as g
|
|
|
|
|
|
def test_all_missing_in_a_real_collection_is_blocked():
|
|
# 40/40 missing → almost certainly a down mount, not 40 real deletions.
|
|
assert g(40, 40) is True
|
|
assert g(30, 40) is True # 75% missing — still implausible
|
|
|
|
|
|
def test_a_few_genuinely_missing_files_are_allowed():
|
|
assert g(3, 40) is False # normal cleanup of a few gone files
|
|
assert g(20, 40) is False # exactly 50% is NOT over the threshold
|
|
|
|
|
|
def test_tiny_sets_are_never_blocked():
|
|
# A 2-track artist legitimately losing both must still clean up.
|
|
assert g(2, 2) is False
|
|
assert g(4, 4) is False # below min_total (5)
|
|
|
|
|
|
def test_edge_inputs():
|
|
assert g(0, 0) is False
|
|
assert g(0, 100) is False # nothing missing
|
|
assert g(5, 5) is True # min_total met, all missing
|