From 6df102b83dd2f67a014a193a9ef7ac0b675f801b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 00:39:45 -0400 Subject: [PATCH] =?UTF-8?q?fc3k:=20cleanup=5Fservice=20mutations=20?= =?UTF-8?q?=E2=80=94=20unlink=20primitive,=20artist=20cascade,=20bulk=20im?= =?UTF-8?q?age=20delete,=20tag=20delete,=20prune=20unused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/cleanup_service.py | 211 ++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index d016012..3f749ad 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -12,6 +12,8 @@ re-exports from this module and then delete the wrapper. """ from __future__ import annotations +from pathlib import Path + from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -154,3 +156,212 @@ def find_unused_tags( if limit is not None: stmt = stmt.limit(limit) return list(session.execute(stmt).scalars().all()) + + +def unlink_image_files( + image: ImageRecord, images_root: Path, +) -> dict: + """Best-effort unlink of all on-disk files for an ImageRecord. + + Targets: image.path (original), image.thumbnail_path (cached + thumbnail), and the computed thumbs path at + /images/thumbs//.(jpg|png|webp) (tries all + three extensions; missing extension is silently OK). + + Returns {"original": bool, "thumbnail": bool}. Missing files + count as success (missing_ok semantics). OSErrors are swallowed + and reported as False so the calling DB delete still proceeds. + """ + out = {"original": False, "thumbnail": False} + if image.path: + try: + Path(image.path).unlink(missing_ok=True) + out["original"] = True + except OSError: + out["original"] = False + # Custom thumbnail_path (when set) — try it first. + if image.thumbnail_path: + try: + Path(image.thumbnail_path).unlink(missing_ok=True) + out["thumbnail"] = True + except OSError: + out["thumbnail"] = False + # Convention thumbs dir — try all extensions; missing OK. + if image.sha256: + bucket = image.sha256[:3] + for ext in ("jpg", "png", "webp"): + try: + (images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink( + missing_ok=True, + ) + except OSError: + pass + return out + + +def delete_artist_cascade( + session: Session, *, artist_id: int, images_root: Path, +) -> dict: + """Batched delete of an artist's images + the artist row. + + Mirrors the cleanup_artist_async pattern: 500-row batches, + commit between batches so partial progress survives a worker + kill. Idempotent on missing artist (returns zeroed counts). + Postgres cascades handle image_tag / image_provenance / + series_page / tag_suggestion_rejection from ImageRecord delete, + and source / post / download_event / etc. from Artist delete + (via Artist.sources cascade="all, delete-orphan"). + """ + artist = session.get(Artist, artist_id) + if artist is None: + return { + "artist": None, + "summary": { + "images_deleted": 0, + "files_deleted": 0, + "thumbs_deleted": 0, + "import_tasks_nulled": 0, + "files_failed": 0, + }, + } + artist_info = {"id": artist.id, "name": artist.name, "slug": artist.slug} + + images_deleted = 0 + files_deleted = 0 + thumbs_deleted = 0 + files_failed = 0 + + while True: + rows = session.execute( + select(ImageRecord) + .where(ImageRecord.artist_id == artist.id) + .limit(500) + ).scalars().all() + if not rows: + break + for img in rows: + unlinked = unlink_image_files(img, images_root) + if unlinked["original"]: + files_deleted += 1 + else: + files_failed += 1 + if unlinked["thumbnail"]: + thumbs_deleted += 1 + session.delete(img) + images_deleted += 1 + session.commit() + + # ImportTask.result_image_id FK is SET NULL on image delete (Postgres + # handles this in the cascade above). We don't separately count those + # in FC-3k — the legacy cleanup_artist_async did it via + # source_path_prefix matching that's out of scope here. + import_tasks_nulled = 0 + + session.delete(artist) + session.commit() + + return { + "artist": artist_info, + "summary": { + "images_deleted": images_deleted, + "files_deleted": files_deleted, + "thumbs_deleted": thumbs_deleted, + "import_tasks_nulled": import_tasks_nulled, + "files_failed": files_failed, + }, + } + + +def delete_images( + session: Session, *, image_ids: list[int], images_root: Path, +) -> dict: + """Delete a list of images in 500-row batches with commit between. + + Postgres CASCADE on image_tag / image_provenance / series_page / + tag_suggestion_rejection / post_attachment(FK SET NULL) handles + the DB side; this function handles file unlinks first then row + deletes. Idempotent on missing IDs (returned as missing_ids; + no error). On partial OSError, the row is still deleted and + files_failed is incremented. + """ + if not image_ids: + return { + "images_deleted": 0, + "files_deleted": 0, + "thumbs_deleted": 0, + "files_failed": 0, + "missing_ids": [], + } + + seen_ids: set[int] = set() + images_deleted = 0 + files_deleted = 0 + thumbs_deleted = 0 + files_failed = 0 + + pending = list(image_ids) + while pending: + batch_ids = pending[:500] + pending = pending[500:] + rows = session.execute( + select(ImageRecord).where(ImageRecord.id.in_(batch_ids)) + ).scalars().all() + for img in rows: + seen_ids.add(img.id) + unlinked = unlink_image_files(img, images_root) + if unlinked["original"]: + files_deleted += 1 + else: + files_failed += 1 + if unlinked["thumbnail"]: + thumbs_deleted += 1 + session.delete(img) + images_deleted += 1 + session.commit() + + missing = sorted(set(image_ids) - seen_ids) + return { + "images_deleted": images_deleted, + "files_deleted": files_deleted, + "thumbs_deleted": thumbs_deleted, + "files_failed": files_failed, + "missing_ids": missing, + } + + +def delete_tag(session: Session, *, tag_id: int) -> dict: + """Simple DELETE FROM tag WHERE id=?. + + Postgres cascades the rest (image_tag, tag_alias, tag_allowlist, + tag_reference_embedding, tag_suggestion_rejection, series_page). + Returns counts BEFORE delete so the caller can surface them. + Raises LookupError if tag_id not found. + """ + tag = session.get(Tag, tag_id) + if tag is None: + raise LookupError(f"tag id not found: {tag_id}") + associations_count = count_tag_associations(session, tag_id=tag_id) + info = {"id": tag.id, "name": tag.name, "kind": tag.kind.value} + session.delete(tag) + session.commit() + return {"deleted": info, "associations_removed": associations_count} + + +def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict: + """Find tags with zero references and (unless dry_run) delete them. + + Returns: + dry_run=True: {"count": N, "sample_names": [first 50]} + dry_run=False: {"deleted": N, "sample_names": [first 50]} + """ + unused = find_unused_tags(session) + sample = [t.name for t in unused[:50]] + if dry_run: + return {"count": len(unused), "sample_names": sample} + ids = [t.id for t in unused] + if ids: + session.execute( + Tag.__table__.delete().where(Tag.id.in_(ids)) + ) + session.commit() + return {"deleted": len(ids), "sample_names": sample}