"""Shorts generation API endpoints. Trigger short generation from approved highlights, list generated shorts, and get presigned download URLs. """ from __future__ import annotations import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database import get_session from models import ( FormatPreset, GeneratedShort, HighlightCandidate, HighlightStatus, ShortStatus, ) logger = logging.getLogger("chrysopedia.shorts") router = APIRouter(prefix="/admin/shorts", tags=["shorts"]) # ── Response schemas ───────────────────────────────────────────────────────── class GeneratedShortResponse(BaseModel): id: str highlight_candidate_id: str format_preset: str status: str error_message: str | None = None file_size_bytes: int | None = None duration_secs: float | None = None width: int height: int share_token: str | None = None created_at: datetime model_config = {"from_attributes": True} class ShortsListResponse(BaseModel): shorts: list[GeneratedShortResponse] class GenerateResponse(BaseModel): status: str message: str # ── Endpoints ──────────────────────────────────────────────────────────────── @router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202) async def generate_shorts( highlight_id: str, captions: bool = True, db: AsyncSession = Depends(get_session), ): """Dispatch shorts generation for an approved highlight candidate. Creates pending GeneratedShort rows for each format preset and dispatches the Celery task. Returns 202 Accepted with status. Args: highlight_id: UUID of the approved highlight candidate. captions: Whether to burn in Whisper-generated subtitles (default True). """ # Validate highlight exists and is approved stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id) result = await db.execute(stmt) highlight = result.scalar_one_or_none() if highlight is None: raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}") if highlight.status != HighlightStatus.approved: raise HTTPException( status_code=400, detail=f"Highlight must be approved before generating shorts (current: {highlight.status.value})", ) # Check if shorts are already processing existing_stmt = ( select(GeneratedShort) .where( GeneratedShort.highlight_candidate_id == highlight_id, GeneratedShort.status.in_([ShortStatus.pending, ShortStatus.processing]), ) ) existing_result = await db.execute(existing_stmt) in_progress = existing_result.scalars().all() if in_progress: raise HTTPException( status_code=409, detail="Shorts generation already in progress for this highlight", ) # Dispatch Celery task from pipeline.stages import stage_generate_shorts try: task = stage_generate_shorts.delay(highlight_id, captions=captions) logger.info( "Shorts generation dispatched highlight_id=%s task_id=%s", highlight_id, task.id, ) except Exception as exc: logger.warning( "Failed to dispatch shorts generation for highlight_id=%s: %s", highlight_id, exc, ) raise HTTPException( status_code=503, detail="Shorts generation dispatch failed — Celery/Redis may be unavailable", ) from exc return GenerateResponse( status="dispatched", message=f"Shorts generation dispatched for highlight {highlight_id}", ) @router.get("/{highlight_id}", response_model=ShortsListResponse) async def list_shorts( highlight_id: str, db: AsyncSession = Depends(get_session), ): """List all generated shorts for a highlight candidate.""" # Verify highlight exists hl_stmt = select(HighlightCandidate.id).where(HighlightCandidate.id == highlight_id) hl_result = await db.execute(hl_stmt) if hl_result.scalar_one_or_none() is None: raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}") stmt = ( select(GeneratedShort) .where(GeneratedShort.highlight_candidate_id == highlight_id) .order_by(GeneratedShort.format_preset) ) result = await db.execute(stmt) shorts = result.scalars().all() return ShortsListResponse( shorts=[ GeneratedShortResponse( id=str(s.id), highlight_candidate_id=str(s.highlight_candidate_id), format_preset=s.format_preset.value, status=s.status.value, error_message=s.error_message, file_size_bytes=s.file_size_bytes, duration_secs=s.duration_secs, width=s.width, height=s.height, share_token=s.share_token, created_at=s.created_at, ) for s in shorts ] ) @router.get("/download/{short_id}") async def download_short( short_id: str, db: AsyncSession = Depends(get_session), ): """Get a presigned download URL for a completed short.""" stmt = select(GeneratedShort).where(GeneratedShort.id == short_id) result = await db.execute(stmt) short = result.scalar_one_or_none() if short is None: raise HTTPException(status_code=404, detail=f"Short not found: {short_id}") if short.status != ShortStatus.complete: raise HTTPException( status_code=400, detail=f"Short is not complete (current: {short.status.value})", ) if not short.minio_object_key: raise HTTPException( status_code=500, detail="Short marked complete but has no storage key", ) from minio_client import generate_download_url try: url = generate_download_url(short.minio_object_key) except Exception as exc: logger.error("Failed to generate download URL for short_id=%s: %s", short_id, exc) raise HTTPException( status_code=503, detail="Failed to generate download URL — MinIO may be unavailable", ) from exc return {"download_url": url, "format_preset": short.format_preset.value}