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