dcfe55d731
Layer 2 — remediate a corrupt file by re-fetching a fresh copy from its source, bounded to a single attempt. Operator-requested 2026-05-28. New backend/app/services/refetch_service.py: - resolve_refetch_source: parse the failed file's sidecar → platform, derive the artist from the import path, find an ENABLED Source with a real feed URL for (artist, platform). Returns None for filesystem-only imports, missing sidecars, or `sidecar:<platform>:<slug>` synthetic anchors (not pollable). - attempt_refetch: if not already refetched AND a Source resolves, delete the corrupt file (so gallery-dl's skip_existing re-fetches it), set ImportTask.refetched=True, and trigger ONE download_source re-check. Bounded by `refetched` so source-side corruption can't loop. Wiring: - Manual endpoint POST /api/import/tasks/<id>/refetch (only on 'failed' tasks). Returns refetch_queued / no_source / already_refetched / not_found / not_failed. - Auto path in recover_interrupted_tasks: for each poison-pill row, if env FC_AUTO_REFETCH_CORRUPT=1, attempt_refetch (default OFF — the manual button is the primary path; auto is opt-in since re-fetch deletes a file + re-runs the downloader). - Frontend: a cloud-refresh icon button on failed rows in ImportTaskList → stores.import.refetchTask → toast keyed on the result status. Filesystem imports with no upstream return no_source — the operator's only remediation there is replacing the file on disk, surfaced clearly in the toast. Tests: 404 unknown task, 400 non-failed task, no_source when unresolvable, and the full resolvable-source path (file deleted, refetched flag set, one download_source dispatched, second call is a no-op). The resolvable test repoints the migration-seeded import_settings(id=1) scan path rather than inserting a conflicting row.
263 lines
9.7 KiB
Python
263 lines
9.7 KiB
Python
"""Import admin API: trigger scan, list tasks, retry, clear."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import delete, select, update
|
|
|
|
from ..extensions import get_session
|
|
from ..models import ImportBatch, ImportTask
|
|
|
|
import_admin_bp = Blueprint("import_admin", __name__, url_prefix="/api/import")
|
|
|
|
|
|
@import_admin_bp.route("/trigger", methods=["POST"])
|
|
async def trigger_scan():
|
|
body = await request.get_json(silent=True) or {}
|
|
mode = body.get("mode", "quick")
|
|
if mode not in ("quick", "deep", "verify"):
|
|
return jsonify({"error": f"mode {mode!r} not supported; use 'quick', 'deep', or 'verify'"}), 400
|
|
|
|
# 'verify' is a library task — short-circuit the import_root walk
|
|
# (no ImportBatch, no per-file ImportTasks).
|
|
if mode == "verify":
|
|
from ..tasks.maintenance import verify_integrity
|
|
|
|
async_result = verify_integrity.delay()
|
|
return jsonify({"celery_task_id": async_result.id, "mode": mode}), 202
|
|
|
|
from ..tasks.scan import scan_directory
|
|
|
|
async_result = scan_directory.delay(triggered_by="manual", mode=mode)
|
|
return jsonify({"celery_task_id": async_result.id, "mode": mode}), 202
|
|
|
|
|
|
@import_admin_bp.route("/status", methods=["GET"])
|
|
async def status():
|
|
async with get_session() as session:
|
|
active = (
|
|
await session.execute(
|
|
select(ImportBatch)
|
|
.where(ImportBatch.status == "running")
|
|
.order_by(ImportBatch.started_at.desc())
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
payload = {"active_batch": None}
|
|
if active:
|
|
payload["active_batch"] = {
|
|
"id": active.id,
|
|
"source_path": active.source_path,
|
|
"scan_mode": active.scan_mode,
|
|
"total_files": active.total_files,
|
|
"imported": active.imported,
|
|
"skipped": active.skipped,
|
|
"failed": active.failed,
|
|
"refreshed": active.refreshed,
|
|
"started_at": active.started_at.isoformat(),
|
|
}
|
|
return jsonify(payload)
|
|
|
|
|
|
@import_admin_bp.route("/tasks", methods=["GET"])
|
|
async def list_tasks():
|
|
status_filter = request.args.get("status")
|
|
try:
|
|
limit = min(int(request.args.get("limit", "50")), 200)
|
|
except ValueError:
|
|
return jsonify({"error": "limit must be an integer"}), 400
|
|
cursor_raw = request.args.get("cursor")
|
|
cursor_id = int(cursor_raw) if cursor_raw else None
|
|
|
|
async with get_session() as session:
|
|
stmt = select(ImportTask).order_by(ImportTask.created_at.desc(), ImportTask.id.desc())
|
|
if status_filter:
|
|
stmt = stmt.where(ImportTask.status == status_filter)
|
|
if cursor_id is not None:
|
|
stmt = stmt.where(ImportTask.id < cursor_id)
|
|
stmt = stmt.limit(limit + 1)
|
|
rows = (await session.execute(stmt)).scalars().all()
|
|
|
|
has_more = len(rows) > limit
|
|
rows = rows[:limit]
|
|
return jsonify({
|
|
"tasks": [
|
|
{
|
|
"id": t.id,
|
|
"batch_id": t.batch_id,
|
|
"source_path": t.source_path,
|
|
"task_type": t.task_type,
|
|
"status": t.status,
|
|
"result_image_id": t.result_image_id,
|
|
"error": t.error,
|
|
"size_bytes": t.size_bytes,
|
|
"created_at": t.created_at.isoformat(),
|
|
"started_at": t.started_at.isoformat() if t.started_at else None,
|
|
"finished_at": t.finished_at.isoformat() if t.finished_at else None,
|
|
}
|
|
for t in rows
|
|
],
|
|
"next_cursor": rows[-1].id if has_more and rows else None,
|
|
})
|
|
|
|
|
|
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
|
async def retry_failed():
|
|
# Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then-
|
|
# UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter
|
|
# ceiling once failed_ids exceeded ~65k rows.
|
|
async with get_session() as session:
|
|
result = await session.execute(
|
|
update(ImportTask)
|
|
.where(ImportTask.status == "failed")
|
|
.values(
|
|
status="queued", error=None,
|
|
started_at=None, finished_at=None,
|
|
)
|
|
.returning(ImportTask.id, ImportTask.task_type)
|
|
)
|
|
failed = result.all()
|
|
if not failed:
|
|
return jsonify({"retried": 0})
|
|
await session.commit()
|
|
|
|
from ..tasks.import_file import enqueue_import
|
|
for tid, task_type in failed:
|
|
enqueue_import(tid, task_type)
|
|
|
|
return jsonify({"retried": len(failed)})
|
|
|
|
|
|
@import_admin_bp.route("/tasks/<int:task_id>/refetch", methods=["POST"])
|
|
async def refetch_task(task_id: int):
|
|
"""Layer-2 one-shot re-download: delete the (corrupt) file behind a
|
|
failed import task and re-run its source's downloader to fetch a
|
|
fresh copy. Only works for files that resolve to an enabled,
|
|
real-URL subscription Source; filesystem-only imports return
|
|
no_source.
|
|
|
|
Returns one of: refetch_queued (+source_id) / no_source /
|
|
already_refetched / not_found / not_failed.
|
|
"""
|
|
async with get_session() as session:
|
|
result = await session.run_sync(_refetch_task_sync, task_id)
|
|
if result["status"] == "not_found":
|
|
return jsonify(result), 404
|
|
if result["status"] == "not_failed":
|
|
return jsonify(result), 400
|
|
return jsonify(result)
|
|
|
|
|
|
def _refetch_task_sync(session, task_id: int) -> dict:
|
|
from pathlib import Path
|
|
|
|
from ..models import ImportSettings
|
|
from ..services.refetch_service import attempt_refetch
|
|
|
|
task = session.get(ImportTask, task_id)
|
|
if task is None:
|
|
return {"status": "not_found"}
|
|
if task.status != "failed":
|
|
return {"status": "not_failed"}
|
|
settings = session.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
return attempt_refetch(session, task, Path(settings.import_scan_path))
|
|
|
|
|
|
@import_admin_bp.route("/clear-stuck", methods=["POST"])
|
|
async def clear_stuck():
|
|
"""Force any non-terminal ImportTask (status in pending/queued/
|
|
processing) to 'failed' AND finalize any ImportBatch that ends up
|
|
with no active children. Escape hatch for the operator when the
|
|
automatic recover_interrupted_tasks sweep keeps re-queueing the
|
|
same stuck row forever (e.g., underlying file is genuinely broken
|
|
and the import keeps OSError-looping at PIL load).
|
|
|
|
Idempotent + non-destructive: rows survive as 'failed' so the
|
|
Retry-Failed button can re-attempt them once whatever was broken
|
|
is fixed. Banked 2026-05-25 — operator hit 3 large PNGs that
|
|
autoretry-looped for 2 days after a corrupt-data PIL OSError.
|
|
"""
|
|
async with get_session() as session:
|
|
# Fold SELECT into UPDATE…WHERE — see /retry-failed for the
|
|
# 65535-parameter ceiling rationale. rowcount is enough here
|
|
# because we don't need the ids afterward (no .delay()).
|
|
clear_result = await session.execute(
|
|
update(ImportTask)
|
|
.where(
|
|
ImportTask.status.in_(["pending", "queued", "processing"])
|
|
)
|
|
.values(
|
|
status="failed",
|
|
finished_at=datetime.now(UTC),
|
|
error=(
|
|
"manually cleared via /api/import/clear-stuck "
|
|
"— stuck in non-terminal state; retry once "
|
|
"underlying cause (corrupt file, missing model, "
|
|
"etc.) is resolved"
|
|
),
|
|
)
|
|
)
|
|
tasks_failed = clear_result.rowcount or 0
|
|
|
|
# Finalize any 'running' ImportBatch that no longer has any
|
|
# active children. The "Scanning..." banner is driven by
|
|
# /api/import/status finding a running batch; left untouched,
|
|
# it would persist forever after the stuck-task clear.
|
|
running_batches = (
|
|
await session.execute(
|
|
select(ImportBatch.id).where(ImportBatch.status == "running")
|
|
)
|
|
).scalars().all()
|
|
finalized_batches = 0
|
|
for batch_id in running_batches:
|
|
still_active = (
|
|
await session.execute(
|
|
select(ImportTask.id)
|
|
.where(ImportTask.batch_id == batch_id)
|
|
.where(ImportTask.status.in_(
|
|
["pending", "queued", "processing"]
|
|
))
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
if still_active is None:
|
|
await session.execute(
|
|
update(ImportBatch)
|
|
.where(ImportBatch.id == batch_id)
|
|
.values(
|
|
status="complete",
|
|
finished_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
finalized_batches += 1
|
|
await session.commit()
|
|
|
|
return jsonify({
|
|
"tasks_failed": tasks_failed,
|
|
"batches_finalized": finalized_batches,
|
|
})
|
|
|
|
|
|
@import_admin_bp.route("/clear-completed", methods=["POST"])
|
|
async def clear_completed():
|
|
body = await request.get_json(silent=True) or {}
|
|
age_days = body.get("age_days", 0)
|
|
status_filter = body.get("status", ["complete", "skipped"])
|
|
if not isinstance(status_filter, list):
|
|
return jsonify({"error": "status must be a list"}), 400
|
|
|
|
cutoff = (
|
|
datetime.now(UTC) - timedelta(days=int(age_days)) if age_days else None
|
|
)
|
|
|
|
async with get_session() as session:
|
|
stmt = delete(ImportTask).where(ImportTask.status.in_(status_filter))
|
|
if cutoff is not None:
|
|
stmt = stmt.where(ImportTask.finished_at < cutoff)
|
|
result = await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
return jsonify({"deleted": result.rowcount or 0})
|