"""Source management API endpoints.""" from quart import Blueprint, request, jsonify, current_app from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.base import utcnow from app.models.source import Source from app.models.subscription import Subscription from app.models.download import Download, DownloadStatus from app.tasks.downloads import download_source from app.api.settings import get_setting_value bp = Blueprint("sources", __name__) @bp.route("", methods=["GET"]) async def list_sources(): """List all sources with optional filtering and pagination.""" # Query parameters platform = request.args.get("platform") subscription_id = request.args.get("subscription_id") enabled = request.args.get("enabled") 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 with subscription eager loading query = select(Source).options(selectinload(Source.subscription)) if platform: query = query.where(Source.platform == platform) if subscription_id: query = query.where(Source.subscription_id == int(subscription_id)) if enabled is not None: query = query.where(Source.enabled == (enabled.lower() == "true")) # Get total count count_query = select(func.count()).select_from(Source) if platform: count_query = count_query.where(Source.platform == platform) if subscription_id: count_query = count_query.where(Source.subscription_id == int(subscription_id)) if enabled is not None: count_query = count_query.where(Source.enabled == (enabled.lower() == "true")) total_result = await session.execute(count_query) total = total_result.scalar() # Apply pagination - order by subscription name, then platform query = query.join(Subscription).order_by(Subscription.name, Source.platform) query = query.offset((page - 1) * per_page).limit(per_page) result = await session.execute(query) sources = result.scalars().all() return jsonify({ "items": [s.to_dict() for s in sources], "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page if total > 0 else 0, }) @bp.route("", methods=["POST"]) async def create_source(): """Create a new source for a subscription.""" data = await request.get_json() # Validate required fields required = ["subscription_id", "platform", "url"] missing = [f for f in required if not data.get(f)] if missing: return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 # Validate platform valid_platforms = ["patreon", "subscribestar", "hentaifoundry", "discord"] if data["platform"] not in valid_platforms: return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400 async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) # Check subscription exists sub_result = await session.execute( select(Subscription).where(Subscription.id == data["subscription_id"]) ) subscription = sub_result.scalar() if not subscription: return jsonify({"error": "Subscription not found"}), 404 # Check for duplicate platform+url existing = await session.execute( select(Source).where( Source.platform == data["platform"], Source.url == data["url"] ) ) if existing.scalar(): return jsonify({"error": "Source with this platform and URL already exists"}), 409 # Get default check interval from database settings default_interval = await get_setting_value( session, "download.schedule_interval", 28800 ) source = Source( subscription_id=data["subscription_id"], platform=data["platform"], url=data["url"], enabled=data.get("enabled", True), check_interval=data.get("check_interval", default_interval), metadata_=data.get("metadata", {}), ) session.add(source) await session.commit() await session.refresh(source) current_app.logger.info(f"Created source: {subscription.name}/{source.platform}") return jsonify(source.to_dict()), 201 @bp.route("/", methods=["GET"]) async def get_source(source_id: int): """Get a single source by ID.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute( select(Source) .options(selectinload(Source.subscription)) .where(Source.id == source_id) ) source = result.scalar() if not source: return jsonify({"error": "Source not found"}), 404 return jsonify(source.to_dict()) @bp.route("/", methods=["PATCH"]) async def update_source(source_id: int): """Update a source.""" data = await request.get_json() async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute( select(Source) .options(selectinload(Source.subscription)) .where(Source.id == source_id) ) source = result.scalar() if not source: return jsonify({"error": "Source not found"}), 404 # Update allowed fields allowed_fields = ["enabled", "check_interval", "metadata"] for field in allowed_fields: if field in data: if field == "metadata": source.metadata_ = data[field] else: setattr(source, field, data[field]) await session.commit() await session.refresh(source) current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}") return jsonify(source.to_dict()) @bp.route("/", methods=["DELETE"]) async def delete_source(source_id: int): """Delete a source.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute( select(Source) .options(selectinload(Source.subscription)) .where(Source.id == source_id) ) source = result.scalar() if not source: return jsonify({"error": "Source not found"}), 404 info = f"{source.subscription.name}/{source.platform}" await session.delete(source) await session.commit() current_app.logger.info(f"Deleted source: {info}") return "", 204 @bp.route("//check", methods=["POST"]) async def trigger_check(source_id: int): """Trigger an immediate download check for a source.""" async with current_app.db_engine.connect() as conn: session = AsyncSession(bind=conn) result = await session.execute( select(Source) .options(selectinload(Source.subscription)) .where(Source.id == source_id) ) source = result.scalar() if not source: return jsonify({"error": "Source not found"}), 404 # Check for existing queued or running download for this source existing = await session.execute( select(Download).where( Download.source_id == source.id, Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING]) ) ) if existing.scalar(): return jsonify({"error": "A download is already queued or running for this source"}), 409 # Create download record in QUEUED state for visibility download = Download( source_id=source.id, url=source.url, status=DownloadStatus.QUEUED, ) session.add(download) await session.commit() await session.refresh(download) # Queue Celery task for immediate download task = download_source.delay(source_id, download_id=download.id) current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}") return jsonify({ "message": "Check queued", "source_id": source_id, "download_id": download.id, "subscription_name": source.subscription.name, "platform": source.platform, "task_id": task.id, }), 202