chrysopedia/backend/routers/shorts.py
jlightner a60f4074dc chore: Add GET/PUT shorts-template admin endpoints, collapsible templat…
- "backend/routers/creators.py"
- "backend/schemas.py"
- "frontend/src/api/templates.ts"
- "frontend/src/pages/HighlightQueue.tsx"
- "frontend/src/pages/HighlightQueue.module.css"
- "backend/routers/shorts.py"
- "backend/pipeline/stages.py"
- "frontend/src/api/shorts.ts"

GSD-Task: S04/T03
2026-04-04 11:25:29 +00:00

211 lines
6.6 KiB
Python

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