fix(scan): refuse to prune when listing returns 0 songs and DB is non-empty

When background_scan() discovers zero songs in the DLC directory but the
songs table is non-empty, skip delete_missing and return with stage='error'.

An empty listing on a non-empty library almost certainly means the DLC mount
was temporarily inaccessible (FUSE remount, NTFS dirty-flag RO fallback, brief
unmount mid-scan) rather than every song being genuinely deleted.
delete_missing({}) on a non-empty DB deleted ALL rows — the #P1-libpurge
incident that wiped 50,943 songs was caused by exactly this path.

The guard fires only when current_files is empty AND the DB has at least one
row, so a genuinely empty new library is unaffected.

Test: test_scan_prune_guard.py::test_empty_listing_refuses_prune_when_db_nonempty
Failing input: dlc dir with no feedpak/sloppak/wem + 1 DB row.
Before: delete_missing({}) fires, row gone.
After:  stage='error', row survives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
This commit is contained in:
byrongamatos
2026-09-04 13:18:46 +02:00
co-authored by Claude Sonnet 4.6
parent b739e44e3d
commit f6e9727b04
2 changed files with 109 additions and 0 deletions
+20
View File
@@ -313,6 +313,26 @@ def background_scan(force: bool = False):
current_files = {_relpath(f, dlc) for f in all_songs}
# Guard: refuse to prune when the listing returned zero songs but the DB is
# non-empty. An empty listing on a non-empty library almost certainly means
# the DLC mount was temporarily inaccessible (FUSE remount, dirty-flag RO
# fallback, brief unmount) rather than every song being genuinely deleted.
# delete_missing({}) would remove ALL rows — a catastrophic silent purge.
# Treat it as a listing failure instead and leave the DB intact.
if not current_files:
with appstate.meta_db._lock:
_existing = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
if _existing > 0:
_msg = (
f"Scan: listing returned 0 songs but the DB has {_existing} rows "
"— possible mount/permission issue. Skipping prune to avoid data loss."
)
log.error("%s", _msg)
_scan_status = {**_SCAN_STATUS_INIT, "running": True,
"stage": "error", "error": _msg}
return
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
# + genuinely-new files) so the scan can surface an added/removed summary.
_delta = appstate.meta_db.delete_missing(current_files)