soulsync/core/library/stale_guard.py
BoulderBadgeDad 94a0070fa8 Orphan detector: hard-bail on a mass-orphan flood instead of warn-only
A DB<->filesystem path mismatch (Docker volume change, remount, Music
Paths unset for the container) makes EVERY library file fail to resolve
to a DB track, so the orphan detector flags the whole library as
orphaned. The mass-orphan check only logged a warning and then created
the findings anyway — so a user batch-applying 'move to staging' or
'delete' would relocate or wipe their entire library.

Make it a hard skip (create zero findings) like the dead-file cleaner
and stale-removal paths already do (#828). Centralise the predicate as
is_implausible_orphan_flood() alongside is_implausible_stale_removal()
so the rule lives in one tested place. Small genuine orphan sets still
surface unchanged — only an implausibly large flood (>50% and >20) is
suppressed.

Tests: seam cases for the new predicate + scan-level regressions (mass
mismatch -> 0 findings; small genuine set -> still reported).
2026-06-12 08:15:23 -07:00

84 lines
3.3 KiB
Python

"""Guard against mass-deleting library rows when storage is unreachable.
The library "sync" / cleanup paths mark a track stale when its file isn't on
disk and then delete the row. But ``os.path.exists`` returns False for EVERY
file when the music storage is momentarily unavailable — a sleeping NAS, a
dropped network mount, an unmounted Docker volume, a WSL mount hiccup. Without a
guard, one click then wipes the whole artist/library from the DB even though the
files are fine.
This mirrors the safety the deep-scan path already had (``database_update_worker``
skips removal when stale > 50% of a >100-track library — issue #828). Centralised
here so every stale-removal site can share one tested rule.
"""
from __future__ import annotations
# Don't second-guess tiny sets — a 2-track artist legitimately losing both files
# shouldn't be blocked. Above this, an implausibly large missing fraction almost
# always means "storage down", not "files actually deleted".
DEFAULT_MIN_TOTAL = 5
DEFAULT_MAX_MISSING_FRACTION = 0.5
def is_implausible_stale_removal(
missing_count: int,
total_count: int,
*,
min_total: int = DEFAULT_MIN_TOTAL,
max_fraction: float = DEFAULT_MAX_MISSING_FRACTION,
) -> bool:
"""True when ``missing_count`` is too large a share of ``total_count`` to be a
real deletion — i.e. the storage is probably unreachable and the caller should
SKIP removal (and warn) rather than delete.
Returns False for small sets (< ``min_total``) so normal cleanup of a few
genuinely-gone files still works.
"""
if total_count <= 0 or missing_count <= 0:
return False
if total_count < min_total:
return False
return missing_count > total_count * max_fraction
# The orphan detector walks the transfer folder and flags any audio file whose
# path/title doesn't resolve to a DB track. If the DB's stored paths share a base
# prefix the local filesystem no longer has (remount, Docker volume change, WSL
# hiccup), EVERY file misses and the whole library looks "orphaned" — and a user
# batch-applying "move to staging" on those findings would relocate their entire
# library. Same failure mode as stale-removal, so we skip the whole result when
# the orphan share is implausibly large. Needs an absolute floor too: 3/4 orphans
# in a tiny folder is normal, 4000/5000 is a path mismatch.
DEFAULT_MIN_ORPHANS = 20
DEFAULT_MAX_ORPHAN_FRACTION = 0.5
def is_implausible_orphan_flood(
orphan_count: int,
total_count: int,
*,
min_orphans: int = DEFAULT_MIN_ORPHANS,
max_fraction: float = DEFAULT_MAX_ORPHAN_FRACTION,
) -> bool:
"""True when so many files look orphaned that the DB↔filesystem path mapping is
almost certainly broken (not real orphans) and the scan should create NO
findings — otherwise a batch "move to staging" / "delete" could wipe the
library. Below ``min_orphans`` (absolute) it always returns False so small,
genuine orphan sets still surface.
"""
if total_count <= 0 or orphan_count <= 0:
return False
if orphan_count <= min_orphans:
return False
return orphan_count > total_count * max_fraction
__all__ = [
"is_implausible_stale_removal",
"is_implausible_orphan_flood",
"DEFAULT_MIN_TOTAL",
"DEFAULT_MAX_MISSING_FRACTION",
"DEFAULT_MIN_ORPHANS",
"DEFAULT_MAX_ORPHAN_FRACTION",
]