"""Surgical re-fetch of a post's external file-host links. The normal download cadence never re-walks deep back-catalogue posts (the seen-gates exist precisely to keep old items from resurfacing), so when a file that CAME from an ExternalLink is deleted — e.g. the failure-triage recovery flow removing a corrupt original — a plain source re-check will never bring it back. The link ROW is the durable, per-post handle: resetting it to pending and dispatching the fetch re-downloads exactly that link's payload, and sha-dedupe at import discards anything that still exists — so only the missing file actually lands. (Operator 2026-07-03: recovery must not require artist-wide deep scans.) """ import logging from sqlalchemy import select from sqlalchemy.orm import Session from ..models import ExternalLink log = logging.getLogger(__name__) def refetch_links_for_post(session: Session, post_id: int) -> dict: """Reset every settled ExternalLink on a post to pending (fresh attempt budget) and dispatch its fetch. In-flight ('downloading') links are left alone. Commits. Returns {links_total, links_reset}.""" links = session.execute( select(ExternalLink).where(ExternalLink.post_id == post_id) ).scalars().all() reset_ids = [] for link in links: if link.status == "downloading": continue link.status = "pending" link.attempts = 0 link.last_error = None link.completed_at = None reset_ids.append(link.id) session.commit() if reset_ids: # Lazy import (services -> tasks would cycle at module load). The # 10-min extdl sweep would pick pending rows up anyway — dispatching # directly just skips the wait. from ..tasks.external import fetch_external_link for lid in reset_ids: fetch_external_link.delay(lid) log.info( "external refetch: post %s — %d/%d link(s) reset + dispatched", post_id, len(reset_ids), len(links), ) return {"links_total": len(links), "links_reset": len(reset_ids)}