"""Download history API endpoints.""" from datetime import datetime 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.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 pending download.status = DownloadStatus.PENDING 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 = datetime.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("/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), })