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:
@@ -126,7 +126,11 @@ async def nc_webhook(
|
|||||||
|
|
||||||
# Import lazily so this router can load before the celery app is
|
# Import lazily so this router can load before the celery app is
|
||||||
# ready — important when the backend boots before broker is up.
|
# 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()
|
supported = _supported_extensions()
|
||||||
|
|
||||||
@@ -154,8 +158,22 @@ async def nc_webhook(
|
|||||||
abs_path = _nc_path_to_abs(nc_path) if nc_path else None
|
abs_path = _nc_path_to_abs(nc_path) if nc_path else None
|
||||||
if not abs_path:
|
if not abs_path:
|
||||||
return {"status": "ignored", "reason": "non-user-files 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:
|
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)
|
await handle_file_deletion(abs_path)
|
||||||
logger.info("nc-webhook deleted: marked %s as discarded", abs_path)
|
logger.info("nc-webhook deleted: marked %s as discarded", abs_path)
|
||||||
return {"status": "applied", "action": "discard", "path": abs_path}
|
return {"status": "applied", "action": "discard", "path": abs_path}
|
||||||
|
|||||||
@@ -204,8 +204,25 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
|||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(Photo).where(Photo.filepath == filepath)
|
select(Photo).where(Photo.filepath == filepath)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
existing_photo = existing.scalar_one_or_none()
|
||||||
logger.debug(f"File already indexed: {filepath}")
|
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
|
processed_files += 1
|
||||||
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
progress_set(REDIS_KEY_PROCESSED, processed_files)
|
||||||
continue
|
continue
|
||||||
@@ -506,6 +523,35 @@ async def handle_file_deletion(filepath: str):
|
|||||||
logger.info(f"Marked photo as discarded: {filepath}")
|
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')
|
@shared_task(name='backfill_gps')
|
||||||
def backfill_gps():
|
def backfill_gps():
|
||||||
"""Re-run metadata extraction on every non-discarded photo that is
|
"""Re-run metadata extraction on every non-discarded photo that is
|
||||||
|
|||||||
Reference in New Issue
Block a user