feat(recovery): surgical re-fetch for deep posts via ExternalLink reset
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user