"""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 batch = running batch that still has outstanding work. # Plain "most recent running" picks freshly-created scans that # enqueued zero new files and hides the older batch that's # actually being processed. Mirrors the EXISTS predicate # /api/system/stats already uses (api/settings.py:145-160). # Audit 2026-06-02 — /api/import/status and /api/system/stats # used to disagree on the active-batch predicate; the UI banner # said "Scanning…" indefinitely while the stats card said idle. active = ( await session.execute( select(ImportBatch) .where( ImportBatch.status == "running", select(ImportTask.id) .where( ImportTask.batch_id == ImportBatch.id, ImportTask.status.in_(["pending", "queued", "processing"]), ) .exists(), ) .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//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 = ImportSettings.load_sync(session) 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})