PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or artist left the row behind rather than taking it. Nothing ever pruned those rows, and nothing in the repo had ever unlinked a file under the attachment store — so both rows and bytes accumulated permanently, invisible to every existing diagnostic. Why a disk->DB reconciliation rather than a row sweep: the store is sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row does not free its blob, and since the artist cascade (#3066) now deletes its attachment rows outright, a freed blob has no DB pointer left to find it by. Walking the store and asking "does any row still reference this sha?" catches orphans from every cause, including ones no future delete path will think to report. Preview and apply share `_orphan_attachment_conditions` (rule 93). The dry-run derives its surviving-sha set by NEGATING that same predicate, so it is honest about blobs the delete would free rather than counting them as still-referenced — the one place this was easy to get backwards, so it has its own parity test. Guards, each with a reason: - A blob is written before its row commits, so a just-stored file legitimately has no referencing row. Files under 6h are never judged — same guard and reasoning as ORPHAN_TEMP_MIN_AGE_HOURS. - `.partial` staging files belong to cleanup_orphaned_temp_files; skipped rather than raced. - The sha is parsed as the first 64 chars, not via Path.stem: store() takes the extension from the source filename, and a URL-encoded basename yields a multi-dot suffix that would make stem eat part of the sha. - A 900s walk budget reports partial=True instead of running to the task's hard limit (rule 89). - TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare 5-min default — the #883 failure class; its invariant test is mirrored here. Defaults to the safe preview at both the task and the route, unlike the other maintenance triggers: this apply unlinks files. Operator-triggered only, never on a beat. Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates & leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so a run survives navigating away. Surfaces files_failed and partial explicitly, since both change what the numbers mean. Also promotes humanBytes to utils/bytes.js — it was byte-identical in VideoDedupCard and GatedPurgeCard and this card would have been the third copy. The three divergent `formatBytes` helpers are deliberately left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
523 lines
22 KiB
Python
523 lines
22 KiB
Python
"""FC-3k: /api/admin — destructive admin actions.
|
|
|
|
Action surfaces:
|
|
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
|
POST /api/admin/images/bulk-delete (Tier C)
|
|
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
|
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
|
POST /api/admin/tags/prune-unused (Tier A)
|
|
POST /api/admin/posts/prune-bare (Tier A)
|
|
POST /api/admin/posts/refetch-external (Tier A)
|
|
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
|
|
|
Tier-C ops take a dry_run body flag (returns projection inline,
|
|
no dispatch) and a confirm body field (server-recomputed token).
|
|
Long-running ops dispatch a maintenance-queue Celery task; the UI
|
|
tails FC-3i's /api/system/activity/runs to surface progress.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select, text
|
|
|
|
from ..extensions import get_session
|
|
from ..models import Artist, Post
|
|
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
|
|
from ._responses import error_response as _bad
|
|
|
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
|
|
|
|
|
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
|
"""Stable 8-hex token derived from the sorted id list. Mutates
|
|
when the selection changes; stays the same across modal opens of
|
|
the same selection so the operator can paste without confusion."""
|
|
canon = ",".join(str(i) for i in sorted(image_ids))
|
|
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
return digest[:8]
|
|
|
|
|
|
async def _run_dry_run_op(service_fn, **service_kwargs):
|
|
"""Shared body for the Tier-A dry-run/apply endpoints: read the `dry_run`
|
|
flag, run the cleanup_service predicate under `run_sync`, and return its
|
|
result dict. The SAME `service_fn` drives both preview and apply (the flag
|
|
just toggles), so a handler physically can't let its preview diverge from
|
|
its delete (rule 93). Default False preserves the existing contract — the UI
|
|
always passes `dry_run` explicitly (true to preview, false to apply). Extra
|
|
service kwargs (e.g. `source_id`) pass straight through."""
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", False))
|
|
async with get_session() as session:
|
|
result = await session.run_sync(
|
|
lambda sync_sess: service_fn(sync_sess, dry_run=dry_run, **service_kwargs)
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
def _queued(async_result):
|
|
"""Standard 202 for an operator-triggered maintenance task: hand the UI the
|
|
Celery task id so it can tail /maintenance/task-result (or the activity
|
|
dashboard) for the summary. (trigger_vacuum stays bespoke — the UI doesn't
|
|
poll it, so it returns no task id.)"""
|
|
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
|
|
|
|
|
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
|
async def artist_cascade_delete(slug: str):
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", False))
|
|
supplied_confirm = body.get("confirm", "")
|
|
|
|
async with get_session() as session:
|
|
artist = (await session.execute(
|
|
select(Artist).where(Artist.slug == slug)
|
|
)).scalar_one_or_none()
|
|
if artist is None:
|
|
return _bad("not_found", status=404)
|
|
artist_id = artist.id
|
|
|
|
projected = await session.run_sync(
|
|
lambda sync_sess: project_artist_cascade(sync_sess, slug=slug)
|
|
)
|
|
|
|
if dry_run:
|
|
return jsonify(projected)
|
|
|
|
expected = f"delete-artist-{artist_id}"
|
|
if supplied_confirm != expected:
|
|
return _bad(
|
|
"confirm_mismatch",
|
|
detail=f"confirm must equal {expected!r}",
|
|
expected=expected,
|
|
)
|
|
|
|
from ..tasks.admin import delete_artist_cascade_task
|
|
async_result = delete_artist_cascade_task.delay(artist_id=artist_id)
|
|
return jsonify({"task_id": async_result.id}), 202
|
|
|
|
|
|
@admin_bp.route("/images/bulk-delete", methods=["POST"])
|
|
async def images_bulk_delete():
|
|
body = await request.get_json(silent=True) or {}
|
|
image_ids = body.get("image_ids")
|
|
if not isinstance(image_ids, list) or not image_ids:
|
|
return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int")
|
|
try:
|
|
image_ids = [int(i) for i in image_ids]
|
|
except (TypeError, ValueError):
|
|
return _bad("invalid_image_ids", detail="image_ids must contain only ints")
|
|
|
|
dry_run = bool(body.get("dry_run", False))
|
|
supplied_confirm = body.get("confirm", "")
|
|
|
|
async with get_session() as session:
|
|
projected = await session.run_sync(
|
|
lambda sync_sess: project_bulk_image_delete(
|
|
sync_sess, image_ids=image_ids,
|
|
)
|
|
)
|
|
|
|
sha8 = _bulk_image_confirm_token(image_ids)
|
|
expected = f"delete-images-{sha8}"
|
|
|
|
if dry_run:
|
|
# Hand the canonical Tier-C confirm token back with the
|
|
# projection so the frontend doesn't have to recompute SHA-256
|
|
# client-side via crypto.subtle (Secure-Context-gated,
|
|
# undefined on plain-HTTP origins per the homelab posture).
|
|
# Operator-flagged 2026-05-27.
|
|
projected["confirm_token"] = expected
|
|
return jsonify(projected)
|
|
|
|
|
|
if supplied_confirm != expected:
|
|
return _bad(
|
|
"confirm_mismatch",
|
|
detail=f"confirm must equal {expected!r}",
|
|
expected=expected,
|
|
)
|
|
|
|
from ..tasks.admin import bulk_delete_images_task
|
|
async_result = bulk_delete_images_task.delay(image_ids=image_ids)
|
|
return jsonify({"task_id": async_result.id}), 202
|
|
|
|
|
|
@admin_bp.route("/tags/<int:tag_id>", methods=["DELETE"])
|
|
async def tag_delete(tag_id: int):
|
|
"""Tier-B sync delete. UI yes/no modal is the only confirmation."""
|
|
from ..services.cleanup_service import delete_tag
|
|
|
|
async with get_session() as session:
|
|
try:
|
|
result = await session.run_sync(
|
|
lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id)
|
|
)
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
except ValueError as exc:
|
|
# System tags (#128) — the training-hygiene machinery keys on
|
|
# these rows.
|
|
return _bad("system_tag", detail=str(exc))
|
|
return jsonify(result)
|
|
|
|
|
|
@admin_bp.route("/tags/<int:dest_id>/merge", methods=["POST"])
|
|
async def tag_merge(dest_id: int):
|
|
"""Wraps TagService.merge. Source repoints to dest, dest survives,
|
|
source row deleted, protective alias auto-created if source was
|
|
ML-applied or allowlisted."""
|
|
from ..services.tag_service import TagMergeConflict, TagService, TagValidationError
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
source_id = body.get("source_id")
|
|
if not isinstance(source_id, int) or source_id == dest_id:
|
|
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
|
|
|
|
# dry_run: non-mutating preview (counts + sample) so the operator can
|
|
# confirm the target before the irreversible merge (#8, rule 93 parity).
|
|
if body.get("dry_run"):
|
|
async with get_session() as session:
|
|
try:
|
|
p = await TagService(session).merge_preview(
|
|
source_id=source_id, target_id=dest_id,
|
|
)
|
|
except TagValidationError as exc:
|
|
return _bad("tag_not_found", status=404, detail=str(exc))
|
|
return jsonify({
|
|
"preview": {
|
|
"source_id": p.source_id, "source_name": p.source_name,
|
|
"target_id": p.target_id, "target_name": p.target_name,
|
|
"compatible": p.compatible,
|
|
"images_moving": p.images_moving,
|
|
"images_already_on_target": p.images_already_on_target,
|
|
"source_total": p.source_total,
|
|
"series_pages": p.series_pages,
|
|
"will_alias": p.will_alias,
|
|
"sample_thumbnails": p.sample_thumbnails,
|
|
},
|
|
})
|
|
|
|
async with get_session() as session:
|
|
try:
|
|
result = await TagService(session).merge(
|
|
source_id=source_id, target_id=dest_id,
|
|
)
|
|
except TagMergeConflict as exc:
|
|
return _bad("merge_conflict", status=409, detail=str(exc))
|
|
except TagValidationError as exc:
|
|
return _bad("tag_kind_mismatch", detail=str(exc))
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
|
|
# MergeResult is a frozen dataclass — flatten to dict.
|
|
return jsonify({
|
|
"result": {
|
|
"target_id": result.target_id,
|
|
"target_name": result.target_name,
|
|
"target_kind": result.target_kind,
|
|
"merged_count": result.merged_count,
|
|
"alias_created": result.alias_created,
|
|
"source_deleted": result.source_deleted,
|
|
},
|
|
})
|
|
|
|
|
|
@admin_bp.route("/tags/<int:tag_id>/usage-count", methods=["GET"])
|
|
async def tag_usage_count(tag_id: int):
|
|
"""Helper for the Tier-B yes/no prompt; surfaces "N associations"
|
|
in the dialog so the operator knows what they're nuking."""
|
|
from ..services.cleanup_service import count_tag_associations
|
|
|
|
async with get_session() as session:
|
|
count = await session.run_sync(
|
|
lambda sync_sess: count_tag_associations(
|
|
sync_sess, tag_id=tag_id,
|
|
)
|
|
)
|
|
return jsonify({"count": count})
|
|
|
|
|
|
@admin_bp.route("/tags/prune-unused", methods=["POST"])
|
|
async def tags_prune_unused():
|
|
"""Tier-A: dry-run preview list IS the prompt. UI calls with
|
|
dry_run=true first, shows the list, operator clicks button to
|
|
re-call with dry_run=false."""
|
|
from ..services.cleanup_service import prune_unused_tags
|
|
|
|
return await _run_dry_run_op(prune_unused_tags)
|
|
|
|
|
|
@admin_bp.route("/posts/prune-bare", methods=["POST"])
|
|
async def posts_prune_bare():
|
|
"""Tier-A: delete bare posts — Post rows with no linked images (primary OR
|
|
provenance) and no attachments. Dry-run preview list IS the prompt: UI calls
|
|
with dry_run=true first, shows the count + sample, operator confirms by
|
|
re-calling with dry_run=false. Same preview/apply-parity predicate as the
|
|
prune itself, so the preview can't diverge from the delete."""
|
|
from ..services.cleanup_service import prune_bare_posts
|
|
|
|
return await _run_dry_run_op(prune_bare_posts)
|
|
|
|
|
|
@admin_bp.route("/posts/reconcile-duplicates", methods=["POST"])
|
|
async def posts_reconcile_duplicates():
|
|
"""Tier-A: unify duplicate post rows for the same real post — the gallery-dl
|
|
(attachment-id) + native (post-id) duplicates — onto ONE post-id-keyed keeper,
|
|
moving image/provenance/attachment/link rows over. Images are untouched.
|
|
dry_run=true returns {groups, posts_to_merge, sample}; dry_run=false applies
|
|
and returns {groups, merged, sample}. Optional source_id scopes to one source.
|
|
Same find_duplicate_post_groups predicate drives preview + apply (rule 93)."""
|
|
from ..services.cleanup_service import reconcile_duplicate_posts
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
raw_source = body.get("source_id")
|
|
try:
|
|
source_id = int(raw_source) if raw_source is not None else None
|
|
except (TypeError, ValueError):
|
|
return _bad("invalid_source_id", detail="source_id must be an integer")
|
|
|
|
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
|
|
|
|
|
|
@admin_bp.route("/posts/refetch-external", methods=["POST"])
|
|
async def posts_refetch_external():
|
|
"""Surgical re-fetch of a post's external file-host links (operator
|
|
2026-07-03): the normal cadence never re-walks deep back-catalogue posts,
|
|
so a deleted external file only comes back by resetting its ExternalLink
|
|
row(s) — this endpoint does that per post and dispatches the fetches.
|
|
Sha-dedupe discards payload files that still exist, so only what's
|
|
missing lands. Body: {external_post_id: str, source_id?: int (to
|
|
disambiguate the same external id across sources)}."""
|
|
from ..services.external_links import refetch_links_for_post
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
ext_id = str(body.get("external_post_id") or "").strip()
|
|
if not ext_id:
|
|
return _bad("missing_external_post_id",
|
|
detail="external_post_id is required")
|
|
raw_source = body.get("source_id")
|
|
try:
|
|
source_id = int(raw_source) if raw_source is not None else None
|
|
except (TypeError, ValueError):
|
|
return _bad("invalid_source_id", detail="source_id must be an integer")
|
|
|
|
async with get_session() as session:
|
|
stmt = select(Post.id).where(Post.external_post_id == ext_id)
|
|
if source_id is not None:
|
|
stmt = stmt.where(Post.source_id == source_id)
|
|
post_ids = (await session.execute(stmt)).scalars().all()
|
|
if not post_ids:
|
|
return _bad("post_not_found", status=404,
|
|
detail=f"no post with external_post_id {ext_id!r}")
|
|
results = {}
|
|
for pid in post_ids:
|
|
results[str(pid)] = await session.run_sync(
|
|
lambda s, p=pid: refetch_links_for_post(s, p)
|
|
)
|
|
return jsonify({"posts": results})
|
|
|
|
|
|
def _reset_content_confirm_token(projection: dict) -> str:
|
|
"""Stable 8-hex token derived from the live counts (mirrors the Tier-C
|
|
bulk-delete token): it changes whenever the data changes, so the apply can
|
|
only ever run against numbers the operator just previewed."""
|
|
canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
|
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
@admin_bp.route("/tags/reset-content", methods=["POST"])
|
|
async def tags_reset_content():
|
|
"""Full-instance reset of the CONTENT vocabulary: deletes ALL general +
|
|
character tags and their image applications — INCLUDING the examples the
|
|
tagging heads learned from. Suggestions do NOT repopulate on their own
|
|
(the Camie predictions that once did are long retired): the operator
|
|
re-tags from scratch and the heads retrain from the new signal. fandom +
|
|
series tags + series_page ordering are preserved.
|
|
|
|
Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02:
|
|
the full reset stays, but behind extra steps): dry_run returns the
|
|
projection + a `confirm` token derived from the live counts; the apply
|
|
must echo that token back or it is rejected."""
|
|
from ..services.cleanup_service import reset_content_tagging
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", False))
|
|
async with get_session() as session:
|
|
projection = await session.run_sync(
|
|
lambda s: reset_content_tagging(s, dry_run=True)
|
|
)
|
|
token = _reset_content_confirm_token(projection)
|
|
if dry_run:
|
|
projection["confirm"] = token
|
|
return jsonify(projection)
|
|
if str(body.get("confirm", "")) != token:
|
|
return _bad(
|
|
"confirm_mismatch",
|
|
detail="run a fresh preview and echo its confirm token",
|
|
)
|
|
result = await session.run_sync(
|
|
lambda s: reset_content_tagging(s, dry_run=False)
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@admin_bp.route("/tags/normalize", methods=["POST"])
|
|
async def tags_normalize():
|
|
"""#714: retro-normalize existing tags to the #701 canonical form (Title
|
|
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
|
|
|
|
dry_run=true (default) returns a projection inline — group/collision/rename
|
|
counts + a sample of the changes — so the UI shows exactly what'll happen.
|
|
dry_run=false dispatches the long-running maintenance task (the merge FK
|
|
repoints can touch many tags); the UI tails the activity dashboard for the
|
|
summary. Idempotent; back up first (the merges are irreversible)."""
|
|
from ..services.tag_service import normalize_existing_tags
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
|
|
if dry_run:
|
|
async with get_session() as session:
|
|
result = await normalize_existing_tags(session, dry_run=True)
|
|
return jsonify(result)
|
|
|
|
from ..tasks.admin import normalize_tags_task
|
|
|
|
async_result = normalize_tags_task.delay()
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
|
async def db_stats():
|
|
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
|
so the operator can see when a VACUUM is worth running."""
|
|
from ..tasks.maintenance import VACUUM_TABLES
|
|
|
|
wanted = set(VACUUM_TABLES)
|
|
async with get_session() as session:
|
|
rows = (await session.execute(text(
|
|
"SELECT relname, n_live_tup, n_dead_tup, last_vacuum, "
|
|
"last_autovacuum, last_analyze FROM pg_stat_user_tables"
|
|
))).all()
|
|
|
|
def _iso(v):
|
|
return v.isoformat() if v is not None else None
|
|
|
|
out = []
|
|
for r in rows:
|
|
if r.relname not in wanted:
|
|
continue
|
|
live = r.n_live_tup or 0
|
|
dead = r.n_dead_tup or 0
|
|
total = live + dead
|
|
out.append({
|
|
"table": r.relname,
|
|
"live": live,
|
|
"dead": dead,
|
|
"dead_pct": round(100 * dead / total, 1) if total else 0.0,
|
|
"last_vacuum": _iso(r.last_vacuum),
|
|
"last_autovacuum": _iso(r.last_autovacuum),
|
|
"last_analyze": _iso(r.last_analyze),
|
|
})
|
|
out.sort(key=lambda t: t["dead"], reverse=True)
|
|
return jsonify({"tables": out})
|
|
|
|
|
|
@admin_bp.route("/maintenance/vacuum", methods=["POST"])
|
|
async def trigger_vacuum():
|
|
"""Operator-triggered VACUUM (ANALYZE) over the high-churn tables — the
|
|
same maintenance-queue task the weekly Beat schedule runs."""
|
|
from ..tasks.maintenance import vacuum_analyze
|
|
|
|
vacuum_analyze.delay()
|
|
return jsonify({"status": "queued"}), 202
|
|
|
|
|
|
@admin_bp.route("/maintenance/reextract-archives", methods=["POST"])
|
|
async def trigger_reextract_archives():
|
|
"""Operator-triggered re-extract (#713): PostAttachments that are actually
|
|
archives but were filed opaquely (pre magic-byte gate) get extracted and
|
|
their members linked to the post. Idempotent; runs on the maintenance queue."""
|
|
from ..tasks.admin import reextract_archive_attachments_task
|
|
|
|
async_result = reextract_archive_attachments_task.delay()
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/prune-missing-files", methods=["POST"])
|
|
async def trigger_prune_missing_files():
|
|
"""Operator-triggered orphan repair (#859): delete ImageRecords whose backing
|
|
file is gone from disk (e.g. left by the external-attach unlink bug), so they
|
|
stop 404-ing on playback. The task aborts WITHOUT deleting if a large fraction
|
|
of files look missing (a filesystem/NFS stall). Maintenance queue;
|
|
operator-triggered only — never an unattended sweep."""
|
|
from ..tasks.admin import prune_missing_file_records_task
|
|
|
|
async_result = prune_missing_file_records_task.delay()
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/reclaim-attachments", methods=["POST"])
|
|
async def trigger_reclaim_attachments():
|
|
"""Reclaim orphaned attachments (#3068). Body {"dry_run": bool}: dry_run
|
|
(the DEFAULT here) projects the orphan rows and unreferenced store blobs
|
|
without touching either; dry_run=false deletes the rows then unlinks every
|
|
blob no surviving row references. Maintenance queue; operator-triggered
|
|
only — never an unattended sweep, since the apply unlinks files. Returns the
|
|
Celery task id — poll /maintenance/task-result/<id> for the summary."""
|
|
from ..tasks.admin import reclaim_orphaned_attachments_task
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
|
async_result = reclaim_orphaned_attachments_task.delay(dry_run=dry_run)
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
|
|
async def trigger_dedup_videos():
|
|
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
|
|
what would be removed (groups / redundant count / reclaimable bytes) WITHOUT
|
|
deleting; dry_run=false applies it (re-link posts to the keeper, then delete
|
|
the redundant copies). Either way it first re-probes NULL-duration videos so
|
|
the existing library participates. Returns the Celery task id — poll
|
|
/maintenance/task-result/<id> for the summary."""
|
|
from ..tasks.admin import dedup_videos_task
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
|
async_result = dedup_videos_task.delay(dry_run=dry_run)
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/purge-gated-previews", methods=["POST"])
|
|
async def trigger_purge_gated_previews():
|
|
"""Cleanup (#874 follow-up). Body {"dry_run": bool}: dry_run=true previews how
|
|
many blurred locked-preview images (grabbed from tier-gated Patreon posts
|
|
before the fix) would be removed WITHOUT deleting; dry_run=false applies it.
|
|
Re-walks every enabled Patreon source read-only and matches by content hash, so
|
|
real content downloaded when access existed is provably spared. Returns the
|
|
Celery task id — poll /maintenance/task-result/<id> for the summary."""
|
|
from ..tasks.admin import purge_gated_previews_task
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
|
async_result = purge_gated_previews_task.delay(dry_run=dry_run)
|
|
return _queued(async_result)
|
|
|
|
|
|
@admin_bp.route("/maintenance/task-result/<task_id>", methods=["GET"])
|
|
async def maintenance_task_result(task_id: str):
|
|
"""Poll a maintenance Celery task's result (the summary dict it returns).
|
|
Used by the video-dedup card to show the dry-run projection before apply."""
|
|
from ..celery_app import celery
|
|
|
|
res = celery.AsyncResult(task_id)
|
|
ready = res.ready()
|
|
return jsonify({
|
|
"ready": ready,
|
|
"successful": res.successful() if ready else None,
|
|
"result": res.result if (ready and res.successful()) else None,
|
|
})
|