Spaces:
Running
Running
Commit ·
8f9855d
1
Parent(s): fe8cd16
feat: m2m api
Browse files- app/api/server.py +13 -0
- app/api/v1/media_convert.py +310 -0
- app/api/v1/qr_decoder.py +4 -2
- app/api/v1/router.py +2 -0
- app/config.py +18 -0
- app/models/schemas.py +185 -0
- app/services/media_conversion_service.py +596 -0
- app/services/media_storage_service.py +181 -0
app/api/server.py
CHANGED
|
@@ -96,6 +96,16 @@ async def lifespan(app: FastAPI):
|
|
| 96 |
|
| 97 |
asyncio.create_task(_self_ping())
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
await _scheduler_service.start()
|
| 100 |
_logger.info("Scheduler service started")
|
| 101 |
|
|
@@ -111,6 +121,8 @@ async def lifespan(app: FastAPI):
|
|
| 111 |
await close_maps_service()
|
| 112 |
from app.api.v1.gcs import close_gcs_service
|
| 113 |
await close_gcs_service()
|
|
|
|
|
|
|
| 114 |
from app.services.supabase import get_supabase_client
|
| 115 |
client = get_supabase_client()
|
| 116 |
if client:
|
|
@@ -133,6 +145,7 @@ def create_application() -> FastAPI:
|
|
| 133 |
{"name": "Verify", "description": "Phone number and identity verification"},
|
| 134 |
{"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
|
| 135 |
{"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
|
|
|
|
| 136 |
],
|
| 137 |
lifespan=lifespan,
|
| 138 |
)
|
|
|
|
| 96 |
|
| 97 |
asyncio.create_task(_self_ping())
|
| 98 |
|
| 99 |
+
if _settings.supabase_upload_enabled:
|
| 100 |
+
try:
|
| 101 |
+
from app.services.media_storage_service import get_storage_service
|
| 102 |
+
|
| 103 |
+
storage = await get_storage_service()
|
| 104 |
+
bucket = await storage.ensure_bucket(_settings.supabase_storage_bucket)
|
| 105 |
+
_logger.info("Supabase Storage bucket ensured: %s", bucket)
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
_logger.error("Failed to ensure Supabase Storage bucket at startup: %s", exc)
|
| 108 |
+
|
| 109 |
await _scheduler_service.start()
|
| 110 |
_logger.info("Scheduler service started")
|
| 111 |
|
|
|
|
| 121 |
await close_maps_service()
|
| 122 |
from app.api.v1.gcs import close_gcs_service
|
| 123 |
await close_gcs_service()
|
| 124 |
+
from app.services.media_storage_service import close_storage_service
|
| 125 |
+
await close_storage_service()
|
| 126 |
from app.services.supabase import get_supabase_client
|
| 127 |
client = get_supabase_client()
|
| 128 |
if client:
|
|
|
|
| 145 |
{"name": "Verify", "description": "Phone number and identity verification"},
|
| 146 |
{"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
|
| 147 |
{"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
|
| 148 |
+
{"name": "Media-to-Media Conversion", "description": "PDF-to-image and image-to-image conversion with local or Supabase Storage output"},
|
| 149 |
],
|
| 150 |
lifespan=lifespan,
|
| 151 |
)
|
app/api/v1/media_convert.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Annotated, Any, Dict, Optional, Tuple
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from fastapi import (
|
| 11 |
+
APIRouter,
|
| 12 |
+
File,
|
| 13 |
+
Form,
|
| 14 |
+
HTTPException,
|
| 15 |
+
UploadFile,
|
| 16 |
+
)
|
| 17 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 18 |
+
from pydantic import ValidationError
|
| 19 |
+
|
| 20 |
+
from app.config import get_settings
|
| 21 |
+
from app.core.logger import get_logger
|
| 22 |
+
from app.models.schemas import (
|
| 23 |
+
ImageConversionParams,
|
| 24 |
+
MediaConversionData,
|
| 25 |
+
MediaConversionResponse,
|
| 26 |
+
MediaFormatsResponse,
|
| 27 |
+
MediaImageFormat,
|
| 28 |
+
MediaImageUrlRequest,
|
| 29 |
+
MediaPDFUrlRequest,
|
| 30 |
+
PDFConversionParams,
|
| 31 |
+
)
|
| 32 |
+
from app.services.media_conversion_service import (
|
| 33 |
+
MediaConversionError,
|
| 34 |
+
_IMAGE_OUTPUT_FORMATS,
|
| 35 |
+
_PDF_OUTPUT_FORMATS,
|
| 36 |
+
media_conversion_service,
|
| 37 |
+
)
|
| 38 |
+
from app.services.media_storage_service import MediaStorageError
|
| 39 |
+
|
| 40 |
+
router = APIRouter()
|
| 41 |
+
_logger = get_logger(__name__)
|
| 42 |
+
_settings = get_settings()
|
| 43 |
+
_MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 44 |
+
|
| 45 |
+
_MIME_BY_EXT = {
|
| 46 |
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
| 47 |
+
".png": "image/png", ".webp": "image/webp",
|
| 48 |
+
".bmp": "image/bmp", ".gif": "image/gif", ".tiff": "image/tiff",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
# Helpers
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
def _ok_response(start: float, data: MediaConversionData) -> MediaConversionResponse:
|
| 57 |
+
return MediaConversionResponse(
|
| 58 |
+
success=True,
|
| 59 |
+
time_ms=round((time.perf_counter() - start) * 1000, 3),
|
| 60 |
+
data=data,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _error_response(start: float, exc: Exception) -> JSONResponse:
|
| 65 |
+
if isinstance(exc, MediaConversionError):
|
| 66 |
+
code, message = exc.status_code, exc.message
|
| 67 |
+
elif isinstance(exc, MediaStorageError):
|
| 68 |
+
code, message = exc.status_code, exc.message
|
| 69 |
+
else:
|
| 70 |
+
code, message = 500, f"Media conversion failed: {exc}"
|
| 71 |
+
_logger.error("media_conversion_error status=%s error=%s", code, message)
|
| 72 |
+
return JSONResponse(
|
| 73 |
+
status_code=code,
|
| 74 |
+
content={
|
| 75 |
+
"success": False,
|
| 76 |
+
"message": message,
|
| 77 |
+
"time_ms": round((time.perf_counter() - start) * 1000, 3),
|
| 78 |
+
},
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def _convert_with_timeout(coro) -> Any:
|
| 83 |
+
try:
|
| 84 |
+
return await asyncio.wait_for(
|
| 85 |
+
coro, timeout=_settings.media_conversion_timeout_seconds
|
| 86 |
+
)
|
| 87 |
+
except asyncio.TimeoutError as exc:
|
| 88 |
+
raise MediaConversionError(
|
| 89 |
+
f"Conversion timed out after {_settings.media_conversion_timeout_seconds}s.",
|
| 90 |
+
status_code=504,
|
| 91 |
+
) from exc
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _build_model(model_cls, values: Dict[str, Any]) -> Any:
|
| 95 |
+
try:
|
| 96 |
+
return model_cls.model_validate(values)
|
| 97 |
+
except ValidationError as exc:
|
| 98 |
+
first = exc.errors()[0]
|
| 99 |
+
raise MediaConversionError(
|
| 100 |
+
f"Invalid conversion parameters: {first.get('msg', str(exc))}",
|
| 101 |
+
status_code=422,
|
| 102 |
+
) from exc
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _read_upload(file: UploadFile) -> bytes:
|
| 106 |
+
if file is None or not file.filename:
|
| 107 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
|
| 108 |
+
raw = file.file.read()
|
| 109 |
+
if not raw:
|
| 110 |
+
raise HTTPException(status_code=422, detail={"success": False, "message": "Uploaded file is empty."})
|
| 111 |
+
if len(raw) > _MAX_UPLOAD_BYTES:
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=413,
|
| 114 |
+
detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."},
|
| 115 |
+
)
|
| 116 |
+
return raw
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def _read_stream_limited(resp, limit: int) -> bytes:
|
| 120 |
+
"""Read a streaming response, aborting with a 413 once it exceeds ``limit`` bytes."""
|
| 121 |
+
chunks: list[bytes] = []
|
| 122 |
+
total = 0
|
| 123 |
+
async for chunk in resp.aiter_bytes():
|
| 124 |
+
total += len(chunk)
|
| 125 |
+
if total > limit:
|
| 126 |
+
raise MediaConversionError(
|
| 127 |
+
f"Remote file exceeds {_settings.max_upload_mb} MB limit.", status_code=413
|
| 128 |
+
)
|
| 129 |
+
chunks.append(chunk)
|
| 130 |
+
return b"".join(chunks)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def _fetch_url(url: str) -> Tuple[bytes, str]:
|
| 134 |
+
"""Verify the URL is downloadable, then download it within the upload size cap."""
|
| 135 |
+
parsed = urlparse(url)
|
| 136 |
+
filename = parsed.path.split("/")[-1] or "media"
|
| 137 |
+
async with httpx.AsyncClient(
|
| 138 |
+
timeout=30.0,
|
| 139 |
+
follow_redirects=True,
|
| 140 |
+
headers={"User-Agent": "agentdeck-media-convert/1.0"},
|
| 141 |
+
) as client:
|
| 142 |
+
# 1) Pre-flight: confirm the URL responds before pulling the body.
|
| 143 |
+
declared_length: Optional[int] = None
|
| 144 |
+
try:
|
| 145 |
+
head_resp = await client.head(url)
|
| 146 |
+
if head_resp.status_code in (405, 501): # HEAD not supported -> validate via GET
|
| 147 |
+
declared_length = None
|
| 148 |
+
else:
|
| 149 |
+
head_resp.raise_for_status()
|
| 150 |
+
content_length = head_resp.headers.get("content-length")
|
| 151 |
+
if content_length and content_length.isdigit():
|
| 152 |
+
declared_length = int(content_length)
|
| 153 |
+
except httpx.HTTPError as exc:
|
| 154 |
+
status = exc.response.status_code if exc.response else "network error"
|
| 155 |
+
raise MediaConversionError(
|
| 156 |
+
f"URL is not downloadable (HTTP {status}).",
|
| 157 |
+
status_code=400,
|
| 158 |
+
) from exc
|
| 159 |
+
|
| 160 |
+
# 2) Reject oversized files up front using the declared Content-Length.
|
| 161 |
+
if declared_length is not None and declared_length > _MAX_UPLOAD_BYTES:
|
| 162 |
+
raise MediaConversionError(
|
| 163 |
+
f"Remote file is {declared_length} bytes, exceeding the "
|
| 164 |
+
f"{_settings.max_upload_mb} MB limit.",
|
| 165 |
+
status_code=413,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# 3) Stream the download with an enforced byte cap (guards against
|
| 169 |
+
# servers that omit or misreport Content-Length).
|
| 170 |
+
try:
|
| 171 |
+
async with client.stream("GET", url) as resp:
|
| 172 |
+
resp.raise_for_status()
|
| 173 |
+
data = await _read_stream_limited(resp, _MAX_UPLOAD_BYTES)
|
| 174 |
+
except httpx.HTTPError as exc:
|
| 175 |
+
raise MediaConversionError(
|
| 176 |
+
f"Failed to download URL: {exc}", status_code=400
|
| 177 |
+
) from exc
|
| 178 |
+
|
| 179 |
+
if not data:
|
| 180 |
+
raise MediaConversionError("Remote file is empty.", status_code=422)
|
| 181 |
+
return data, filename
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# Endpoints
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
@router.post(
|
| 189 |
+
"/media-convert/pdf",
|
| 190 |
+
response_model=MediaConversionResponse,
|
| 191 |
+
summary="Convert an uploaded PDF to images",
|
| 192 |
+
)
|
| 193 |
+
async def convert_pdf_file(
|
| 194 |
+
file: Annotated[UploadFile, File(description="PDF file to convert")],
|
| 195 |
+
format: Annotated[MediaImageFormat, Form(description="Output format (PNG/JPEG/WEBP)")] = MediaImageFormat.PNG,
|
| 196 |
+
dpi: int = Form(150, description="Render DPI (72-300)"),
|
| 197 |
+
quality: int = Form(85, description="Output quality (1-100)"),
|
| 198 |
+
pages: Optional[str] = Form(None, description="Page spec: '1', '1-3', '1,3,5-7'"),
|
| 199 |
+
grayscale: bool = Form(False),
|
| 200 |
+
transparent_bg: bool = Form(False),
|
| 201 |
+
split_page: bool = Form(False),
|
| 202 |
+
):
|
| 203 |
+
start = time.perf_counter()
|
| 204 |
+
raw = _read_upload(file)
|
| 205 |
+
try:
|
| 206 |
+
params = _build_model(PDFConversionParams, {
|
| 207 |
+
"format": format, "dpi": dpi, "quality": quality, "pages": pages,
|
| 208 |
+
"grayscale": grayscale, "transparent_bg": transparent_bg, "split_page": split_page,
|
| 209 |
+
})
|
| 210 |
+
data = await _convert_with_timeout(
|
| 211 |
+
media_conversion_service.convert_pdf(raw, params, file.filename or "upload.pdf")
|
| 212 |
+
)
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
return _error_response(start, exc)
|
| 215 |
+
return _ok_response(start, data)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@router.post(
|
| 219 |
+
"/media-convert/pdf/url",
|
| 220 |
+
response_model=MediaConversionResponse,
|
| 221 |
+
summary="Convert a PDF from a URL to images",
|
| 222 |
+
)
|
| 223 |
+
async def convert_pdf_url(body: MediaPDFUrlRequest):
|
| 224 |
+
start = time.perf_counter()
|
| 225 |
+
try:
|
| 226 |
+
raw, filename = await _fetch_url(body.url)
|
| 227 |
+
data = await _convert_with_timeout(
|
| 228 |
+
media_conversion_service.convert_pdf(raw, body.params, body.url)
|
| 229 |
+
)
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
return _error_response(start, exc)
|
| 232 |
+
return _ok_response(start, data)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.post(
|
| 236 |
+
"/media-convert/image",
|
| 237 |
+
response_model=MediaConversionResponse,
|
| 238 |
+
summary="Convert an uploaded image to another image format",
|
| 239 |
+
)
|
| 240 |
+
async def convert_image_file(
|
| 241 |
+
file: Annotated[UploadFile, File(description="Image file to convert")],
|
| 242 |
+
format: Annotated[MediaImageFormat, Form(description="Target format")] = MediaImageFormat.JPEG,
|
| 243 |
+
quality: int = Form(85, description="Output quality (1-100)"),
|
| 244 |
+
grayscale: bool = Form(False),
|
| 245 |
+
width: Optional[int] = Form(None, description="Resize width"),
|
| 246 |
+
height: Optional[int] = Form(None, description="Resize height"),
|
| 247 |
+
rotate: float = Form(0.0, description="Rotate clockwise in degrees"),
|
| 248 |
+
flip: Optional[str] = Form(None, description="horizontal or vertical"),
|
| 249 |
+
):
|
| 250 |
+
start = time.perf_counter()
|
| 251 |
+
raw = _read_upload(file)
|
| 252 |
+
try:
|
| 253 |
+
params = _build_model(ImageConversionParams, {
|
| 254 |
+
"format": format, "quality": quality, "grayscale": grayscale,
|
| 255 |
+
"width": width, "height": height, "rotate": rotate, "flip": flip,
|
| 256 |
+
})
|
| 257 |
+
data = await _convert_with_timeout(
|
| 258 |
+
media_conversion_service.convert_image(raw, file.filename or "image", params)
|
| 259 |
+
)
|
| 260 |
+
except Exception as exc:
|
| 261 |
+
return _error_response(start, exc)
|
| 262 |
+
return _ok_response(start, data)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@router.post(
|
| 266 |
+
"/media-convert/image/url",
|
| 267 |
+
response_model=MediaConversionResponse,
|
| 268 |
+
summary="Convert an image from a URL to another image format",
|
| 269 |
+
)
|
| 270 |
+
async def convert_image_url(body: MediaImageUrlRequest):
|
| 271 |
+
start = time.perf_counter()
|
| 272 |
+
try:
|
| 273 |
+
raw, filename = await _fetch_url(body.url)
|
| 274 |
+
data = await _convert_with_timeout(
|
| 275 |
+
media_conversion_service.convert_image(raw, body.url, body.params)
|
| 276 |
+
)
|
| 277 |
+
except Exception as exc:
|
| 278 |
+
return _error_response(start, exc)
|
| 279 |
+
return _ok_response(start, data)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@router.get(
|
| 283 |
+
"/media-convert/files/{job_id}/{filename}",
|
| 284 |
+
summary="Download a converted file (local mode)",
|
| 285 |
+
responses={404: {"description": "File not found"}},
|
| 286 |
+
)
|
| 287 |
+
async def download_file(job_id: str, filename: str):
|
| 288 |
+
root = Path(_settings.media_output_dir).resolve()
|
| 289 |
+
if job_id != Path(job_id).name or filename != Path(filename).name:
|
| 290 |
+
raise HTTPException(status_code=404, detail={"success": False, "message": "File not found."})
|
| 291 |
+
path = (root / job_id / "out" / filename).resolve()
|
| 292 |
+
if not str(path).startswith(str(root)) or not path.is_file():
|
| 293 |
+
raise HTTPException(status_code=404, detail={"success": False, "message": "File not found."})
|
| 294 |
+
media_type = _MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
| 295 |
+
return FileResponse(path, media_type=media_type, filename=filename)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@router.get(
|
| 299 |
+
"/media-convert/formats",
|
| 300 |
+
response_model=MediaFormatsResponse,
|
| 301 |
+
summary="List supported media conversions",
|
| 302 |
+
)
|
| 303 |
+
async def list_formats():
|
| 304 |
+
return MediaFormatsResponse(
|
| 305 |
+
success=True,
|
| 306 |
+
pdf_to_image=sorted(_PDF_OUTPUT_FORMATS),
|
| 307 |
+
image_to_image=sorted(_IMAGE_OUTPUT_FORMATS),
|
| 308 |
+
storage_enabled=_settings.supabase_upload_enabled,
|
| 309 |
+
signed_url_ttl_seconds=_settings.supabase_signed_url_ttl_seconds,
|
| 310 |
+
)
|
app/api/v1/qr_decoder.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import List, Optional
|
|
| 6 |
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
|
|
|
| 9 |
from app.core.logger import get_logger
|
| 10 |
from app.services.qr_decoder_service import QRDecoderService
|
| 11 |
|
|
@@ -13,9 +14,10 @@ logger = get_logger(__name__)
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
_service = QRDecoderService()
|
| 19 |
|
| 20 |
|
| 21 |
class QRDecodeUrlRequest(BaseModel):
|
|
|
|
| 6 |
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
| 9 |
+
from app.config import get_settings
|
| 10 |
from app.core.logger import get_logger
|
| 11 |
from app.services.qr_decoder_service import QRDecoderService
|
| 12 |
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
| 17 |
+
_settings = get_settings()
|
| 18 |
+
MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 19 |
|
| 20 |
+
_service = QRDecoderService(max_file_size_mb=_settings.max_upload_mb)
|
| 21 |
|
| 22 |
|
| 23 |
class QRDecodeUrlRequest(BaseModel):
|
app/api/v1/router.py
CHANGED
|
@@ -16,6 +16,7 @@ from app.api.v1 import (
|
|
| 16 |
google_oauth,
|
| 17 |
json_extract,
|
| 18 |
keys_extract,
|
|
|
|
| 19 |
qr_decoder,
|
| 20 |
qr_generator,
|
| 21 |
reconcile,
|
|
@@ -57,6 +58,7 @@ api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
|
|
| 57 |
api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
|
| 58 |
api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
|
| 59 |
api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
|
|
|
|
| 60 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 61 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
| 62 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
|
|
|
| 16 |
google_oauth,
|
| 17 |
json_extract,
|
| 18 |
keys_extract,
|
| 19 |
+
media_convert,
|
| 20 |
qr_decoder,
|
| 21 |
qr_generator,
|
| 22 |
reconcile,
|
|
|
|
| 58 |
api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
|
| 59 |
api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
|
| 60 |
api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
|
| 61 |
+
api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
|
| 62 |
api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
|
| 63 |
api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
|
| 64 |
api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
|
app/config.py
CHANGED
|
@@ -124,6 +124,24 @@ class Settings(BaseSettings):
|
|
| 124 |
google_oauth_state_ttl_minutes: int = 10
|
| 125 |
google_oauth_jwks_ttl_seconds: int = 3600
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
# Scheduler settings
|
| 128 |
max_http_timeout: float = 300.0
|
| 129 |
default_scheduler_timezone: str = "UTC"
|
|
|
|
| 124 |
google_oauth_state_ttl_minutes: int = 10
|
| 125 |
google_oauth_jwks_ttl_seconds: int = 3600
|
| 126 |
|
| 127 |
+
# Media-to-Media conversion settings
|
| 128 |
+
media_output_dir: str = "./data/media-convert"
|
| 129 |
+
media_max_workers: int = 4
|
| 130 |
+
media_default_dpi: int = 150
|
| 131 |
+
media_max_dpi: int = 300
|
| 132 |
+
media_default_quality: int = 85
|
| 133 |
+
media_max_pages: int = 200
|
| 134 |
+
media_max_memory_mb: int = 512
|
| 135 |
+
media_max_image_pixels: int = 50_000_000
|
| 136 |
+
media_upload_concurrency: int = 4
|
| 137 |
+
media_signed_url_ttl_seconds: int = 86400
|
| 138 |
+
media_conversion_timeout_seconds: int = 300
|
| 139 |
+
|
| 140 |
+
# Supabase Storage upload for converted media
|
| 141 |
+
supabase_upload_enabled: bool = Field(default=False, alias="SUPABASE_UPLOAD_ENABLED")
|
| 142 |
+
supabase_storage_bucket: str = Field(default="media-convert", alias="SUPABASE_STORAGE_BUCKET")
|
| 143 |
+
supabase_signed_url_ttl_seconds: int = Field(default=86400, alias="SUPABASE_SIGNED_URL_TTL_SECONDS")
|
| 144 |
+
|
| 145 |
# Scheduler settings
|
| 146 |
max_http_timeout: float = 300.0
|
| 147 |
default_scheduler_timezone: str = "UTC"
|
app/models/schemas.py
CHANGED
|
@@ -1449,3 +1449,188 @@ class GCSURLResponse(BaseModel):
|
|
| 1449 |
method: Optional[str] = Field(None, description="HTTP method the signed URL authorizes")
|
| 1450 |
expires_in_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds")
|
| 1451 |
error: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
method: Optional[str] = Field(None, description="HTTP method the signed URL authorizes")
|
| 1450 |
expires_in_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds")
|
| 1451 |
error: Optional[str] = None
|
| 1452 |
+
|
| 1453 |
+
|
| 1454 |
+
# ---------------------------------------------------------------------------
|
| 1455 |
+
# Media-to-Media conversion
|
| 1456 |
+
# ---------------------------------------------------------------------------
|
| 1457 |
+
|
| 1458 |
+
class MediaImageFormat(str, Enum):
|
| 1459 |
+
"""Output image formats supported by the media converter."""
|
| 1460 |
+
|
| 1461 |
+
JPEG = "JPEG"
|
| 1462 |
+
JPG = "JPEG"
|
| 1463 |
+
PNG = "PNG"
|
| 1464 |
+
WEBP = "WEBP"
|
| 1465 |
+
BMP = "BMP"
|
| 1466 |
+
GIF = "GIF"
|
| 1467 |
+
TIFF = "TIFF"
|
| 1468 |
+
|
| 1469 |
+
|
| 1470 |
+
class PDFConversionParams(BaseModel):
|
| 1471 |
+
"""Options for PDF -> image conversion."""
|
| 1472 |
+
|
| 1473 |
+
format: MediaImageFormat = Field(MediaImageFormat.PNG, description="Output image format")
|
| 1474 |
+
dpi: int = Field(150, ge=72, le=300, description="Render DPI (72-300)")
|
| 1475 |
+
quality: int = Field(85, ge=1, le=100, description="Output quality (1-100, JPEG/WEBP/TIFF)")
|
| 1476 |
+
pages: Optional[str] = Field(None, description="Page spec: '1', '1-3', '1,3,5-7'. Empty = all pages.")
|
| 1477 |
+
grayscale: bool = Field(False, description="Convert output to grayscale")
|
| 1478 |
+
transparent_bg: bool = Field(False, description="Preserve transparency (PNG only)")
|
| 1479 |
+
split_page: bool = Field(False, description="True: each page is a separate image. False: stitch into one tall image.")
|
| 1480 |
+
|
| 1481 |
+
@field_validator("format", mode="before")
|
| 1482 |
+
@classmethod
|
| 1483 |
+
def normalise_format(cls, v):
|
| 1484 |
+
if isinstance(v, str):
|
| 1485 |
+
upper = v.upper()
|
| 1486 |
+
if upper == "JPG":
|
| 1487 |
+
return "JPEG"
|
| 1488 |
+
return upper
|
| 1489 |
+
return v
|
| 1490 |
+
|
| 1491 |
+
@field_validator("pages")
|
| 1492 |
+
@classmethod
|
| 1493 |
+
def validate_page_spec(cls, v):
|
| 1494 |
+
if v is None or str(v).strip() == "":
|
| 1495 |
+
return None
|
| 1496 |
+
spec = str(v)
|
| 1497 |
+
if not re.match(r"^\s*(\d+(-\d+)?)(\s*,\s*(\d+(-\d+)?))*\s*$", spec):
|
| 1498 |
+
raise ValueError(
|
| 1499 |
+
f"Invalid page spec '{v}'. Use comma-separated numbers or ranges, "
|
| 1500 |
+
"e.g. '1', '1-3', '1,3,5-7'."
|
| 1501 |
+
)
|
| 1502 |
+
for token in spec.split(","):
|
| 1503 |
+
token = token.strip()
|
| 1504 |
+
if "-" in token:
|
| 1505 |
+
start, end = (int(x) for x in token.split("-", 1))
|
| 1506 |
+
if start < 1 or end < 1:
|
| 1507 |
+
raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
|
| 1508 |
+
if start > end:
|
| 1509 |
+
raise ValueError(f"Invalid range '{token}': start must be <= end")
|
| 1510 |
+
elif int(token) < 1:
|
| 1511 |
+
raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
|
| 1512 |
+
return spec
|
| 1513 |
+
|
| 1514 |
+
@model_validator(mode="after")
|
| 1515 |
+
def validate_transparent_bg(self):
|
| 1516 |
+
if self.transparent_bg and self.format not in (MediaImageFormat.PNG, MediaImageFormat.WEBP):
|
| 1517 |
+
raise ValueError("transparent_bg is only supported for PNG or WEBP output")
|
| 1518 |
+
return self
|
| 1519 |
+
|
| 1520 |
+
|
| 1521 |
+
class ImageConversionParams(BaseModel):
|
| 1522 |
+
"""Options for image -> image conversion."""
|
| 1523 |
+
|
| 1524 |
+
format: MediaImageFormat = Field(MediaImageFormat.JPEG, description="Target output format")
|
| 1525 |
+
quality: int = Field(85, ge=1, le=100, description="Output quality (1-100, JPEG/WEBP/TIFF)")
|
| 1526 |
+
grayscale: bool = Field(False, description="Convert output to grayscale")
|
| 1527 |
+
width: Optional[int] = Field(None, ge=1, le=100000, description="Resize width (keeps aspect ratio if height omitted)")
|
| 1528 |
+
height: Optional[int] = Field(None, ge=1, le=100000, description="Resize height (keeps aspect ratio if width omitted)")
|
| 1529 |
+
rotate: float = Field(0.0, ge=0, le=360, description="Rotate clockwise in degrees")
|
| 1530 |
+
flip: Optional[Literal["horizontal", "vertical"]] = Field(None, description="Flip the image")
|
| 1531 |
+
|
| 1532 |
+
@field_validator("format", mode="before")
|
| 1533 |
+
@classmethod
|
| 1534 |
+
def normalise_format(cls, v):
|
| 1535 |
+
if isinstance(v, str):
|
| 1536 |
+
upper = v.upper()
|
| 1537 |
+
if upper == "JPG":
|
| 1538 |
+
return "JPEG"
|
| 1539 |
+
return upper
|
| 1540 |
+
return v
|
| 1541 |
+
|
| 1542 |
+
@model_validator(mode="after")
|
| 1543 |
+
def validate_resize(self):
|
| 1544 |
+
if self.width is None and self.height is None:
|
| 1545 |
+
return self
|
| 1546 |
+
if self.width is not None and self.width < 1:
|
| 1547 |
+
raise ValueError("width must be a positive integer")
|
| 1548 |
+
if self.height is not None and self.height < 1:
|
| 1549 |
+
raise ValueError("height must be a positive integer")
|
| 1550 |
+
return self
|
| 1551 |
+
|
| 1552 |
+
|
| 1553 |
+
class MediaOutputFile(BaseModel):
|
| 1554 |
+
"""A single converted media file produced by a conversion job."""
|
| 1555 |
+
|
| 1556 |
+
filename: str = Field(..., description="Output file name")
|
| 1557 |
+
page_number: Optional[int] = Field(None, description="Source page number for PDF conversions (1-indexed)")
|
| 1558 |
+
width: int = Field(0, description="Pixel width")
|
| 1559 |
+
height: int = Field(0, description="Pixel height")
|
| 1560 |
+
size_bytes: int = Field(0, description="File size in bytes")
|
| 1561 |
+
format: str = Field(..., description="Output format (e.g. JPEG, PNG)")
|
| 1562 |
+
content_type: str = Field("application/octet-stream", description="MIME type of the output")
|
| 1563 |
+
url: str = Field(..., description="Download URL (Supabase signed URL or local endpoint)")
|
| 1564 |
+
base64: Optional[str] = Field(None, description="Base64-encoded file bytes (local mode only)")
|
| 1565 |
+
|
| 1566 |
+
|
| 1567 |
+
class MediaUploadSummary(BaseModel):
|
| 1568 |
+
"""How the converted files are exposed to the caller."""
|
| 1569 |
+
|
| 1570 |
+
mode: Literal["storage", "local"] = Field("local", description="'storage' = Supabase Storage, 'local' = served from this service")
|
| 1571 |
+
bucket: Optional[str] = Field(None, description="Supabase Storage bucket used (storage mode)")
|
| 1572 |
+
total_files: int = Field(0, description="Number of files uploaded / linked")
|
| 1573 |
+
failed_uploads: int = Field(0, description="Number of uploads that failed (storage mode)")
|
| 1574 |
+
url_ttl_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds (storage mode)")
|
| 1575 |
+
expires_at: Optional[str] = Field(None, description="ISO-8601 timestamp when signed URLs expire (storage mode)")
|
| 1576 |
+
|
| 1577 |
+
|
| 1578 |
+
class MediaConversionData(BaseModel):
|
| 1579 |
+
"""Structured result of a media-to-media conversion."""
|
| 1580 |
+
|
| 1581 |
+
job_id: str = Field(..., description="Unique job identifier")
|
| 1582 |
+
source: str = Field(..., description="Source file name or URL")
|
| 1583 |
+
input_format: str = Field(..., description="Detected input media format")
|
| 1584 |
+
output_format: str = Field(..., description="Primary output format")
|
| 1585 |
+
total_pages: int = Field(0, description="Source page count (PDF) or 1 (images)")
|
| 1586 |
+
converted_files: int = Field(0, description="Number of output files produced")
|
| 1587 |
+
outputs: List[MediaOutputFile] = Field(default_factory=list, description="Converted files with URLs")
|
| 1588 |
+
upload: MediaUploadSummary = Field(default_factory=MediaUploadSummary, description="Upload / exposure details")
|
| 1589 |
+
warning: Optional[str] = Field(None, description="Warnings, e.g. signed URL expiry notice")
|
| 1590 |
+
|
| 1591 |
+
|
| 1592 |
+
class MediaConversionResponse(BaseModel):
|
| 1593 |
+
"""Envelope for synchronous media conversion responses."""
|
| 1594 |
+
|
| 1595 |
+
success: bool
|
| 1596 |
+
time_ms: float = 0.0
|
| 1597 |
+
data: Optional[MediaConversionData] = None
|
| 1598 |
+
error: Optional[str] = None
|
| 1599 |
+
|
| 1600 |
+
|
| 1601 |
+
class MediaFormatsResponse(BaseModel):
|
| 1602 |
+
"""Lists the supported media-to-media conversions."""
|
| 1603 |
+
|
| 1604 |
+
success: bool
|
| 1605 |
+
pdf_to_image: List[str] = Field(default_factory=list, description="Output formats from PDF input")
|
| 1606 |
+
image_to_image: List[str] = Field(default_factory=list, description="Output formats from image input")
|
| 1607 |
+
storage_enabled: bool = Field(False, description="Whether Supabase Storage upload is enabled")
|
| 1608 |
+
signed_url_ttl_seconds: int = Field(86400, description="Signed URL lifetime when storage is enabled")
|
| 1609 |
+
|
| 1610 |
+
|
| 1611 |
+
class MediaPDFUrlRequest(BaseModel):
|
| 1612 |
+
"""Body for converting a PDF fetched from a URL."""
|
| 1613 |
+
|
| 1614 |
+
url: str = Field(..., description="Publicly reachable PDF URL")
|
| 1615 |
+
params: PDFConversionParams = Field(default_factory=PDFConversionParams)
|
| 1616 |
+
|
| 1617 |
+
@field_validator("url")
|
| 1618 |
+
@classmethod
|
| 1619 |
+
def validate_scheme(cls, v: str) -> str:
|
| 1620 |
+
if not v.startswith(("http://", "https://")):
|
| 1621 |
+
raise ValueError("Only http/https URLs are supported.")
|
| 1622 |
+
return v
|
| 1623 |
+
|
| 1624 |
+
|
| 1625 |
+
class MediaImageUrlRequest(BaseModel):
|
| 1626 |
+
"""Body for converting an image fetched from a URL."""
|
| 1627 |
+
|
| 1628 |
+
url: str = Field(..., description="Publicly reachable image URL")
|
| 1629 |
+
params: ImageConversionParams = Field(default_factory=ImageConversionParams)
|
| 1630 |
+
|
| 1631 |
+
@field_validator("url")
|
| 1632 |
+
@classmethod
|
| 1633 |
+
def validate_scheme(cls, v: str) -> str:
|
| 1634 |
+
if not v.startswith(("http://", "https://")):
|
| 1635 |
+
raise ValueError("Only http/https URLs are supported.")
|
| 1636 |
+
return v
|
app/services/media_conversion_service.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Media-to-Media conversion service.
|
| 2 |
+
|
| 3 |
+
Converts between media formats without blocking the FastAPI event loop:
|
| 4 |
+
|
| 5 |
+
* PDF -> images (JPEG / PNG / WEBP) using pypdfium2 + Pillow.
|
| 6 |
+
* Image -> image (JPEG / PNG / WEBP / BMP / GIF / TIFF) using Pillow.
|
| 7 |
+
|
| 8 |
+
All CPU-bound pixel work runs in the shared thread pool
|
| 9 |
+
(:mod:`app.core.thread_pool`), mirroring the architecture of the document
|
| 10 |
+
converter and the reference PDF-conversion service.
|
| 11 |
+
|
| 12 |
+
Output files are written to a per-job directory and then either:
|
| 13 |
+
|
| 14 |
+
* exposed through local download endpoints (``SUPABASE_UPLOAD_ENABLED=false``),
|
| 15 |
+
or
|
| 16 |
+
* uploaded to Supabase Storage with 24-hour signed URLs
|
| 17 |
+
(``SUPABASE_UPLOAD_ENABLED=true``) and a warning stating the expiry.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import base64
|
| 24 |
+
import io
|
| 25 |
+
import re
|
| 26 |
+
import time
|
| 27 |
+
import uuid
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 30 |
+
|
| 31 |
+
from app.config import get_settings
|
| 32 |
+
from app.core.logger import get_logger
|
| 33 |
+
from app.core.thread_pool import thread_pool as _thread_pool
|
| 34 |
+
from app.models.schemas import (
|
| 35 |
+
ImageConversionParams,
|
| 36 |
+
MediaConversionData,
|
| 37 |
+
MediaOutputFile,
|
| 38 |
+
MediaUploadSummary,
|
| 39 |
+
PDFConversionParams,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_logger = get_logger(__name__)
|
| 43 |
+
_settings = get_settings()
|
| 44 |
+
|
| 45 |
+
_PAGE_SPEC_RE = re.compile(r"^\s*(\d+(-\d+)?)(\s*,\s*(\d+(-\d+)?))*\s*$")
|
| 46 |
+
|
| 47 |
+
_PDF_OUTPUT_FORMATS = frozenset({"JPEG", "PNG", "WEBP"})
|
| 48 |
+
_IMAGE_OUTPUT_FORMATS = frozenset({"JPEG", "PNG", "WEBP", "BMP", "GIF", "TIFF"})
|
| 49 |
+
|
| 50 |
+
_EXT_BY_FORMAT: Dict[str, str] = {
|
| 51 |
+
"JPEG": "jpg", "PNG": "png", "WEBP": "webp",
|
| 52 |
+
"BMP": "bmp", "GIF": "gif", "TIFF": "tiff",
|
| 53 |
+
}
|
| 54 |
+
_MIME_BY_FORMAT: Dict[str, str] = {
|
| 55 |
+
"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp",
|
| 56 |
+
"BMP": "image/bmp", "GIF": "image/gif", "TIFF": "image/tiff",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class MediaConversionError(Exception):
|
| 61 |
+
"""Raised for invalid input or failed conversions, mapped to HTTP errors."""
|
| 62 |
+
|
| 63 |
+
def __init__(self, message: str, status_code: int = 400) -> None:
|
| 64 |
+
super().__init__(message)
|
| 65 |
+
self.message = message
|
| 66 |
+
self.status_code = status_code
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _fmt_str(fmt) -> str:
|
| 70 |
+
return fmt.value if hasattr(fmt, "value") else str(fmt)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _ext_for(fmt: str) -> str:
|
| 74 |
+
return _EXT_BY_FORMAT.get(fmt, "bin")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _save_kwargs(fmt: str, quality: int) -> Dict[str, Any]:
|
| 78 |
+
if fmt == "JPEG":
|
| 79 |
+
return {"quality": quality, "optimize": True}
|
| 80 |
+
if fmt == "PNG":
|
| 81 |
+
return {"optimize": True}
|
| 82 |
+
if fmt == "WEBP":
|
| 83 |
+
return {"quality": quality, "method": 4}
|
| 84 |
+
if fmt == "TIFF":
|
| 85 |
+
return {"compression": "tiff_lzw"}
|
| 86 |
+
return {}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _normalise_for_jpeg(img):
|
| 90 |
+
"""Return an RGB image suitable for JPEG, flattening alpha onto white."""
|
| 91 |
+
from PIL import Image
|
| 92 |
+
|
| 93 |
+
mode = img.mode
|
| 94 |
+
if mode == "RGB":
|
| 95 |
+
return img
|
| 96 |
+
if mode in ("RGBA", "LA", "P"):
|
| 97 |
+
if mode == "P":
|
| 98 |
+
img = img.convert("RGBA")
|
| 99 |
+
bg = Image.new("RGB", img.size, (255, 255, 255))
|
| 100 |
+
mask = img.split()[-1] if mode in ("RGBA", "LA") else None
|
| 101 |
+
bg.paste(img, mask=mask)
|
| 102 |
+
return bg
|
| 103 |
+
return img.convert("RGB")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _normalise_for_png(img):
|
| 107 |
+
"""Return an image whose mode PIL accepts for PNG / WEBP."""
|
| 108 |
+
if img.mode in ("RGB", "RGBA", "L", "LA"):
|
| 109 |
+
return img
|
| 110 |
+
if img.mode == "P":
|
| 111 |
+
return img.convert("RGBA")
|
| 112 |
+
return img.convert("RGB")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _parse_pages(spec: Optional[str], total_pages: int) -> List[int]:
|
| 116 |
+
"""Expand a page spec ('1', '1-3', '1,3,5-7') into 0-indexed page indices."""
|
| 117 |
+
if not spec or not spec.strip():
|
| 118 |
+
return list(range(total_pages))
|
| 119 |
+
indices: set[int] = set()
|
| 120 |
+
for token in str(spec).split(","):
|
| 121 |
+
token = token.strip()
|
| 122 |
+
if "-" in token:
|
| 123 |
+
start, end = (int(x) for x in token.split("-", 1))
|
| 124 |
+
indices.update(range(start - 1, end))
|
| 125 |
+
else:
|
| 126 |
+
indices.add(int(token) - 1)
|
| 127 |
+
valid = sorted(i for i in indices if 0 <= i < total_pages)
|
| 128 |
+
if not valid:
|
| 129 |
+
raise MediaConversionError(
|
| 130 |
+
f"Page spec '{spec}' contains no pages within the document's {total_pages} page(s).",
|
| 131 |
+
status_code=422,
|
| 132 |
+
)
|
| 133 |
+
return valid
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _guard_memory(page_count: int, dpi: int) -> None:
|
| 137 |
+
bytes_per_page = (dpi * 8.5) * (dpi * 11) * 3
|
| 138 |
+
estimated_mb = (bytes_per_page * page_count) / (1024 * 1024)
|
| 139 |
+
if estimated_mb > _settings.media_max_memory_mb:
|
| 140 |
+
raise MediaConversionError(
|
| 141 |
+
f"Requested conversion would need ~{estimated_mb:.0f} MB of memory "
|
| 142 |
+
f"({page_count} pages at {dpi} DPI). Reduce DPI or select fewer pages.",
|
| 143 |
+
status_code=422,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Thread-pool worker functions (must stay top-level for pickling)
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def _pdf_total_pages(data: bytes) -> int:
|
| 152 |
+
import pypdfium2 as pdfium
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
doc = pdfium.PdfDocument(data)
|
| 156 |
+
except Exception as exc:
|
| 157 |
+
raise MediaConversionError(
|
| 158 |
+
f"Input is not a valid PDF: {exc}", status_code=422
|
| 159 |
+
) from exc
|
| 160 |
+
try:
|
| 161 |
+
total = len(doc)
|
| 162 |
+
finally:
|
| 163 |
+
doc.close()
|
| 164 |
+
if total == 0:
|
| 165 |
+
raise MediaConversionError("PDF contains no pages.", status_code=422)
|
| 166 |
+
return total
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _split_pdf_pages(data: bytes, page_indices: List[int]) -> Dict[int, bytes]:
|
| 170 |
+
"""Extract each requested page into its own single-page PDF blob."""
|
| 171 |
+
import pypdfium2 as pdfium
|
| 172 |
+
|
| 173 |
+
blobs: Dict[int, bytes] = {}
|
| 174 |
+
try:
|
| 175 |
+
src = pdfium.PdfDocument(data)
|
| 176 |
+
for page_idx in page_indices:
|
| 177 |
+
dst = pdfium.PdfDocument.new()
|
| 178 |
+
try:
|
| 179 |
+
dst.import_pages(src, pages=[page_idx])
|
| 180 |
+
with io.BytesIO() as buf:
|
| 181 |
+
dst.save(buf)
|
| 182 |
+
blobs[page_idx] = buf.getvalue()
|
| 183 |
+
finally:
|
| 184 |
+
dst.close()
|
| 185 |
+
finally:
|
| 186 |
+
src.close()
|
| 187 |
+
return blobs
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _render_pdf_page(blob: bytes, params: PDFConversionParams, out_path: Path) -> Tuple[int, int, int]:
|
| 191 |
+
"""Render a single-page PDF blob to an image file. Returns (width, height, size_bytes)."""
|
| 192 |
+
from PIL import Image
|
| 193 |
+
import pypdfium2 as pdfium
|
| 194 |
+
|
| 195 |
+
fmt = _fmt_str(params.format)
|
| 196 |
+
doc = None
|
| 197 |
+
bitmap = None
|
| 198 |
+
try:
|
| 199 |
+
doc = pdfium.PdfDocument(blob)
|
| 200 |
+
scale = params.dpi / 72.0
|
| 201 |
+
bitmap = doc[0].render(scale=scale)
|
| 202 |
+
img = bitmap.to_pil()
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
raise MediaConversionError(f"Failed to render PDF page: {exc}", status_code=500) from exc
|
| 205 |
+
finally:
|
| 206 |
+
if bitmap is not None:
|
| 207 |
+
try:
|
| 208 |
+
bitmap.close()
|
| 209 |
+
except Exception:
|
| 210 |
+
pass
|
| 211 |
+
if doc is not None:
|
| 212 |
+
try:
|
| 213 |
+
doc.close()
|
| 214 |
+
except Exception:
|
| 215 |
+
pass
|
| 216 |
+
|
| 217 |
+
if fmt == "JPEG":
|
| 218 |
+
img = _normalise_for_jpeg(img)
|
| 219 |
+
else:
|
| 220 |
+
if params.transparent_bg:
|
| 221 |
+
img = _normalise_for_png(img)
|
| 222 |
+
else:
|
| 223 |
+
img = _normalise_for_png(img)
|
| 224 |
+
if img.mode in ("RGBA", "LA"):
|
| 225 |
+
bg = Image.new("RGB", img.size, (255, 255, 255))
|
| 226 |
+
bg.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
|
| 227 |
+
img = bg
|
| 228 |
+
if params.grayscale:
|
| 229 |
+
img = img.convert("L")
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
img.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
|
| 233 |
+
except OSError as exc:
|
| 234 |
+
raise MediaConversionError(f"Failed to write output file '{out_path.name}': {exc}", status_code=500) from exc
|
| 235 |
+
|
| 236 |
+
stat = out_path.stat()
|
| 237 |
+
return img.width, img.height, stat.st_size
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _stitch_images(page_files: List[Tuple[int, Path]], fmt: str, out_path: Path, quality: int) -> Tuple[int, int, int]:
|
| 241 |
+
"""Stitch rendered page images vertically into one tall image."""
|
| 242 |
+
from PIL import Image
|
| 243 |
+
|
| 244 |
+
images: List[Image.Image] = []
|
| 245 |
+
for _, path in sorted(page_files, key=lambda t: t[0]):
|
| 246 |
+
img = Image.open(path)
|
| 247 |
+
if fmt == "JPEG":
|
| 248 |
+
img = _normalise_for_jpeg(img)
|
| 249 |
+
else:
|
| 250 |
+
img = _normalise_for_png(img)
|
| 251 |
+
images.append(img)
|
| 252 |
+
|
| 253 |
+
total_width = max(im.width for im in images)
|
| 254 |
+
total_height = sum(im.height for im in images)
|
| 255 |
+
mode = images[0].mode
|
| 256 |
+
fill = (255, 255, 255) if mode == "RGB" else 255
|
| 257 |
+
stitched = Image.new(mode, (total_width, total_height), color=fill)
|
| 258 |
+
y_offset = 0
|
| 259 |
+
for img in images:
|
| 260 |
+
stitched.paste(img, (0, y_offset))
|
| 261 |
+
y_offset += img.height
|
| 262 |
+
|
| 263 |
+
stitched.save(str(out_path), format=fmt, **_save_kwargs(fmt, quality))
|
| 264 |
+
stat = out_path.stat()
|
| 265 |
+
return stitched.width, stitched.height, stat.st_size
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _convert_image_bytes(data: bytes, params: ImageConversionParams, out_path: Path) -> Tuple[int, int, int, str]:
|
| 269 |
+
"""Convert raw image bytes to the requested format. Returns (width, height, size_bytes, detected_format)."""
|
| 270 |
+
from PIL import Image, UnidentifiedImageError
|
| 271 |
+
|
| 272 |
+
try:
|
| 273 |
+
img = Image.open(io.BytesIO(data))
|
| 274 |
+
detected = (img.format or "UNKNOWN").upper()
|
| 275 |
+
img.load()
|
| 276 |
+
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
| 277 |
+
raise MediaConversionError(
|
| 278 |
+
f"Input is not a valid or supported image: {exc}", status_code=422
|
| 279 |
+
) from exc
|
| 280 |
+
|
| 281 |
+
if params.rotate:
|
| 282 |
+
img = img.rotate(params.rotate % 360, expand=True)
|
| 283 |
+
if params.flip == "horizontal":
|
| 284 |
+
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
| 285 |
+
elif params.flip == "vertical":
|
| 286 |
+
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
| 287 |
+
if params.grayscale:
|
| 288 |
+
img = img.convert("L")
|
| 289 |
+
|
| 290 |
+
if params.width is not None or params.height is not None:
|
| 291 |
+
orig_w, orig_h = img.size
|
| 292 |
+
if params.width is not None and params.height is not None:
|
| 293 |
+
target = (params.width, params.height)
|
| 294 |
+
elif params.width is not None:
|
| 295 |
+
ratio = params.width / orig_w
|
| 296 |
+
target = (params.width, max(1, round(orig_h * ratio)))
|
| 297 |
+
else:
|
| 298 |
+
ratio = params.height / orig_h
|
| 299 |
+
target = (max(1, round(orig_w * ratio)), params.height)
|
| 300 |
+
if target[0] * target[1] > _settings.media_max_image_pixels:
|
| 301 |
+
raise MediaConversionError(
|
| 302 |
+
f"Resized image ({target[0]}x{target[1]}) exceeds the "
|
| 303 |
+
f"{_settings.media_max_image_pixels} pixel limit.",
|
| 304 |
+
status_code=422,
|
| 305 |
+
)
|
| 306 |
+
img = img.resize(target, Image.LANCZOS)
|
| 307 |
+
|
| 308 |
+
fmt = _fmt_str(params.format)
|
| 309 |
+
if fmt == "JPEG":
|
| 310 |
+
img = _normalise_for_jpeg(img)
|
| 311 |
+
elif fmt in ("PNG", "WEBP") and img.mode not in ("RGB", "RGBA", "L", "LA"):
|
| 312 |
+
img = _normalise_for_png(img)
|
| 313 |
+
|
| 314 |
+
try:
|
| 315 |
+
img.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
|
| 316 |
+
except OSError as exc:
|
| 317 |
+
raise MediaConversionError(f"Failed to write output file '{out_path.name}': {exc}", status_code=500) from exc
|
| 318 |
+
|
| 319 |
+
stat = out_path.stat()
|
| 320 |
+
return img.width, img.height, stat.st_size, detected
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ---------------------------------------------------------------------------
|
| 324 |
+
# Service
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
|
| 327 |
+
class MediaConversionService:
|
| 328 |
+
"""Orchestrates media conversion plus local/storage exposure of results."""
|
| 329 |
+
|
| 330 |
+
def __init__(self, storage=None) -> None:
|
| 331 |
+
self._storage = storage
|
| 332 |
+
|
| 333 |
+
async def _resolve_storage(self):
|
| 334 |
+
if self._storage is not None:
|
| 335 |
+
return self._storage
|
| 336 |
+
from app.services.media_storage_service import get_storage_service
|
| 337 |
+
|
| 338 |
+
return await get_storage_service()
|
| 339 |
+
|
| 340 |
+
async def convert_pdf(
|
| 341 |
+
self,
|
| 342 |
+
data: bytes,
|
| 343 |
+
params: PDFConversionParams,
|
| 344 |
+
source: str,
|
| 345 |
+
job_id: Optional[str] = None,
|
| 346 |
+
) -> MediaConversionData:
|
| 347 |
+
"""Convert a PDF to images. Runs validation + rendering in the thread pool."""
|
| 348 |
+
job_id = job_id or str(uuid.uuid4())
|
| 349 |
+
fmt = _fmt_str(params.format)
|
| 350 |
+
if fmt not in _PDF_OUTPUT_FORMATS:
|
| 351 |
+
raise MediaConversionError(
|
| 352 |
+
f"Unsupported output format '{fmt}'. PDF can only be converted to JPEG, PNG or WEBP.",
|
| 353 |
+
status_code=422,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
loop = asyncio.get_running_loop()
|
| 357 |
+
total_pages = await loop.run_in_executor(_thread_pool, _pdf_total_pages, data)
|
| 358 |
+
if total_pages > _settings.media_max_pages:
|
| 359 |
+
raise MediaConversionError(
|
| 360 |
+
f"PDF has {total_pages} pages, exceeding the {_settings.media_max_pages} page limit.",
|
| 361 |
+
status_code=422,
|
| 362 |
+
)
|
| 363 |
+
page_indices = _parse_pages(params.pages, total_pages)
|
| 364 |
+
_guard_memory(len(page_indices), params.dpi)
|
| 365 |
+
|
| 366 |
+
start = time.perf_counter()
|
| 367 |
+
job_dir = self._job_dir(job_id)
|
| 368 |
+
out_dir = job_dir / "out"
|
| 369 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 370 |
+
|
| 371 |
+
page_blobs = await loop.run_in_executor(
|
| 372 |
+
_thread_pool, _split_pdf_pages, data, page_indices
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
if params.split_page:
|
| 376 |
+
render_coros = [
|
| 377 |
+
loop.run_in_executor(
|
| 378 |
+
_thread_pool,
|
| 379 |
+
_render_pdf_page,
|
| 380 |
+
page_blobs[idx],
|
| 381 |
+
params,
|
| 382 |
+
out_dir / f"page_{idx + 1:04d}.{_ext_for(fmt)}",
|
| 383 |
+
)
|
| 384 |
+
for idx in sorted(page_blobs)
|
| 385 |
+
]
|
| 386 |
+
outcomes = await asyncio.gather(*render_coros, return_exceptions=True)
|
| 387 |
+
files: List[MediaOutputFile] = []
|
| 388 |
+
for idx, outcome in zip(sorted(page_blobs), outcomes):
|
| 389 |
+
if isinstance(outcome, Exception):
|
| 390 |
+
_logger.error("page_render_failed job=%s page=%d error=%s", job_id, idx + 1, outcome)
|
| 391 |
+
raise MediaConversionError(
|
| 392 |
+
f"Failed to render page {idx + 1}: {outcome}", status_code=500
|
| 393 |
+
) from outcome
|
| 394 |
+
width, height, size = outcome
|
| 395 |
+
files.append(self._output_file(
|
| 396 |
+
f"page_{idx + 1:04d}.{_ext_for(fmt)}", idx + 1,
|
| 397 |
+
width, height, size, fmt,
|
| 398 |
+
))
|
| 399 |
+
else:
|
| 400 |
+
if len(page_blobs) == 1:
|
| 401 |
+
width, height, size = await loop.run_in_executor(
|
| 402 |
+
_thread_pool,
|
| 403 |
+
_render_pdf_page,
|
| 404 |
+
page_blobs[sorted(page_blobs)[0]],
|
| 405 |
+
params,
|
| 406 |
+
out_dir / f"stitched.{_ext_for(fmt)}",
|
| 407 |
+
)
|
| 408 |
+
else:
|
| 409 |
+
render_coros = [
|
| 410 |
+
loop.run_in_executor(
|
| 411 |
+
_thread_pool,
|
| 412 |
+
_render_pdf_page,
|
| 413 |
+
page_blobs[idx],
|
| 414 |
+
params,
|
| 415 |
+
out_dir / f"_page_{idx + 1:04d}.{_ext_for(fmt)}",
|
| 416 |
+
)
|
| 417 |
+
for idx in sorted(page_blobs)
|
| 418 |
+
]
|
| 419 |
+
outcomes = await asyncio.gather(*render_coros, return_exceptions=True)
|
| 420 |
+
for idx, outcome in zip(sorted(page_blobs), outcomes):
|
| 421 |
+
if isinstance(outcome, Exception):
|
| 422 |
+
raise MediaConversionError(
|
| 423 |
+
f"Failed to render page {idx + 1}: {outcome}", status_code=500
|
| 424 |
+
) from outcome
|
| 425 |
+
page_files = [(idx, out_dir / f"_page_{idx + 1:04d}.{_ext_for(fmt)}") for idx in sorted(page_blobs)]
|
| 426 |
+
width, height, size = await loop.run_in_executor(
|
| 427 |
+
_thread_pool,
|
| 428 |
+
_stitch_images,
|
| 429 |
+
page_files,
|
| 430 |
+
fmt,
|
| 431 |
+
out_dir / f"stitched.{_ext_for(fmt)}",
|
| 432 |
+
params.quality,
|
| 433 |
+
)
|
| 434 |
+
for _, tmp_path in page_files:
|
| 435 |
+
tmp_path.unlink(missing_ok=True)
|
| 436 |
+
files = [self._output_file(
|
| 437 |
+
f"stitched.{_ext_for(fmt)}", None, width, height, size, fmt,
|
| 438 |
+
)]
|
| 439 |
+
|
| 440 |
+
upload, warning = await self._expose(files, job_id, out_dir, job_dir)
|
| 441 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 442 |
+
_logger.info(
|
| 443 |
+
"pdf_conversion_complete job=%s pages=%d duration_ms=%s mode=%s",
|
| 444 |
+
job_id, total_pages, duration_ms, upload.mode,
|
| 445 |
+
)
|
| 446 |
+
return MediaConversionData(
|
| 447 |
+
job_id=job_id,
|
| 448 |
+
source=source,
|
| 449 |
+
input_format="PDF",
|
| 450 |
+
output_format=fmt,
|
| 451 |
+
total_pages=total_pages,
|
| 452 |
+
converted_files=len(files),
|
| 453 |
+
outputs=files,
|
| 454 |
+
upload=upload,
|
| 455 |
+
warning=warning,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
async def convert_image(
|
| 459 |
+
self,
|
| 460 |
+
data: bytes,
|
| 461 |
+
filename: str,
|
| 462 |
+
params: ImageConversionParams,
|
| 463 |
+
job_id: Optional[str] = None,
|
| 464 |
+
) -> MediaConversionData:
|
| 465 |
+
"""Convert an image to a target image format."""
|
| 466 |
+
job_id = job_id or str(uuid.uuid4())
|
| 467 |
+
fmt = _fmt_str(params.format)
|
| 468 |
+
if fmt not in _IMAGE_OUTPUT_FORMATS:
|
| 469 |
+
raise MediaConversionError(
|
| 470 |
+
f"Unsupported output format '{fmt}'.",
|
| 471 |
+
status_code=422,
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
start = time.perf_counter()
|
| 475 |
+
job_dir = self._job_dir(job_id)
|
| 476 |
+
out_dir = job_dir / "out"
|
| 477 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 478 |
+
stem = Path(filename or "image").stem or "image"
|
| 479 |
+
out_path = out_dir / f"{stem}.{_ext_for(fmt)}"
|
| 480 |
+
|
| 481 |
+
loop = asyncio.get_running_loop()
|
| 482 |
+
width, height, size, detected = await loop.run_in_executor(
|
| 483 |
+
_thread_pool, _convert_image_bytes, data, params, out_path
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
file = self._output_file(out_path.name, None, width, height, size, fmt)
|
| 487 |
+
upload, warning = await self._expose([file], job_id, out_dir, job_dir)
|
| 488 |
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 489 |
+
_logger.info(
|
| 490 |
+
"image_conversion_complete job=%s input=%s duration_ms=%s mode=%s",
|
| 491 |
+
job_id, detected, duration_ms, upload.mode,
|
| 492 |
+
)
|
| 493 |
+
return MediaConversionData(
|
| 494 |
+
job_id=job_id,
|
| 495 |
+
source=filename,
|
| 496 |
+
input_format=detected or "IMAGE",
|
| 497 |
+
output_format=fmt,
|
| 498 |
+
total_pages=1,
|
| 499 |
+
converted_files=1,
|
| 500 |
+
outputs=[file],
|
| 501 |
+
upload=upload,
|
| 502 |
+
warning=warning,
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# ------------------------------------------------------------------
|
| 506 |
+
# Helpers
|
| 507 |
+
# ------------------------------------------------------------------
|
| 508 |
+
|
| 509 |
+
@staticmethod
|
| 510 |
+
def _job_dir(job_id: str) -> Path:
|
| 511 |
+
root = Path(_settings.media_output_dir).resolve()
|
| 512 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 513 |
+
return root / job_id
|
| 514 |
+
|
| 515 |
+
@staticmethod
|
| 516 |
+
def _output_file(
|
| 517 |
+
filename: str,
|
| 518 |
+
page_number: Optional[int],
|
| 519 |
+
width: int,
|
| 520 |
+
height: int,
|
| 521 |
+
size_bytes: int,
|
| 522 |
+
fmt: str,
|
| 523 |
+
) -> MediaOutputFile:
|
| 524 |
+
return MediaOutputFile(
|
| 525 |
+
filename=filename,
|
| 526 |
+
page_number=page_number,
|
| 527 |
+
width=width,
|
| 528 |
+
height=height,
|
| 529 |
+
size_bytes=size_bytes,
|
| 530 |
+
format=fmt,
|
| 531 |
+
content_type=_MIME_BY_FORMAT.get(fmt, "application/octet-stream"),
|
| 532 |
+
url="", # filled in by _expose
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
async def _expose(
|
| 536 |
+
self,
|
| 537 |
+
files: List[MediaOutputFile],
|
| 538 |
+
job_id: str,
|
| 539 |
+
out_dir: Path,
|
| 540 |
+
job_dir: Path,
|
| 541 |
+
) -> Tuple[MediaUploadSummary, Optional[str]]:
|
| 542 |
+
"""Expose output files via Supabase signed URLs or local download endpoints."""
|
| 543 |
+
if _settings.supabase_upload_enabled:
|
| 544 |
+
storage = await self._resolve_storage()
|
| 545 |
+
bucket = await storage.ensure_bucket(_settings.supabase_storage_bucket)
|
| 546 |
+
ttl = _settings.supabase_signed_url_ttl_seconds
|
| 547 |
+
semaphore = asyncio.Semaphore(max(1, _settings.media_upload_concurrency))
|
| 548 |
+
|
| 549 |
+
from app.services.media_storage_service import iso_expiry
|
| 550 |
+
|
| 551 |
+
async def _upload_one(f: MediaOutputFile) -> MediaOutputFile:
|
| 552 |
+
storage_path = f"{job_id}/{f.filename}"
|
| 553 |
+
async with semaphore:
|
| 554 |
+
await storage.upload_file(
|
| 555 |
+
bucket, storage_path,
|
| 556 |
+
(out_dir / f.filename).read_bytes(), f.content_type,
|
| 557 |
+
)
|
| 558 |
+
f.url = await storage.create_signed_url(bucket, storage_path, ttl)
|
| 559 |
+
return f
|
| 560 |
+
|
| 561 |
+
outcomes = await asyncio.gather(*[_upload_one(f) for f in files], return_exceptions=True)
|
| 562 |
+
failed = 0
|
| 563 |
+
for f, outcome in zip(files, outcomes):
|
| 564 |
+
if isinstance(outcome, Exception):
|
| 565 |
+
failed += 1
|
| 566 |
+
_logger.error("storage_upload_failed job=%s file=%s error=%s", job_id, f.filename, outcome)
|
| 567 |
+
f.url = f"/api/v1/media-convert/files/{job_id}/{f.filename}"
|
| 568 |
+
|
| 569 |
+
expires_at = iso_expiry(ttl)
|
| 570 |
+
warning = (
|
| 571 |
+
f"Converted files were uploaded to Supabase Storage bucket "
|
| 572 |
+
f"'{bucket}'. The returned signed URLs are valid for 24 hours "
|
| 573 |
+
f"(expire at {expires_at}). Regenerate by re-running the conversion."
|
| 574 |
+
)
|
| 575 |
+
return (
|
| 576 |
+
MediaUploadSummary(
|
| 577 |
+
mode="storage",
|
| 578 |
+
bucket=bucket,
|
| 579 |
+
total_files=len(files),
|
| 580 |
+
failed_uploads=failed,
|
| 581 |
+
url_ttl_seconds=ttl,
|
| 582 |
+
expires_at=expires_at,
|
| 583 |
+
),
|
| 584 |
+
warning,
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
for f in files:
|
| 588 |
+
f.url = f"/api/v1/media-convert/files/{job_id}/{f.filename}"
|
| 589 |
+
try:
|
| 590 |
+
f.base64 = base64.b64encode((out_dir / f.filename).read_bytes()).decode("ascii")
|
| 591 |
+
except OSError:
|
| 592 |
+
f.base64 = None
|
| 593 |
+
return MediaUploadSummary(mode="local", total_files=len(files), failed_uploads=0), None
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
media_conversion_service = MediaConversionService()
|
app/services/media_storage_service.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase Storage integration for the Media-to-Media conversion APIs.
|
| 2 |
+
|
| 3 |
+
Provides a lazy, connection-light async client that:
|
| 4 |
+
1. Ensures the destination bucket exists (creating it if needed).
|
| 5 |
+
2. Uploads converted media files.
|
| 6 |
+
3. Returns short-lived signed URLs (default 24 hours) for download.
|
| 7 |
+
|
| 8 |
+
All storage3 calls are already async/HTTP-based, so no thread-pool offload is
|
| 9 |
+
required here. The client is created on first use and closed during shutdown.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import time
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
from typing import Any, Optional
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.core.logger import get_logger
|
| 21 |
+
|
| 22 |
+
_logger = get_logger(__name__)
|
| 23 |
+
_settings = get_settings()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class MediaStorageError(Exception):
|
| 27 |
+
"""Raised when Supabase Storage operations fail."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, message: str, status_code: int = 502) -> None:
|
| 30 |
+
super().__init__(message)
|
| 31 |
+
self.message = message
|
| 32 |
+
self.status_code = status_code
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MediaStorageService:
|
| 36 |
+
"""Async client for uploading converted media to Supabase Storage."""
|
| 37 |
+
|
| 38 |
+
def __init__(self) -> None:
|
| 39 |
+
self._client: Optional[Any] = None
|
| 40 |
+
self._client_lock = asyncio.Lock()
|
| 41 |
+
self._known_buckets: set[str] = set()
|
| 42 |
+
|
| 43 |
+
# ------------------------------------------------------------------
|
| 44 |
+
# Client management
|
| 45 |
+
# ------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
async def _get_client(self):
|
| 48 |
+
"""Lazily build the Supabase Storage async client."""
|
| 49 |
+
if self._client is None:
|
| 50 |
+
async with self._client_lock:
|
| 51 |
+
if self._client is None:
|
| 52 |
+
url = _settings.supabase_url
|
| 53 |
+
key = _settings.supabase_service_role_key
|
| 54 |
+
if not url or not key:
|
| 55 |
+
raise MediaStorageError(
|
| 56 |
+
"Supabase is not configured. Set SUPABASE_URL and "
|
| 57 |
+
"SUPABASE_SERVICE_ROLE_KEY to enable storage uploads.",
|
| 58 |
+
status_code=503,
|
| 59 |
+
)
|
| 60 |
+
from storage3 import AsyncStorageClient
|
| 61 |
+
|
| 62 |
+
self._client = AsyncStorageClient(
|
| 63 |
+
url,
|
| 64 |
+
headers={
|
| 65 |
+
"apikey": key,
|
| 66 |
+
"Authorization": f"Bearer {key}",
|
| 67 |
+
},
|
| 68 |
+
timeout=60,
|
| 69 |
+
)
|
| 70 |
+
_logger.info("Supabase Storage client initialized")
|
| 71 |
+
return self._client
|
| 72 |
+
|
| 73 |
+
async def close(self) -> None:
|
| 74 |
+
async with self._client_lock:
|
| 75 |
+
if self._client is not None:
|
| 76 |
+
try:
|
| 77 |
+
await self._client.aclose()
|
| 78 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 79 |
+
_logger.debug("Error closing Supabase Storage client: %s", exc)
|
| 80 |
+
self._client = None
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
# Bucket management
|
| 84 |
+
# ------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
async def ensure_bucket(self, bucket_id: str) -> str:
|
| 87 |
+
"""Verify the bucket exists, creating it (private) if it does not."""
|
| 88 |
+
bucket_id = (bucket_id or _settings.supabase_storage_bucket or "media-convert").strip()
|
| 89 |
+
if not bucket_id:
|
| 90 |
+
raise MediaStorageError("A non-empty storage bucket name is required.", status_code=400)
|
| 91 |
+
if bucket_id in self._known_buckets:
|
| 92 |
+
return bucket_id
|
| 93 |
+
|
| 94 |
+
client = await self._get_client()
|
| 95 |
+
exists = False
|
| 96 |
+
try:
|
| 97 |
+
buckets = await client.list_buckets()
|
| 98 |
+
exists = any(getattr(b, "id", None) == bucket_id for b in buckets)
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
_logger.warning("Could not list Supabase buckets: %s", exc)
|
| 101 |
+
|
| 102 |
+
if not exists:
|
| 103 |
+
try:
|
| 104 |
+
await client.create_bucket(bucket_id, name=bucket_id, options={"public": False})
|
| 105 |
+
_logger.info("Created Supabase Storage bucket: %s", bucket_id)
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
raise MediaStorageError(
|
| 108 |
+
f"Failed to create storage bucket '{bucket_id}': {exc}", status_code=502
|
| 109 |
+
) from exc
|
| 110 |
+
|
| 111 |
+
self._known_buckets.add(bucket_id)
|
| 112 |
+
return bucket_id
|
| 113 |
+
|
| 114 |
+
# ------------------------------------------------------------------
|
| 115 |
+
# Uploads & signed URLs
|
| 116 |
+
# ------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
async def upload_file(self, bucket_id: str, path: str, data: bytes, content_type: str) -> None:
|
| 119 |
+
"""Upload raw bytes to ``bucket_id`` under ``path``."""
|
| 120 |
+
client = await self._get_client()
|
| 121 |
+
try:
|
| 122 |
+
response = await client.from_(bucket_id).upload(
|
| 123 |
+
path,
|
| 124 |
+
data,
|
| 125 |
+
file_options={"content-type": content_type, "cache-control": "3600"},
|
| 126 |
+
)
|
| 127 |
+
if response is None or getattr(response, "error", None):
|
| 128 |
+
_logger.error("Supabase upload returned an error: %s", response)
|
| 129 |
+
raise MediaStorageError(
|
| 130 |
+
"Supabase Storage upload failed (unknown response).", status_code=502
|
| 131 |
+
)
|
| 132 |
+
except MediaStorageError:
|
| 133 |
+
raise
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
raise MediaStorageError(
|
| 136 |
+
f"Supabase Storage upload failed for '{path}': {exc}", status_code=502
|
| 137 |
+
) from exc
|
| 138 |
+
|
| 139 |
+
async def create_signed_url(self, bucket_id: str, path: str, ttl_seconds: int) -> str:
|
| 140 |
+
"""Create a signed download URL valid for ``ttl_seconds``."""
|
| 141 |
+
client = await self._get_client()
|
| 142 |
+
try:
|
| 143 |
+
result = await client.from_(bucket_id).create_signed_url(path, int(ttl_seconds))
|
| 144 |
+
signed = getattr(result, "signed_url", None) or (result.get("signedURL") if isinstance(result, dict) else None)
|
| 145 |
+
if not signed:
|
| 146 |
+
raise MediaStorageError(
|
| 147 |
+
"Supabase returned an empty signed URL.", status_code=502
|
| 148 |
+
)
|
| 149 |
+
return signed
|
| 150 |
+
except MediaStorageError:
|
| 151 |
+
raise
|
| 152 |
+
except Exception as exc:
|
| 153 |
+
raise MediaStorageError(
|
| 154 |
+
f"Failed to create signed URL for '{path}': {exc}", status_code=502
|
| 155 |
+
) from exc
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
_storage_service: Optional[MediaStorageService] = None
|
| 159 |
+
_storage_service_lock = asyncio.Lock()
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def get_storage_service() -> MediaStorageService:
|
| 163 |
+
"""Return the shared MediaStorageService singleton."""
|
| 164 |
+
global _storage_service
|
| 165 |
+
if _storage_service is None:
|
| 166 |
+
async with _storage_service_lock:
|
| 167 |
+
if _storage_service is None:
|
| 168 |
+
_storage_service = MediaStorageService()
|
| 169 |
+
return _storage_service
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def close_storage_service() -> None:
|
| 173 |
+
global _storage_service
|
| 174 |
+
if _storage_service is not None:
|
| 175 |
+
await _storage_service.close()
|
| 176 |
+
_storage_service = None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def iso_expiry(ttl_seconds: int) -> str:
|
| 180 |
+
"""ISO-8601 UTC timestamp ``ttl_seconds`` from now."""
|
| 181 |
+
return datetime.fromtimestamp(time.time() + ttl_seconds, tz=timezone.utc).isoformat()
|