f5ca91ee02
- Layout: mobile hamburger menu + overlay backdrop + close button; content area always full-width - Media browser: filter chips (default still+turntable); advanced toggle for GLB/STL; thumbnail_url previews for non-image types; video hover-play for turntable - Backend: asset_types multi-filter, thumbnail_url in MediaAssetOut, download proxy endpoint for MinIO/local files - Admin: "Import Existing Media" button → POST /api/admin/import-media-assets - Billing: fix invoice create 500 (MissingGreenlet — use selectinload after commit); PDF download uses axios blob instead of bare <a href> (auth header missing); fix storage.upload() accepting str|Path - SSE task logs: task_logs.py core + router, LiveRenderLog component - CadPreview: fix infinite loop when no gltf_geometry assets; loading screen before ThreeDViewer render - render-worker: add trimesh layer to Dockerfile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
4.5 KiB
Python
134 lines
4.5 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.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.domains.media.models import MediaAssetType
|
|
from app.domains.media.schemas import MediaAssetOut
|
|
from app.domains.media import service
|
|
|
|
router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False)
|
|
|
|
|
|
@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)
|
|
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)
|
|
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}
|