"""Delete extracted figures that nothing has ever used. The extractor pulled every embedded image out of 18 source PDFs — figures, photographs, logos, page rules — and only the ones it could tie to a question were ever attached. The rest have sat in storage since import. "Unused" is defined by exclusion, and every exclusion is checked here rather than assumed: a figure is kept if any question uses it as a stem or explanation image, if any question version mentions it, if it appears in article prose or a flashcard, or if it is not an extracted figure at all. What is left is deleted from object storage and from the image bank. This is not reversible from the application. The volume is in the nightly borg backup, which is the only way back. docker compose exec backend python -m scripts.purge_unused_figures docker compose exec backend python -m scripts.purge_unused_figures --apply """ import sys from sqlalchemy import text as sa_text from app.config import settings from app.database import SessionLocal from app.models.media import MediaAsset, MediaTagLink from app.services import storage_service PREFIX = "images/" def _referenced(db) -> set[str]: """Every figure any part of the platform points at, however indirectly.""" keys: set[str] = set() for statement in ( "SELECT image_path FROM questions WHERE image_path LIKE 'images/%'", "SELECT explanation_image_path FROM questions WHERE explanation_image_path LIKE 'images/%'", "SELECT snapshot->>'image_path' FROM question_versions WHERE snapshot->>'image_path' LIKE 'images/%'", "SELECT snapshot->>'explanation_image_path' FROM question_versions" " WHERE snapshot->>'explanation_image_path' LIKE 'images/%'", ): keys.update(row[0] for row in db.execute(sa_text(statement)).fetchall() if row[0]) # Prose can embed a figure by path; a substring search is the honest check # because the path is inside Markdown rather than in a column of its own. prose = db.execute(sa_text(""" SELECT COALESCE(content, '') || ' ' || COALESCE(sections::text, '') FROM articles UNION ALL SELECT COALESCE(front, '') || ' ' || COALESCE(back, '') FROM flashcards """)).fetchall() body = " ".join(row[0] or "" for row in prose) if PREFIX in body: for key in list(_stored_keys()): if key in body: keys.add(key) return keys def _stored_keys() -> list[str]: client = storage_service._s3() keys = [] for page in client.get_paginator("list_objects_v2").paginate( Bucket=settings.S3_BUCKET, Prefix=PREFIX): keys.extend((obj["Key"], obj["Size"]) for obj in page.get("Contents", [])) return [k for k, _ in keys], dict(keys) def main(): apply_changes = "--apply" in sys.argv db = SessionLocal() try: keys, sizes = _stored_keys() keep = _referenced(db) doomed = [k for k in keys if k not in keep] freed = sum(sizes.get(k, 0) for k in doomed) print(f" figures in storage : {len(keys)}") print(f" referenced, kept : {len(keys) - len(doomed)}") print(f" unused, to delete : {len(doomed)} ({freed / 1024 / 1024:.0f} MB)") if not apply_changes: print("\n Re-run with --apply to delete them. Only the nightly backup" "\n holds them afterwards.") return 0 client = storage_service._s3() removed = 0 for start in range(0, len(doomed), 900): batch = doomed[start:start + 900] client.delete_objects(Bucket=settings.S3_BUCKET, Delete={"Objects": [{"Key": k} for k in batch]}) ids = [row[0] for row in db.query(MediaAsset.id).filter( MediaAsset.path.in_(batch)).all()] if ids: db.query(MediaTagLink).filter(MediaTagLink.media_id.in_(ids)).delete( synchronize_session=False) db.query(MediaAsset).filter(MediaAsset.id.in_(ids)).delete( synchronize_session=False) db.commit() removed += len(batch) print(f" …{removed}/{len(doomed)}", flush=True) print(f"\n deleted {removed} figures, {freed / 1024 / 1024:.0f} MB freed.") finally: db.close() return 0 if __name__ == "__main__": sys.exit(main())