mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-10 23:24:30 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
b739e44e3d
commit
f6e9727b04
+20
@@ -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)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Scan prune guard — background_scan() must refuse to prune when listing returns 0
|
||||
songs but the songs table is non-empty (feedBack#P1-libpurge).
|
||||
|
||||
Failing input:
|
||||
- builtin_content seeding mocked (simulates RO mount where writes fail)
|
||||
- dlc dir contains no feedpak/sloppak/wem files
|
||||
- songs table has 1 row
|
||||
|
||||
Without the guard: delete_missing({}) fires, all rows deleted.
|
||||
With the guard: scan aborts with stage='error', row survives.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prune_guard_env(tmp_path, monkeypatch, reset_scan_state):
|
||||
"""Isolated scan env with seeding mocked out, in-process executor, pre-populated DB."""
|
||||
import concurrent.futures
|
||||
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
|
||||
# Empty dlc dir — no feedpak/sloppak/wem files anywhere
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
|
||||
|
||||
# Mock out builtin seeding so the dlc dir stays empty (simulates a RO FUSE
|
||||
# mount where seed writes fail silently and the listing returns nothing).
|
||||
monkeypatch.setattr("builtin_content.seed_builtin_diagnostic_sloppaks",
|
||||
lambda *a, **kw: None)
|
||||
monkeypatch.setattr("builtin_content.seed_builtin_starter_content",
|
||||
lambda *a, **kw: None)
|
||||
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
|
||||
import scan as scan_mod
|
||||
monkeypatch.setattr(
|
||||
scan_mod, "_make_scan_executor",
|
||||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=1),
|
||||
)
|
||||
|
||||
yield mod, scan_mod
|
||||
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_empty_listing_refuses_prune_when_db_nonempty(prune_guard_env):
|
||||
"""background_scan() with 0 discovered songs + 1 DB row → stage=error, row survives.
|
||||
|
||||
Failing input: dlc dir empty (no feedpak/sloppak/wem), songs table has 1 row.
|
||||
Expected: row count unchanged, scan_status['stage'] == 'error'.
|
||||
"""
|
||||
mod, scan_mod = prune_guard_env
|
||||
import appstate
|
||||
|
||||
# Pre-populate the songs table with one row
|
||||
appstate.meta_db.put(
|
||||
"song_that_must_survive.feedpak", 12345.0, 1000,
|
||||
{"title": "Survivor", "artist": "Test", "album": "", "duration": 1.0,
|
||||
"tuning": "", "arrangements": [], "format": "archive"},
|
||||
)
|
||||
count_before = appstate.meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM songs").fetchone()[0]
|
||||
assert count_before == 1, f"pre-condition: 1 row in DB, got {count_before}"
|
||||
|
||||
# Run scan — dlc dir is empty, seeding mocked → listing finds 0 songs
|
||||
scan_mod.background_scan()
|
||||
|
||||
count_after = appstate.meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM songs").fetchone()[0]
|
||||
|
||||
assert count_after == count_before, (
|
||||
f"prune guard must refuse delete_missing when listing returns 0 songs; "
|
||||
f"DB had {count_before} row(s), now has {count_after}"
|
||||
)
|
||||
assert scan_mod._scan_status["stage"] == "error", (
|
||||
f"scan must set stage='error' when the guard fires, "
|
||||
f"got {scan_mod._scan_status['stage']!r}"
|
||||
)
|
||||
assert scan_mod._scan_status["error"] is not None, "error message must be set"
|
||||
Reference in New Issue
Block a user