7c19ad91ed
Autoscaler (agent 2026-07-02.5): the buffer-occupancy signal alone would peg downloaders at DL_MAX while the bandwidth CAP — not concurrency — is the real constraint (8 streams sharing 8 MB/s move no more data than 4). Growth is now gated on the pipe having headroom (net < 85% of cap) and a pipe pinned at the cap (>= 95%) sheds streams down to 3; dead band prevents flapping. The UI hint says 'holding at the bandwidth cap' and /status reports bw_capped, so the behavior is legible without tests that need the ML stack. Reset content tagging: stays a FULL-instance reset (operator's call), but now lives in a fenced 'Danger zone' section on Cleanup and the apply is gated by a preview-derived confirm token (mirrors the Tier-C bulk-delete pattern — stale counts are rejected server-side). Copy no longer claims suggestions repopulate: it says plainly the heads' training examples are deleted and re-tagging starts fresh. Moved out of TagMaintenanceCard into DangerZoneCard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
464 lines
19 KiB
Python
464 lines
19 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)
|
|
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
|
|
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)
|
|
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)
|
|
|
|
|
|
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/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,
|
|
})
|