"""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.source import Source from app.models.subscription import Subscription 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", 3600 ) 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 # Queue Celery task for immediate download task = download_source.delay(source_id) current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}") return jsonify({ "message": "Check queued", "source_id": source_id, "subscription_name": source.subscription.name, "platform": source.platform, "task_id": task.id, }), 202