"""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.auth.models import User from app.domains.media.models import MediaAsset, MediaAssetType from app.domains.media.schemas import MediaAssetOut from app.domains.media import service from app.utils.auth import get_current_user 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 from sqlalchemy import text # products has RLS — bypass for this internal read-only lookup await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) 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), sort_by: str = Query("created_at"), sort_dir: str = Query("desc"), _user: User = Depends(get_current_user), 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, sort_by=sort_by, sort_dir=sort_dir, ) 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, _user: User = Depends(get_current_user), 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) from app.config import settings candidate = Path(key) if not candidate.is_absolute(): candidate = Path(settings.upload_dir) / key # Legacy path remapping: /shared/renders/{uuid}/{file} → UPLOAD_DIR/renders/{uuid}/{file} if not candidate.exists() and "/shared/renders/" in key: import logging parts = key.split("/") if len(parts) >= 2: remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1] if remapped.exists(): logging.getLogger(__name__).warning( "Remapped legacy path %s → %s", key, remapped ) candidate = remapped 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, headers={"Cache-Control": "max-age=3600, public"}, ) # 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}", "Cache-Control": "max-age=3600, public", }, ) except Exception: raise HTTPException(404, "File not available") @router.post("/zip") async def zip_download( asset_ids: list[uuid.UUID], _user: User = Depends(get_current_user), 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(): import logging from pathlib import Path from app.core.storage import get_storage logger = logging.getLogger(__name__) buf = io.BytesIO() seen_names: dict[str, int] = {} with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: storage = get_storage() for a in assets: key = a.storage_key # Use filename from storage_key (always has correct extension) original_name = Path(key).name ext = Path(key).suffix.lstrip(".") or (a.mime_type or "").split("/")[-1] or "bin" base = original_name if original_name else f"{a.asset_type.value}_{a.id}.{ext}" # Deduplicate filenames within the ZIP if base in seen_names: seen_names[base] += 1 stem = Path(base).stem suffix = Path(base).suffix fname = f"{stem}_{seen_names[base]}{suffix}" else: seen_names[base] = 0 fname = base try: # Check absolute path first (local filesystem) candidate = Path(key) if not candidate.is_absolute(): from app.config import settings candidate = Path(settings.upload_dir) / key if candidate.exists(): data = candidate.read_bytes() else: data = storage.download_bytes(key) zf.writestr(fname, data) except Exception as exc: logger.warning("ZIP: skipping asset %s — %s", a.id, exc) 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} @router.delete("/{asset_id}/permanent") async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)): """Permanently remove a MediaAsset record from the database.""" deleted = await service.delete_media_asset(db, asset_id) if not deleted: raise HTTPException(404, "Asset not found") return {"ok": True}