"""Download history API endpoints.""" from quart import Blueprint, request, jsonify, current_app from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.base import utcnow from app.models.download import Download, DownloadStatus from app.models.source import Source from app.tasks.downloads import process_download bp = Blueprint("downloads", __name__) @bp.route("", methods=["GET"]) async def list_downloads(): """List download history with filtering and pagination.""" # Query parameters source_id = request.args.get("source_id", type=int) status = request.args.get("status") from_date = request.args.get("from_date") to_date = request.args.get("to_date") page = int(request.args.get("page", 1)) per_page = min(int(request.args.get("per_page", 50)), 100) async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Build query query = select(Download) count_query = select(func.count()).select_from(Download) filters = [] if source_id: filters.append(Download.source_id == source_id) if status: filters.append(Download.status == status) if from_date: filters.append(Download.created_at >= datetime.fromisoformat(from_date)) if to_date: filters.append(Download.created_at <= datetime.fromisoformat(to_date)) if filters: query = query.where(and_(*filters)) count_query = count_query.where(and_(*filters)) # Get total count total_result = await session.execute(count_query) total = total_result.scalar() # Apply pagination and ordering query = query.order_by(Download.created_at.desc()) query = query.offset((page - 1) * per_page).limit(per_page) result = await session.execute(query) downloads = result.scalars().all() return jsonify({ "items": [d.to_dict() for d in downloads], "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page, }) @bp.route("/", methods=["GET"]) async def get_download(download_id: int): """Get download details.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute(select(Download).where(Download.id == download_id)) download = result.scalar() if not download: return jsonify({"error": "Download not found"}), 404 # Include source info if available response = download.to_dict() if download.source_id: source_result = await session.execute( select(Source) .options(selectinload(Source.subscription)) .where(Source.id == download.source_id) ) source = source_result.scalar() if source: response["source"] = { "id": source.id, "subscription_name": source.subscription.name if source.subscription else None, "platform": source.platform, } return jsonify(response) @bp.route("//retry", methods=["POST"]) async def retry_download(download_id: int): """Retry a failed download.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute(select(Download).where(Download.id == download_id)) download = result.scalar() if not download: return jsonify({"error": "Download not found"}), 404 if download.status != DownloadStatus.FAILED: return jsonify({"error": "Can only retry failed downloads"}), 400 # Reset status to queued download.status = DownloadStatus.QUEUED download.error_type = None download.error_message = None await session.commit() # Queue Celery task to retry the download task = process_download.delay(download_id) current_app.logger.info(f"Queued retry for download: {download_id}") return jsonify({ "message": "Retry queued", "download_id": download_id, "task_id": task.id, }), 202 @bp.route("/recent-activity", methods=["GET"]) async def recent_activity(): """Get recent noteworthy downloads: failures and completions with new files. These are downloads that warrant user attention, not routine "no new content" runs. Results are limited to the last 7 days by default. """ limit = min(int(request.args.get("limit", 10)), 50) days = int(request.args.get("days", 7)) from datetime import timedelta cutoff = utcnow() - timedelta(days=days) async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Get failed downloads OR completed with files (noteworthy activity) from sqlalchemy import or_ query = select(Download).where( and_( Download.created_at >= cutoff, or_( Download.status == DownloadStatus.FAILED, and_( Download.status == DownloadStatus.COMPLETED, Download.file_count > 0 ) ) ) ).order_by(Download.created_at.desc()).limit(limit) result = await session.execute(query) downloads = result.scalars().all() return jsonify({ "items": [d.to_dict() for d in downloads], "count": len(downloads), }) @bp.route("/reset-orphaned", methods=["POST"]) async def reset_orphaned_downloads(): """Reset orphaned running downloads back to queued. Jobs can get stuck in 'running' status if the worker crashes. This endpoint resets them so they can be retried. """ from app.tasks.maintenance import reset_orphaned_jobs # Allow specifying threshold, default to 0 to reset ALL running jobs data = await request.get_json() or {} threshold_minutes = data.get("threshold_minutes", 0) # 0 = reset all running try: if threshold_minutes == 0: # Reset all running jobs immediately (synchronous for immediate feedback) from app.tasks.maintenance import _reset_all_running_jobs_async import asyncio # Run in the current event loop result = await _reset_all_running_jobs_async() current_app.logger.info(f"Manual reset of running jobs: {result}") return jsonify({ "message": f"Reset {result['reset_count']} orphaned running jobs", "reset_count": result['reset_count'], }) else: # Queue the task for threshold-based reset task = reset_orphaned_jobs.delay(threshold_minutes) return jsonify({ "message": "Orphaned job reset queued", "task_id": task.id, "threshold_minutes": threshold_minutes, }), 202 except Exception as e: current_app.logger.error(f"Failed to reset orphaned jobs: {e}") return jsonify({"error": str(e)}), 500 @bp.route("/requeue-stale", methods=["POST"]) async def requeue_stale_downloads(): """Re-queue Celery tasks for downloads stuck in 'queued' status. Downloads can get stuck in 'queued' database status if the Celery task was lost (e.g., sent to wrong Redis DB, Redis restart, etc.). This endpoint re-sends the Celery tasks for those jobs. """ from app.tasks.maintenance import requeue_stale_jobs, _requeue_all_queued_jobs_async data = await request.get_json() or {} threshold_minutes = data.get("threshold_minutes", 0) # 0 = re-queue ALL queued jobs try: if threshold_minutes == 0: # Re-queue all queued jobs immediately (synchronous for immediate feedback) result = await _requeue_all_queued_jobs_async() current_app.logger.info(f"Manual re-queue of all queued jobs: {result}") return jsonify({ "message": f"Re-queued {result['requeued_count']} queued jobs", "requeued_count": result['requeued_count'], }) else: # Queue the task for threshold-based re-queue task = requeue_stale_jobs.delay(threshold_minutes) return jsonify({ "message": "Stale job re-queue queued", "task_id": task.id, "threshold_minutes": threshold_minutes, }), 202 except Exception as e: current_app.logger.error(f"Failed to re-queue stale jobs: {e}") return jsonify({"error": str(e)}), 500 @bp.route("/stats", methods=["GET"]) async def get_stats(): """Get download statistics.""" period = request.args.get("period", "week") # day, week, month async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Total counts by status status_query = select( Download.status, func.count(Download.id) ).group_by(Download.status) status_result = await session.execute(status_query) status_counts = {row[0]: row[1] for row in status_result} # Counts by platform (through source) platform_query = select( Source.platform, func.count(Download.id) ).join(Source, Download.source_id == Source.id).group_by(Source.platform) platform_result = await session.execute(platform_query) platform_counts = {row[0]: row[1] for row in platform_result} # Recent downloads count recent_query = select(func.count()).select_from(Download).where( Download.status == DownloadStatus.COMPLETED ) recent_result = await session.execute(recent_query) recent_completed = recent_result.scalar() return jsonify({ "total": sum(status_counts.values()), "by_status": status_counts, "by_platform": platform_counts, "completed": status_counts.get(DownloadStatus.COMPLETED, 0), "failed": status_counts.get(DownloadStatus.FAILED, 0), "pending": status_counts.get(DownloadStatus.PENDING, 0), "queued": status_counts.get(DownloadStatus.QUEUED, 0), "running": status_counts.get(DownloadStatus.RUNNING, 0), })