fc3k: cleanup_service mutations — unlink primitive, artist cascade, bulk image delete, tag delete, prune unused
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<sha256[:3]>/<sha256>.(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}
|
||||
|
||||
Reference in New Issue
Block a user