from __future__ import annotations import asyncio import time from typing import Annotated, Any, Dict, Optional, Tuple from urllib.parse import urlparse import httpx from fastapi import ( APIRouter, File, Form, HTTPException, UploadFile, ) from fastapi.responses import JSONResponse from pydantic import ValidationError from app.config import get_settings from app.core.logger import get_logger from app.models.schemas import ( ImageConversionParams, MediaConversionData, MediaConversionResponse, MediaFormatsResponse, MediaImageFormat, MediaImageUrlRequest, MediaPDFUrlRequest, PDFConversionParams, ) from app.services.media_conversion_service import ( MediaConversionError, _IMAGE_OUTPUT_FORMATS, _PDF_OUTPUT_FORMATS, media_conversion_service, ) from app.services.media_storage_service import MediaStorageError router = APIRouter() _logger = get_logger(__name__) _settings = get_settings() _MAX_UPLOAD_BYTES = _settings.max_upload_bytes # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _ok_response(start: float, data: MediaConversionData) -> MediaConversionResponse: return MediaConversionResponse( success=True, time_ms=round((time.perf_counter() - start) * 1000, 3), data=data, ) def _error_response(start: float, exc: Exception) -> JSONResponse: if isinstance(exc, MediaConversionError): code, message = exc.status_code, exc.message elif isinstance(exc, MediaStorageError): code, message = exc.status_code, exc.message else: code, message = 500, f"Media conversion failed: {exc}" _logger.error("media_conversion_error status=%s error=%s", code, message) return JSONResponse( status_code=code, content={ "success": False, "message": message, "time_ms": round((time.perf_counter() - start) * 1000, 3), }, ) async def _convert_with_timeout(coro) -> Any: try: return await asyncio.wait_for( coro, timeout=_settings.media_conversion_timeout_seconds ) except asyncio.TimeoutError as exc: raise MediaConversionError( f"Conversion timed out after {_settings.media_conversion_timeout_seconds}s.", status_code=504, ) from exc def _build_model(model_cls, values: Dict[str, Any]) -> Any: try: return model_cls.model_validate(values) except ValidationError as exc: first = exc.errors()[0] raise MediaConversionError( f"Invalid conversion parameters: {first.get('msg', str(exc))}", status_code=422, ) from exc def _read_upload(file: UploadFile) -> bytes: if file is None or not file.filename: raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."}) raw = file.file.read() if not raw: raise HTTPException(status_code=422, detail={"success": False, "message": "Uploaded file is empty."}) if len(raw) > _MAX_UPLOAD_BYTES: raise HTTPException( status_code=413, detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."}, ) return raw async def _read_stream_limited(resp, limit: int) -> bytes: """Read a streaming response, aborting with a 413 once it exceeds ``limit`` bytes.""" chunks: list[bytes] = [] total = 0 async for chunk in resp.aiter_bytes(): total += len(chunk) if total > limit: raise MediaConversionError( f"Remote file exceeds {_settings.max_upload_mb} MB limit.", status_code=413 ) chunks.append(chunk) return b"".join(chunks) async def _fetch_url(url: str) -> Tuple[bytes, str]: """Verify the URL is downloadable, then download it within the upload size cap.""" parsed = urlparse(url) filename = parsed.path.split("/")[-1] or "media" async with httpx.AsyncClient( timeout=30.0, follow_redirects=True, headers={"User-Agent": "agentdeck-media-convert/1.0"}, ) as client: # 1) Pre-flight: confirm the URL responds before pulling the body. declared_length: Optional[int] = None try: head_resp = await client.head(url) if head_resp.status_code in (405, 501): # HEAD not supported -> validate via GET declared_length = None else: head_resp.raise_for_status() content_length = head_resp.headers.get("content-length") if content_length and content_length.isdigit(): declared_length = int(content_length) except httpx.HTTPError as exc: status = exc.response.status_code if exc.response else "network error" raise MediaConversionError( f"URL is not downloadable (HTTP {status}).", status_code=400, ) from exc # 2) Reject oversized files up front using the declared Content-Length. if declared_length is not None and declared_length > _MAX_UPLOAD_BYTES: raise MediaConversionError( f"Remote file is {declared_length} bytes, exceeding the " f"{_settings.max_upload_mb} MB limit.", status_code=413, ) # 3) Stream the download with an enforced byte cap (guards against # servers that omit or misreport Content-Length). try: async with client.stream("GET", url) as resp: resp.raise_for_status() data = await _read_stream_limited(resp, _MAX_UPLOAD_BYTES) except httpx.HTTPError as exc: raise MediaConversionError( f"Failed to download URL: {exc}", status_code=400 ) from exc if not data: raise MediaConversionError("Remote file is empty.", status_code=422) return data, filename # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.post( "/media-convert/pdf", response_model=MediaConversionResponse, summary="Convert an uploaded PDF to images", ) async def convert_pdf_file( file: Annotated[UploadFile, File(description="PDF file to convert")], format: Annotated[MediaImageFormat, Form(description="Output format (PNG/JPEG/WEBP)")] = MediaImageFormat.PNG, dpi: int = Form(150, description="Render DPI (72-300)"), quality: int = Form(85, description="Output quality (1-100)"), pages: Optional[str] = Form(None, description="Page spec: '1', '1-3', '1,3,5-7'"), grayscale: bool = Form(False), transparent_bg: bool = Form(False), split_page: bool = Form(False), ): start = time.perf_counter() raw = _read_upload(file) try: params = _build_model(PDFConversionParams, { "format": format, "dpi": dpi, "quality": quality, "pages": pages, "grayscale": grayscale, "transparent_bg": transparent_bg, "split_page": split_page, }) data = await _convert_with_timeout( media_conversion_service.convert_pdf(raw, params, file.filename or "upload.pdf") ) except Exception as exc: return _error_response(start, exc) return _ok_response(start, data) @router.post( "/media-convert/pdf/url", response_model=MediaConversionResponse, summary="Convert a PDF from a URL to images", ) async def convert_pdf_url(body: MediaPDFUrlRequest): start = time.perf_counter() try: raw, filename = await _fetch_url(body.url) data = await _convert_with_timeout( media_conversion_service.convert_pdf(raw, body.params, body.url) ) except Exception as exc: return _error_response(start, exc) return _ok_response(start, data) @router.post( "/media-convert/image", response_model=MediaConversionResponse, summary="Convert an uploaded image to another image format", ) async def convert_image_file( file: Annotated[UploadFile, File(description="Image file to convert")], format: Annotated[MediaImageFormat, Form(description="Target format")] = MediaImageFormat.JPEG, quality: int = Form(85, description="Output quality (1-100)"), grayscale: bool = Form(False), width: Optional[int] = Form(None, description="Resize width"), height: Optional[int] = Form(None, description="Resize height"), rotate: float = Form(0.0, description="Rotate clockwise in degrees"), flip: Optional[str] = Form(None, description="horizontal or vertical"), ): start = time.perf_counter() raw = _read_upload(file) try: params = _build_model(ImageConversionParams, { "format": format, "quality": quality, "grayscale": grayscale, "width": width, "height": height, "rotate": rotate, "flip": flip, }) data = await _convert_with_timeout( media_conversion_service.convert_image(raw, file.filename or "image", params) ) except Exception as exc: return _error_response(start, exc) return _ok_response(start, data) @router.post( "/media-convert/image/url", response_model=MediaConversionResponse, summary="Convert an image from a URL to another image format", ) async def convert_image_url(body: MediaImageUrlRequest): start = time.perf_counter() try: raw, filename = await _fetch_url(body.url) data = await _convert_with_timeout( media_conversion_service.convert_image(raw, body.url, body.params) ) except Exception as exc: return _error_response(start, exc) return _ok_response(start, data) @router.get( "/media-convert/formats", response_model=MediaFormatsResponse, summary="List supported media conversions", ) async def list_formats(): return MediaFormatsResponse( success=True, pdf_to_image=sorted(_PDF_OUTPUT_FORMATS), image_to_image=sorted(_IMAGE_OUTPUT_FORMATS), storage_enabled=_settings.supabase_upload_enabled, signed_url_ttl_seconds=_settings.supabase_signed_url_ttl_seconds, )