fix(scanner): verify recorded folders per entry, not per scan (#1145)

`_reconcile_cache()` replaced the recorded folder list only when *nothing* was left
unverified: one switched-off drive, one directory `os.walk` could not enter, or one
symlink whose target was gone, and the whole list fell back to a union with what
the walk found. A folder moved with a file manager therefore stayed in the sidebar
for good — the walk kept adding the new location while the old entry was never
dropped, a restart re-hydrated the union from SQLite, and the ghost could not be
deleted in the UI either because its directory was gone.

* `_UnreachablePaths.covers_folder()` answers "could this specific entry have been
  read?" for a library-relative folder: it is kept only while a directory the walk
  failed to enter (or an offline symlink) may still hold it. A root that is
  unreadable *as a whole* deliberately does not count: library-relative names carry
  no root, so a missing drive would trivially cover every entry and no ghost could
  ever be cleared. Its folders still show while they hold cached models
  (`get_all_folders()` unions the model-derived folders) and a genuinely empty one
  comes back the next time that root is walked.
* The merge keeps today's union for a scoped scan, and for a root-scoped scan that
  does not cover every root: those cannot speak for the roots they were not asked
  about. Asking for every root is a full refresh and cleans again.

Verified: a folder moved externally is dropped while another root is offline, the
offline root keeps its own folders and cached models, and a scoped scan still never
drops a folder of a root it did not scan. The Windows-only half of this (a
backslash `os.sep` making the configured twin shadow the live root, so deleted
models were never pruned) is now reproduced in CI by patching `os.sep` instead of
needing a Windows runner.
This commit is contained in:
Will Miao
2026-10-12 09:31:17 +08:00
parent fadad1b507
commit 0508daa0fb
3 changed files with 243 additions and 15 deletions
+63 -12
View File
@@ -309,6 +309,36 @@ class _UnreachablePaths:
return prefix
return None
def covers_folder(self, root: str, folder: str) -> bool:
"""Whether *folder* (relative to *root*) sits in an unreadable area.
Used when refreshing the recorded folder list: an entry the walk did not
find is only kept while a *specific* area we could not read may still hold
it -- a directory ``os.walk`` failed to enter, or a first-level symlink
whose target is gone. Both directions count: the folder may be inside the
unreadable directory, or hold it.
A root that is unreadable *as a whole* (drive switched off) deliberately
does not count here. The recorded list holds library-relative names, so a
missing root would trivially "cover" every entry and no ghost could ever be
cleared (issue #1145). Its folders still show up while they hold cached
models -- ``get_all_folders()`` unions the model-derived folders -- and a
genuinely empty folder comes back the next time that root is walked.
"""
relative = (folder or "").replace("\\", "/").strip("/")
if not relative:
return False
root_prefix = _normalized_root_prefix(root)
candidate = f"{root_prefix}{relative}/"
if _CASE_INSENSITIVE_PATHS:
candidate = candidate.lower()
return any(
prefix != root_prefix
and (candidate.startswith(prefix) or prefix.startswith(candidate))
for prefix in self._prefixes
)
def record_kept(self, prefix: str, amount: int = 1) -> None:
detail = self._details.get(prefix)
if detail is not None:
@@ -2197,22 +2227,43 @@ class ModelScanner:
total_removed += dedup_removed
# The walk above visited every directory *in scope*, so refresh the
# recorded folder list (including empty folders) when nothing was
# left unverified — e.g. an empty folder was created or removed
# externally. A scoped scan, or one with unreadable paths, can only
# add to the list: dropping folders this walk never looked at would
# empty the sidebar for every drive that was not scanned.
# recorded folder list (including empty folders) when this run could
# speak for every root: e.g. an empty folder was created or removed
# externally.
#
# A folder the walk did not find is dropped unless a root we could not
# read still covers it — the drive is off, ``os.walk`` failed to enter
# a directory, or a first-level symlink lost its target. Keeping the
# *whole* list whenever anything was unreadable is what made a ghost
# folder outlive every refresh, a restart and even the folder being
# deleted in the UI (issue #1145). A scoped scan cannot speak for the
# roots it was not asked about, so it still only adds: dropping
# folders it never looked at would empty the sidebar for every drive
# that was not scanned.
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
fully_verified = (
len(roots) == len(configured_roots)
and scope_folder is None
and not unreachable.has_entries()
discovered_set = set(sorted_discovered)
recorded = set(self._cache.all_folders or [])
speaks_for_every_root = scope_folder is None and (
scope is None
or scope.roots is None
or len(scope_roots) == len(configured_roots)
)
if fully_verified:
merged_folders = sorted_discovered
if speaks_for_every_root:
merged_folders = sorted(
discovered_set
| {
folder
for folder in recorded - discovered_set
if any(
unreachable.covers_folder(root, folder)
for root in configured_roots + configured_only
)
},
key=lambda x: x.lower(),
)
else:
merged_folders = sorted(
set(self._cache.all_folders or []) | set(sorted_discovered),
recorded | discovered_set,
key=lambda x: x.lower(),
)
folders_changed = self._cache.all_folders != merged_folders