"""FC-3c: GET /api/downloads (list) + GET /api/downloads/ (detail). List view: keyset-paginated, newest first, optional filtering by status/source/artist. Returns slim records. Detail view: full DownloadEvent including the metadata JSONB. """ from datetime import UTC, datetime, timedelta from quart import Blueprint, jsonify, request from sqlalchemy import func, select from ..extensions import get_session from ..models import Artist, DownloadEvent, Source downloads_bp = Blueprint("downloads", __name__, url_prefix="/api/downloads") _ALLOWED_STATUSES = frozenset({"pending", "running", "ok", "error", "skipped"}) def _summary_from_metadata(metadata: dict | None) -> dict: metadata = metadata or {} run_stats = metadata.get("run_stats") or {} return { "downloaded": int(run_stats.get("downloaded_count", 0)), "skipped": int(run_stats.get("skipped_count", 0)), "quarantined": int(run_stats.get("quarantined_count", 0)), "duration_seconds": metadata.get("duration_seconds"), "error_type": metadata.get("error_type"), } def _list_record(event: DownloadEvent, source: Source | None, artist: Artist | None) -> dict: return { "id": event.id, "source_id": event.source_id, "artist_name": artist.name if artist else None, "artist_slug": artist.slug if artist else None, "platform": source.platform if source else None, "status": event.status, "started_at": event.started_at.isoformat() if event.started_at else None, "finished_at": event.finished_at.isoformat() if event.finished_at else None, "files_count": event.files_count, "bytes_downloaded": event.bytes_downloaded, "error": event.error, "summary": _summary_from_metadata(event.metadata_), # plan #709: mid-walk live counts for a RUNNING native-ingester event # (None otherwise; phase 3 overwrites metadata with run_stats on finish). "live": (event.metadata_ or {}).get("live"), } def _detail_record(event: DownloadEvent, source: Source | None, artist: Artist | None) -> dict: base = _list_record(event, source, artist) base["metadata"] = event.metadata_ or {} return base @downloads_bp.route("", methods=["GET"]) async def list_downloads(): status = request.args.get("status") if status and status not in _ALLOWED_STATUSES: return jsonify({"error": "invalid_status"}), 400 try: limit = int(request.args.get("limit", "50")) except ValueError: return jsonify({"error": "invalid_limit"}), 400 limit = max(1, min(200, limit)) before_raw = request.args.get("before") before = None if before_raw is not None: try: before = int(before_raw) except ValueError: return jsonify({"error": "invalid_before"}), 400 try: source_id = int(request.args["source_id"]) if "source_id" in request.args else None artist_id = int(request.args["artist_id"]) if "artist_id" in request.args else None except (ValueError, KeyError): return jsonify({"error": "invalid_filter"}), 400 async with get_session() as session: stmt = ( select(DownloadEvent, Source, Artist) .outerjoin(Source, Source.id == DownloadEvent.source_id) .outerjoin(Artist, Artist.id == Source.artist_id) .order_by(DownloadEvent.id.desc()) .limit(limit) ) if status: stmt = stmt.where(DownloadEvent.status == status) if source_id is not None: stmt = stmt.where(DownloadEvent.source_id == source_id) if artist_id is not None: stmt = stmt.where(Source.artist_id == artist_id) if before is not None: stmt = stmt.where(DownloadEvent.id < before) rows = (await session.execute(stmt)).all() return jsonify([_list_record(e, s, a) for e, s, a in rows]) @downloads_bp.route("/stats", methods=["GET"]) async def downloads_stats(): """Status-grouped count over download_event for the dashboard stat chips. `?window_hours=` (default 24) bounds by `started_at`. The full set of statuses is always present in the response (zero for missing) so the UI doesn't have to fill in defaults. """ try: window_hours = int(request.args.get("window_hours", "24")) except ValueError: return jsonify({"error": "invalid_window_hours"}), 400 if window_hours < 1 or window_hours > 24 * 365: return jsonify({"error": "invalid_window_hours"}), 400 since = datetime.now(UTC) - timedelta(hours=window_hours) out = {"pending": 0, "running": 0, "ok": 0, "error": 0, "skipped": 0} async with get_session() as session: stmt = ( select(DownloadEvent.status, func.count()) .where(DownloadEvent.started_at >= since) .group_by(DownloadEvent.status) ) for status, n in (await session.execute(stmt)).all(): if status in out: out[status] = int(n) return jsonify(out) @downloads_bp.route("/activity", methods=["GET"]) async def downloads_activity(): """Hourly download-event counts over the last `?hours=` (default 24). Returns a fixed-length, oldest-first bucket array so the UI can render a sparkline directly. Bucketing is done in Python against UTC to dodge session-timezone ambiguity in SQL date_trunc. """ try: hours = int(request.args.get("hours", "24")) except ValueError: return jsonify({"error": "invalid_hours"}), 400 hours = max(1, min(168, hours)) now = datetime.now(UTC) end = now.replace(minute=0, second=0, microsecond=0) start = end - timedelta(hours=hours - 1) buckets = [ {"hour": (start + timedelta(hours=i)).isoformat(), "ok": 0, "error": 0, "other": 0, "total": 0} for i in range(hours) ] async with get_session() as session: rows = (await session.execute( select(DownloadEvent.started_at, DownloadEvent.status) .where(DownloadEvent.started_at >= start) )).all() for started_at, status in rows: if started_at is None: continue sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC) idx = int((sa - start).total_seconds() // 3600) if not (0 <= idx < hours): continue b = buckets[idx] if status == "ok": b["ok"] += 1 elif status == "error": b["error"] += 1 else: b["other"] += 1 b["total"] += 1 return jsonify({"hours": hours, "buckets": buckets}) @downloads_bp.route("/", methods=["GET"]) async def get_download(event_id: int): async with get_session() as session: row = (await session.execute( select(DownloadEvent, Source, Artist) .outerjoin(Source, Source.id == DownloadEvent.source_id) .outerjoin(Artist, Artist.id == Source.artist_id) .where(DownloadEvent.id == event_id) )).first() if row is None: return jsonify({"error": "not_found"}), 404 event, source, artist = row return jsonify(_detail_record(event, source, artist)) @downloads_bp.route("/recover-stalled", methods=["POST"]) async def recover_stalled(): """Trigger the recover_stalled_download_events sweep on demand. The same sweep runs every 5 min via Beat (see celery_app.beat_schedule); this endpoint exists so the operator can force-clear stuck pending/ running download_events from the Subscriptions → Downloads maintenance menu without waiting for the next scheduled tick. """ # Local import: avoids registering maintenance tasks during blueprint # import (Celery task discovery races with the API import otherwise). from ..tasks.maintenance import recover_stalled_download_events recover_stalled_download_events.delay() return jsonify({"queued": True}), 202