"""Layer-2 one-shot re-download remediation for corrupt imported files. When an import fails on a file that came from a known, pollable subscription Source, deleting the bad copy and re-running the source's downloader can fetch a fresh, unblemished copy. This only helps when: - the corruption is in transit / on disk (not at the source), AND - the file resolves to an ENABLED Source with a real feed URL (a `sidecar::` synthetic anchor is not pollable), AND - we haven't already re-fetched this task once (bounded by ImportTask.refetched so source-side corruption can't loop). Filesystem-only imports with no resolvable Source return 'no_source' — the operator's only remediation there is to replace the file on disk. Operator-requested 2026-05-28 (Layer 2). """ import json import logging from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import Session from ..models import Artist, ImportTask, Source from ..utils.paths import derive_top_level_artist from ..utils.sidecar import find_sidecar, parse_sidecar from ..utils.slug import slugify log = logging.getLogger(__name__) def resolve_refetch_source( session: Session, source_path: str, import_root: Path, ) -> Source | None: """Find an enabled, real-URL Source for the file's (artist, platform), or None when nothing re-pollable resolves.""" path = Path(source_path) sc = find_sidecar(path) if sc is None: return None try: data = json.loads(sc.read_text("utf-8")) except (OSError, json.JSONDecodeError): return None if not isinstance(data, dict): return None sd = parse_sidecar(data) if not sd.platform: return None artist_name = derive_top_level_artist(path, import_root) if not artist_name: return None artist = session.execute( select(Artist).where(Artist.slug == slugify(artist_name)) ).scalar_one_or_none() if artist is None: return None src = session.execute( select(Source) .where( Source.artist_id == artist.id, Source.platform == sd.platform, Source.enabled.is_(True), ) .order_by(Source.id.asc()) ).scalars().first() if src is None: return None if (src.url or "").startswith("sidecar:"): return None # synthetic anchor — not a pollable feed return src def attempt_refetch( session: Session, task: ImportTask, import_root: Path, ) -> dict: """Delete the corrupt file, mark the task refetched, and trigger ONE source re-check. Idempotent/bounded: a task already refetched (or with no resolvable Source) is a no-op. Commits.""" if task.refetched: return {"status": "already_refetched"} src = resolve_refetch_source(session, task.source_path, import_root) if src is None: return {"status": "no_source"} # Remove the bad copy so gallery-dl (skip_existing) re-fetches it on # the source re-check instead of skipping the still-present corrupt # file. try: Path(task.source_path).unlink(missing_ok=True) except OSError as exc: log.warning("refetch unlink failed for %s: %s", task.source_path, exc) task.refetched = True session.add(task) session.commit() # Lazy import to avoid a tasks→services→tasks import cycle at module # load. download_source.delay() is sync-safe in any context. from ..tasks.download import download_source download_source.delay(src.id) return {"status": "refetch_queued", "source_id": src.id}