llm-ready-data / app /api /v1 /media_convert.py
validops-east-1's picture
whatever
5f78436
Raw
History Blame Contribute Delete
10.3 kB
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,
)