479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""MediaAsset router — /api/media."""
|
|
import io
|
|
import math
|
|
import uuid
|
|
import zipfile
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.core.render_paths import resolve_result_path
|
|
from app.domains.auth.models import User
|
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
|
from app.domains.media.schemas import MediaAssetOut, MediaAssetBrowseItem, MediaAssetBrowseResponse
|
|
from app.domains.media import service
|
|
from app.utils.auth import get_current_user
|
|
|
|
router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False)
|
|
|
|
|
|
def _resolve_asset_candidate(key: str):
|
|
return resolve_result_path(key)
|
|
|
|
|
|
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]}/thumbnail"
|
|
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("/assets", response_model=MediaAssetBrowseResponse)
|
|
async def browse_media_assets(
|
|
asset_type: str | None = None,
|
|
category_key: str | None = None,
|
|
render_status: str | None = None,
|
|
q: str | None = None,
|
|
exclude_technical: bool = Query(True, description="Exclude GLB/STL/Blend technical assets"),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> MediaAssetBrowseResponse:
|
|
"""Media browser: server-side filtered + paginated asset list with product context."""
|
|
from app.domains.products.models import Product
|
|
from app.domains.orders.models import OrderLine
|
|
from sqlalchemy import desc
|
|
|
|
# Build query with LEFT JOINs to get product and order_line context.
|
|
# MediaAsset has direct product_id FK and order_line_id FK.
|
|
# OrderLine has render_status which we also want to surface.
|
|
stmt = (
|
|
select(
|
|
MediaAsset,
|
|
Product.name.label("product_name"),
|
|
Product.pim_id.label("product_pim_id"),
|
|
Product.category_key.label("category_key"),
|
|
OrderLine.render_status.label("render_status"),
|
|
Product.ebene1.label("product_ebene1"),
|
|
Product.ebene2.label("product_ebene2"),
|
|
Product.baureihe.label("product_baureihe"),
|
|
Product.produkt_baureihe.label("product_produkt_baureihe"),
|
|
Product.lagertyp.label("product_lagertyp"),
|
|
Product.name_cad_modell.label("product_name_cad_modell"),
|
|
)
|
|
.outerjoin(Product, MediaAsset.product_id == Product.id)
|
|
.outerjoin(OrderLine, MediaAsset.order_line_id == OrderLine.id)
|
|
.where(MediaAsset.is_archived == False) # noqa: E712
|
|
.order_by(desc(MediaAsset.created_at))
|
|
)
|
|
|
|
# Apply filters
|
|
_TECHNICAL_TYPES = (
|
|
MediaAssetType.gltf_geometry,
|
|
MediaAssetType.blend_production,
|
|
MediaAssetType.stl_low,
|
|
MediaAssetType.stl_high,
|
|
)
|
|
if asset_type:
|
|
try:
|
|
at_enum = MediaAssetType(asset_type)
|
|
stmt = stmt.where(MediaAsset.asset_type == at_enum)
|
|
except ValueError:
|
|
pass # invalid type → ignore filter
|
|
elif exclude_technical:
|
|
stmt = stmt.where(MediaAsset.asset_type.notin_(_TECHNICAL_TYPES))
|
|
|
|
if category_key:
|
|
stmt = stmt.where(Product.category_key == category_key)
|
|
|
|
if render_status:
|
|
stmt = stmt.where(OrderLine.render_status == render_status)
|
|
|
|
if q:
|
|
pattern = f"%{q}%"
|
|
from sqlalchemy import or_
|
|
stmt = stmt.where(
|
|
or_(
|
|
Product.name.ilike(pattern),
|
|
Product.pim_id.ilike(pattern),
|
|
Product.ebene1.ilike(pattern),
|
|
Product.ebene2.ilike(pattern),
|
|
Product.baureihe.ilike(pattern),
|
|
Product.produkt_baureihe.ilike(pattern),
|
|
Product.lagertyp.ilike(pattern),
|
|
Product.name_cad_modell.ilike(pattern),
|
|
)
|
|
)
|
|
|
|
# Count total matching rows
|
|
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
total_result = await db.execute(count_stmt)
|
|
total = total_result.scalar_one()
|
|
|
|
# Paginate
|
|
offset = (page - 1) * page_size
|
|
stmt = stmt.offset(offset).limit(page_size)
|
|
|
|
all_rows = (await db.execute(stmt)).all()
|
|
|
|
# Pre-assign thumbnail_url so _resolve_thumbnails_bulk can check it
|
|
raw_assets = [row[0] for row in all_rows]
|
|
for a in raw_assets:
|
|
a.thumbnail_url = service.get_thumbnail_url(a)
|
|
# Resolve fallback thumbnails for non-image assets via product→cad lookup
|
|
await _resolve_thumbnails_bulk(db, raw_assets)
|
|
|
|
items: list[MediaAssetBrowseItem] = []
|
|
for row in all_rows:
|
|
asset: MediaAsset = row[0]
|
|
product_name: str | None = row[1]
|
|
product_pim_id: str | None = row[2]
|
|
cat_key: str | None = row[3]
|
|
r_status: str | None = row[4]
|
|
ebene1: str | None = row[5]
|
|
ebene2: str | None = row[6]
|
|
baureihe: str | None = row[7]
|
|
produkt_baureihe: str | None = row[8]
|
|
lagertyp: str | None = row[9]
|
|
name_cad_modell: str | None = row[10]
|
|
|
|
thumb = asset.thumbnail_url
|
|
item = MediaAssetBrowseItem(
|
|
id=asset.id,
|
|
asset_type=asset.asset_type,
|
|
file_path=asset.storage_key,
|
|
file_size_bytes=asset.file_size_bytes,
|
|
mime_type=asset.mime_type,
|
|
created_at=asset.created_at,
|
|
order_line_id=asset.order_line_id,
|
|
product_id=asset.product_id,
|
|
product_name=product_name,
|
|
product_pim_id=product_pim_id,
|
|
category_key=cat_key,
|
|
render_status=r_status,
|
|
product_ebene1=ebene1,
|
|
product_ebene2=ebene2,
|
|
product_baureihe=baureihe,
|
|
product_produkt_baureihe=produkt_baureihe,
|
|
product_lagertyp=lagertyp,
|
|
product_name_cad_modell=name_cad_modell,
|
|
download_url=f"/api/media/{asset.id}/download",
|
|
thumbnail_url=thumb,
|
|
)
|
|
items.append(item)
|
|
|
|
pages = max(1, math.ceil(total / page_size))
|
|
return MediaAssetBrowseResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
pages=pages,
|
|
)
|
|
|
|
|
|
@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.get("/{asset_id}/thumbnail")
|
|
async def thumbnail_asset(
|
|
asset_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Serve asset as an inline image — no auth required (UUID is opaque enough).
|
|
|
|
Only serves image/video MIME types; returns 404 for binary files.
|
|
"""
|
|
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")
|
|
|
|
mime = asset.mime_type or ""
|
|
if not (mime.startswith("image/") or mime.startswith("video/")):
|
|
raise HTTPException(404, "Not a previewable asset")
|
|
|
|
key = asset.storage_key
|
|
candidate = _resolve_asset_candidate(key)
|
|
if candidate is not None and candidate.exists():
|
|
return FileResponse(
|
|
str(candidate), media_type=mime,
|
|
headers={"Cache-Control": "max-age=86400, public"},
|
|
)
|
|
try:
|
|
from app.core.storage import get_storage
|
|
data = get_storage().download_bytes(key)
|
|
return Response(content=data, media_type=mime,
|
|
headers={"Cache-Control": "max-age=86400, public"})
|
|
except Exception:
|
|
raise HTTPException(404, "File not available")
|
|
|
|
|
|
@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)
|
|
candidate = _resolve_asset_candidate(key)
|
|
if candidate is not None and 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": "no-cache"},
|
|
)
|
|
|
|
# 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": "no-cache",
|
|
},
|
|
)
|
|
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 = _resolve_asset_candidate(key)
|
|
if candidate is not None and 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}
|
|
|
|
|
|
@router.post("/batch-delete")
|
|
async def batch_delete_assets(
|
|
asset_ids: list[uuid.UUID],
|
|
_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Permanently delete multiple MediaAsset records."""
|
|
from app.utils.auth import require_global_admin
|
|
await require_global_admin(_user)
|
|
|
|
deleted = 0
|
|
for aid in asset_ids:
|
|
ok = await service.delete_media_asset(db, aid)
|
|
if ok:
|
|
deleted += 1
|
|
return {"deleted": deleted, "requested": len(asset_ids)}
|
|
|
|
|
|
@router.post("/cleanup-orphaned")
|
|
async def cleanup_orphaned_assets(
|
|
_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete all MediaAsset DB records whose backing file doesn't exist on disk or in MinIO.
|
|
|
|
Returns counts of checked/deleted records. Admin only.
|
|
"""
|
|
import logging
|
|
from pathlib import Path
|
|
from app.core.storage import get_storage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
storage = get_storage()
|
|
|
|
def _file_exists(key: str) -> bool:
|
|
candidate = _resolve_asset_candidate(key)
|
|
if candidate is not None and candidate.exists():
|
|
return True
|
|
# Check MinIO
|
|
try:
|
|
storage.download_bytes(key)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
result = await db.execute(select(MediaAsset).where(MediaAsset.is_archived == False)) # noqa: E712
|
|
all_assets = result.scalars().all()
|
|
|
|
deleted_ids = []
|
|
for asset in all_assets:
|
|
if not _file_exists(asset.storage_key):
|
|
logger.info("Cleanup: deleting orphaned asset %s (%s)", asset.id, asset.storage_key)
|
|
await db.delete(asset)
|
|
deleted_ids.append(str(asset.id))
|
|
|
|
if deleted_ids:
|
|
await db.commit()
|
|
|
|
return {
|
|
"checked": len(all_assets),
|
|
"deleted": len(deleted_ids),
|
|
"deleted_ids": deleted_ids,
|
|
}
|