Files
HartOMat/backend/app/domains/media/router.py
T

180 lines
6.4 KiB
Python

"""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}