From 94088253f8791d34f27d40c77b4de19336f78157 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 12:50:52 +0200 Subject: [PATCH] fix(nc-webhook): propagate folder deletes + resurrect un-discarded files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced by the Phase 2 deletion-roundtrip test: A) Folder delete in NC only fires one NodeDeletedEvent (for the folder itself, no .jpg suffix). The handler bailed with "unsupported extension" and photos inside the folder kept is_discarded=false in mule until the 30-min discard_missing_photos_beat caught up. Fix: when the deleted path has no supported extension, call new `handle_directory_deletion()` which UPDATEs every Photo whose filepath starts with `dirpath + '/'`. Single SQL statement, idempotent (excludes already-discarded rows so re-deliveries don't re-stamp discarded_at). C) PUT-overwrite of a previously-discarded file fired NodeWrittenEvent → scan_folder, but scan_folder's "Photo exists by filepath, skip" branch left is_discarded=true. File was back on disk; mule still treated it as gone. Fix: in that branch, if the existing row is discarded, flip is_discarded=false + clear discarded_at + re-queue extract_metadata so EXIF / nextcloud_fileid pick up any changes to the bytes. Together these close the gap for "delete then put back" round-trips via the NC webhook path. Trashbin-restore (bug B in the test report) remains an NC-side gap — NC doesn't emit any event mule subscribes to for restore-from-trash. That stays a TODO. --- backend/app/routers/nc_webhook.py | 22 ++++++++++++-- backend/app/tasks/scan.py | 50 +++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/backend/app/routers/nc_webhook.py b/backend/app/routers/nc_webhook.py index d8cc6a3..e2636f5 100644 --- a/backend/app/routers/nc_webhook.py +++ b/backend/app/routers/nc_webhook.py @@ -126,7 +126,11 @@ async def nc_webhook( # Import lazily so this router can load before the celery app is # ready — important when the backend boots before broker is up. - from app.tasks.scan import scan_folder, handle_file_deletion + from app.tasks.scan import ( + scan_folder, + handle_file_deletion, + handle_directory_deletion, + ) supported = _supported_extensions() @@ -154,8 +158,22 @@ async def nc_webhook( abs_path = _nc_path_to_abs(nc_path) if nc_path else None if not abs_path: return {"status": "ignored", "reason": "non-user-files path"} + # Folder deletes: NC fires exactly one NodeDeletedEvent for the + # folder, not one per child file. Detect the directory case by + # the absence of a supported image extension and recursively + # discard every Photo under that prefix. if Path(abs_path).suffix.lower() not in supported: - return {"status": "ignored", "reason": "unsupported extension"} + n = await handle_directory_deletion(abs_path) + logger.info( + "nc-webhook deleted (dir): %s -> %s photos discarded", + abs_path, n, + ) + return { + "status": "applied", + "action": "discard_subtree", + "path": abs_path, + "discarded": n, + } await handle_file_deletion(abs_path) logger.info("nc-webhook deleted: marked %s as discarded", abs_path) return {"status": "applied", "action": "discard", "path": abs_path} diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 455fcfb..7c3befe 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -204,8 +204,25 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta existing = await session.execute( select(Photo).where(Photo.filepath == filepath) ) - if existing.scalar_one_or_none(): - logger.debug(f"File already indexed: {filepath}") + existing_photo = existing.scalar_one_or_none() + if existing_photo is not None: + # Resurrect a previously-discarded row if the + # file is back on disk. WebDAV DELETE + + # re-upload, trashbin restore via PUT-overwrite, + # and any "I removed it then put it back" flow + # all land here. Re-queue extract_metadata in + # case the bytes changed (different EXIF, new + # nextcloud_fileid). + if existing_photo.is_discarded: + existing_photo.is_discarded = False + existing_photo.discarded_at = None + await session.commit() + logger.info( + f"Resurrected discarded photo on rescan: {filepath}" + ) + extract_metadata.delay(existing_photo.id) + else: + logger.debug(f"File already indexed: {filepath}") processed_files += 1 progress_set(REDIS_KEY_PROCESSED, processed_files) continue @@ -506,6 +523,35 @@ async def handle_file_deletion(filepath: str): logger.info(f"Marked photo as discarded: {filepath}") +async def handle_directory_deletion(dirpath: str) -> int: + """Mark every Photo under `dirpath` as discarded — used when Nextcloud + fires a NodeDeletedEvent on a folder. NC emits ONE event for the + folder itself (not one per child file), so without this we'd never + see the children disappear except via the 30-min reconcile sweep. + + Returns the number of photos affected. Matches by `filepath LIKE + dirpath + '/%'` (the trailing slash is important — we don't want + `/photos/foo` to also match `/photos/foobar.jpg`). + """ + from sqlalchemy import update + + prefix = dirpath.rstrip("/") + "/" + async with AsyncSessionLocal() as session: + result = await session.execute( + update(Photo) + .where( + Photo.filepath.like(prefix + "%"), + Photo.is_discarded.is_(False), + ) + .values(is_discarded=True, discarded_at=datetime.utcnow()) + ) + await session.commit() + n = result.rowcount or 0 + if n: + logger.info(f"Marked {n} photos as discarded under {dirpath}") + return n + + @shared_task(name='backfill_gps') def backfill_gps(): """Re-run metadata extraction on every non-discarded photo that is