"""MediaAsset router — /api/media.""" import io import uuid import zipfile from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.domains.media.models import MediaAsset, MediaAssetType from app.domains.media.schemas import MediaAssetOut from app.domains.media import service router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False) async def _resolve_thumbnails_bulk(db: AsyncSession, assets: list) -> None: """Resolve thumbnail_url for assets using the same priority as product pages. Priority per asset (applied only when thumbnail_url is not yet set): 1. Latest 'still' MediaAsset for the same product (rendered preview) 2. Product's linked CadFile thumbnail (/api/cad/{id}/thumbnail) """ needs = [a for a in assets if not a.thumbnail_url and a.product_id] if not needs: return product_ids = list({a.product_id for a in needs}) # 1. Latest 'still' asset per product (DISTINCT ON product_id ORDER BY created_at DESC) still_rows = await db.execute( select(MediaAsset.product_id, MediaAsset.id) .where( MediaAsset.product_id.in_(product_ids), MediaAsset.asset_type == MediaAssetType.still, MediaAsset.is_archived == False, # noqa: E712 ) .order_by(MediaAsset.product_id, MediaAsset.created_at.desc()) .distinct(MediaAsset.product_id) ) best_still: dict[str, str] = {str(pid): str(sid) for pid, sid in still_rows.all()} # 2. Fallback: product's cad_file_id → CAD thumbnail endpoint from app.domains.products.models import Product prod_rows = await db.execute( select(Product.id, Product.cad_file_id).where(Product.id.in_(product_ids)) ) product_cad: dict[str, str] = { str(pid): str(cid) for pid, cid in prod_rows.all() if cid } for a in needs: pid = str(a.product_id) if pid in best_still: a.thumbnail_url = f"/api/media/{best_still[pid]}/download" elif pid in product_cad: a.thumbnail_url = f"/api/cad/{product_cad[pid]}/thumbnail" @router.get("", response_model=list[MediaAssetOut]) @router.get("/", response_model=list[MediaAssetOut], include_in_schema=False) async def list_assets( product_id: uuid.UUID | None = None, order_line_id: uuid.UUID | None = None, cad_file_id: uuid.UUID | None = None, asset_type: MediaAssetType | None = None, asset_types: list[MediaAssetType] = Query(default=[]), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), db: AsyncSession = Depends(get_db), ): assets = await service.list_media_assets( db, product_id=product_id, order_line_id=order_line_id, cad_file_id=cad_file_id, asset_type=asset_type, asset_types=asset_types if asset_types else None, skip=skip, limit=limit, ) for a in assets: a.download_url = service.get_download_url(a) a.thumbnail_url = service.get_thumbnail_url(a) await _resolve_thumbnails_bulk(db, assets) return assets @router.get("/{asset_id}", response_model=MediaAssetOut) async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): asset = await service.get_media_asset(db, asset_id) if not asset: raise HTTPException(404, "Asset not found") asset.download_url = service.get_download_url(asset) asset.thumbnail_url = service.get_thumbnail_url(asset) await _resolve_thumbnails_bulk(db, [asset]) return asset @router.api_route("/{asset_id}/download", methods=["GET", "HEAD"]) async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): """Proxy file content directly — avoids internal MinIO hostname issues.""" from fastapi.responses import FileResponse, Response from pathlib import Path asset = await service.get_media_asset(db, asset_id) if not asset: raise HTTPException(404, "Asset not found") key = asset.storage_key mime = asset.mime_type or "application/octet-stream" # Local file path (absolute or relative to UPLOAD_DIR) candidate = Path(key) if not candidate.is_absolute(): from app.config import settings candidate = Path(settings.UPLOAD_DIR) / key if candidate.exists(): ext = candidate.suffix.lstrip(".") fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}" return FileResponse(str(candidate), media_type=mime, filename=fname) # Fall back to MinIO try: from app.core.storage import get_storage data = get_storage().download_bytes(key) ext = key.rsplit(".", 1)[-1] if "." in key else "bin" fname = f"{asset.asset_type.value}_{asset_id}.{ext}" return Response( content=data, media_type=mime, headers={"Content-Disposition": f"attachment; filename={fname}"}, ) except Exception: raise HTTPException(404, "File not available") @router.post("/zip") async def zip_download( asset_ids: list[uuid.UUID], db: AsyncSession = Depends(get_db), ): assets = [] for aid in asset_ids: a = await service.get_media_asset(db, aid) if a: assets.append(a) if not assets: raise HTTPException(404, "No assets found") def generate(): buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: from app.core.storage import get_storage storage = get_storage() for a in assets: ext = (a.mime_type or "").split("/")[-1] or "bin" fname = f"{a.asset_type.value}_{a.id}.{ext}" try: data = storage.download_bytes(a.storage_key) zf.writestr(fname, data) except Exception: pass yield buf.getvalue() return StreamingResponse( generate(), media_type="application/zip", headers={"Content-Disposition": "attachment; filename=media-export.zip"}, ) @router.delete("/{asset_id}") async def archive_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): asset = await service.archive_media_asset(db, asset_id) if not asset: raise HTTPException(404, "Asset not found") return {"ok": True}