fix(nc-webhook): propagate folder deletes + resurrect un-discarded files

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.
This commit is contained in:
Claudio
2026-05-11 12:50:52 +02:00
parent f657e2c0ba
commit 94088253f8
2 changed files with 68 additions and 4 deletions

View File

@@ -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