feat(recovery): surgical re-fetch for deep posts via ExternalLink reset
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 47s
CI / backend-lint-and-test (push) Successful in 2m0s
CI / integration (push) Successful in 4m50s

Operator-flagged: the recovered defective files live DEEP in their artists'
back-catalogues — the normal download cadence (by design, via the seen-gates)
will never re-walk them, so recovery's source re-check alone can't bring them
back. The durable per-post handle is the ExternalLink row, which survives the
image delete:

- services/external_links.refetch_links_for_post: reset settled links to
  pending (fresh attempt budget, in-flight left alone) + dispatch their
  fetches; sha-dedupe at import discards payload files that still exist, so
  only the missing file lands.
- recover_defective_image now captures the image's post ids BEFORE the delete
  cascades provenance away and resets those posts' links — future recoveries
  are surgical automatically (response gains links_reset; source re-check
  stays for gallery-dl-native files within walk reach).
- POST /api/admin/posts/refetch-external {external_post_id, source_id?} — the
  manual tool for the three files recovered before this fix existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 21:07:21 -04:00
parent b1cfbcc06a
commit aa12a57f97
5 changed files with 213 additions and 11 deletions
+40 -1
View File
@@ -7,6 +7,7 @@ Action surfaces:
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,
@@ -22,7 +23,7 @@ from quart import Blueprint, jsonify, request
from sqlalchemy import select, text
from ..extensions import get_session
from ..models import Artist
from ..models import Artist, Post
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
from ._responses import error_response as _bad
@@ -276,6 +277,44 @@ async def posts_reconcile_duplicates():
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