fix(images): background jobs skip files hidden by the removal mark; walkers skip trash folders inside output roots (#501)

- Thumbnail jobs and the legacy scan_output_dir listing leave
  library-removed files out (one mark check, mark_hides).
- Missing-file links also forgets removal marks that hide nothing any
  more (file gone from a folder that exists, or changed), only under a
  readable, non-empty source, re-checked at apply.
- The sidebar Images, Favorites and Needs review counts and the
  collection counts are read again when an image_delete job ends.
- utils/media_folders.py: one skip for thumbnails and trash folders
  (.Trash, .Trash-<uid>, .Trashes, $RECYCLE.BIN) used by every walker,
  the image monitor and the delete review.
This commit is contained in:
Vito Sansevero
2026-10-29 13:25:23 -07:00
parent 66ca988d2a
commit 245d0cdc6a
15 changed files with 635 additions and 91 deletions
+40 -32
View File
@@ -47,12 +47,16 @@ from .maintenance import MAINTENANCE_PREVIEW_LEASE, _fail_job, _failure, backup_
try:
from ...database import image_removal_schema
from ...database.image_removal import mark_hides
from ...database.annotation_schema import MAX_LOCATOR_LENGTH
from ...utils import trash
from ...utils.media_folders import is_trash_folder
except ImportError:
from database import image_removal_schema
from database.image_removal import mark_hides
from database.annotation_schema import MAX_LOCATOR_LENGTH
from utils import trash
from utils.media_folders import is_trash_folder
IMAGE_DELETE = "delete_images"
MODES = ("library", "trash")
@@ -61,7 +65,6 @@ TITLES = {
"trash": "Delete images (move to the trash)",
}
MAX_DETAIL_ROWS = 50
TRASH_FOLDERS = frozenset({".trash", ".trashes", "$recycle.bin"})
# Reason codes (design §4.4) and the sentence each shows.
REASONS = {
@@ -93,19 +96,12 @@ SOURCES_CHANGED = "The sources changed since the review; nothing was changed."
RECORDS_FAILED = "The image records could not be removed; the job stopped."
def _is_trash_folder(name: str) -> bool:
folded = name.casefold()
return folded in TRASH_FOLDERS or (
folded.startswith(".trash-") and folded[7:].isdigit()
)
def lexical_problem(relative: str) -> str | None:
"""The refusal code a root-relative POSIX path earns before any file access."""
parts = relative.split("/")
if "thumbnails" in parts[:-1]:
return "thumbnail"
if any(_is_trash_folder(part) for part in parts[:-1]):
if any(is_trash_folder(part) for part in parts[:-1]):
return "trash-folder"
if os.path.splitext(parts[-1])[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
return "not-media"
@@ -443,37 +439,49 @@ class ImageDeleteMixin:
if not marks:
return set()
located = self._workspace_locators(listings)
hidden = set()
for locator, (size, mtime_ns) in marks.items():
entry = located.get(locator)
if entry is None:
continue
try:
info = os.lstat(entry.path)
except OSError:
continue
if (info.st_size, info.st_mtime_ns) == (size, mtime_ns):
hidden.add(entry.normalized)
return hidden
return {
entry.normalized
for locator, entry in ((locator, located.get(locator)) for locator in marks)
if entry is not None and mark_hides(marks, locator, entry.path)
}
def _removed_paths(self, paths) -> set:
"""The absolute paths among *paths* a removal mark hides (blocking)."""
marks = self._removal_marks()
if not marks:
return set()
hidden = set()
for path in paths:
mark = marks.get(os.path.normcase(os.path.realpath(str(path))))
if mark is None:
continue
try:
info = os.lstat(path)
except OSError:
continue
if (info.st_size, info.st_mtime_ns) == tuple(mark):
hidden.add(str(path))
return {
str(path)
for path in paths
if mark_hides(marks, os.path.normcase(os.path.realpath(str(path))), path)
}
def _removal_check_under(self, root):
"""``hidden(path) -> bool`` for files a walk of *root* found (blocking).
A walk never follows a link below *root*, so a file's locator is
``normcase(join(realpath(root), relative))``: no ``realpath`` per
file, and an ``lstat`` only for a marked one. Without marks it
answers False at once.
"""
marks = self._removal_marks()
if not marks:
return lambda _path: False
root = os.fspath(root)
real = os.path.realpath(root)
def hidden(path):
relative = os.path.relpath(os.fspath(path), root)
locator = os.path.normcase(os.path.join(real, relative))
return mark_hides(marks, locator, path)
return hidden
def _without_removed(self, root, paths) -> list:
"""*paths* found by a walk of *root*, less the hidden ones (blocking)."""
removed = self._removal_check_under(root)
return [path for path in paths if not removed(path)]
def parse_mode(value):
"""The apply's ``mode``, or None when it is not one of MODES."""