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 __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -154,3 +156,212 @@ def find_unused_tags(
|
|||||||
if limit is not None:
|
if limit is not None:
|
||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
return list(session.execute(stmt).scalars().all())
|
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