"""Creator highlight management endpoints — review, trim, approve/reject highlights. Auth-guarded endpoints for creators to manage auto-detected highlight candidates for their videos. """ import logging import uuid from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from auth import get_current_user from database import get_session from models import HighlightCandidate, HighlightStatus, KeyMoment, SourceVideo, User from pipeline.highlight_schemas import HighlightScoreBreakdown logger = logging.getLogger("chrysopedia.creator_highlights") router = APIRouter(prefix="/creator", tags=["creator-highlights"]) # ── Pydantic Schemas ───────────────────────────────────────────────────────── class KeyMomentInfo(BaseModel): """Embedded key-moment info for highlight responses.""" id: uuid.UUID title: str start_time: float end_time: float model_config = {"from_attributes": True} class HighlightListItem(BaseModel): """Single item in the highlights list response.""" id: uuid.UUID key_moment_id: uuid.UUID source_video_id: uuid.UUID score: float duration_secs: float status: str trim_start: float | None = None trim_end: float | None = None key_moment: KeyMomentInfo | None = None model_config = {"from_attributes": True} class HighlightListResponse(BaseModel): """Response for GET /creator/highlights.""" highlights: list[HighlightListItem] class HighlightDetailResponse(BaseModel): """Response for GET /creator/highlights/{id} with full breakdown.""" id: uuid.UUID key_moment_id: uuid.UUID source_video_id: uuid.UUID score: float score_breakdown: HighlightScoreBreakdown | None = None duration_secs: float status: str trim_start: float | None = None trim_end: float | None = None key_moment: KeyMomentInfo | None = None model_config = {"from_attributes": True} class HighlightStatusUpdate(BaseModel): """Request body for PATCH status.""" status: str = Field(..., pattern="^(approved|rejected)$", description="New status: approved or rejected") class HighlightTrimUpdate(BaseModel): """Request body for PATCH trim.""" trim_start: float = Field(..., ge=0.0, description="Trim start in seconds") trim_end: float = Field(..., ge=0.0, description="Trim end in seconds") # ── Helpers ────────────────────────────────────────────────────────────────── async def _verify_creator(current_user: User) -> uuid.UUID: """Return creator_id or raise 403.""" if current_user.creator_id is None: raise HTTPException(status_code=403, detail="No creator profile linked") return current_user.creator_id async def _get_highlight_for_creator( highlight_id: uuid.UUID, creator_id: uuid.UUID, db: AsyncSession, *, eager_key_moment: bool = False, ) -> HighlightCandidate: """Fetch highlight and verify creator ownership via source video.""" stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id) if eager_key_moment: stmt = stmt.options(selectinload(HighlightCandidate.key_moment)) highlight = (await db.execute(stmt)).scalar_one_or_none() if highlight is None: raise HTTPException(status_code=404, detail="Highlight not found") # Verify ownership via source video video = (await db.execute( select(SourceVideo).where( SourceVideo.id == highlight.source_video_id, SourceVideo.creator_id == creator_id, ) )).scalar_one_or_none() if video is None: raise HTTPException(status_code=404, detail="Highlight not found or not owned by you") return highlight # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/highlights", response_model=HighlightListResponse) async def list_creator_highlights( current_user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_session), status: str | None = Query(None, description="Filter by status: candidate, approved, rejected"), shorts_only: bool = Query(False, description="Only show highlights ≤ 60s"), ) -> HighlightListResponse: """List highlights for the creator's videos with optional filters.""" creator_id = await _verify_creator(current_user) # Get all video IDs for this creator video_ids_stmt = select(SourceVideo.id).where(SourceVideo.creator_id == creator_id) stmt = ( select(HighlightCandidate) .where(HighlightCandidate.source_video_id.in_(video_ids_stmt)) .options(selectinload(HighlightCandidate.key_moment)) .order_by(HighlightCandidate.score.desc()) ) if status is not None: try: HighlightStatus(status) except ValueError: raise HTTPException(status_code=422, detail=f"Invalid status: {status}") stmt = stmt.where(HighlightCandidate.status == status) if shorts_only: stmt = stmt.where(HighlightCandidate.duration_secs <= 60.0) result = await db.execute(stmt) highlights = result.scalars().all() logger.debug("Creator %s highlights: %d results", creator_id, len(highlights)) return HighlightListResponse( highlights=[HighlightListItem.model_validate(h) for h in highlights], ) @router.get("/highlights/{highlight_id}", response_model=HighlightDetailResponse) async def get_creator_highlight( highlight_id: uuid.UUID, current_user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_session), ) -> HighlightDetailResponse: """Get full detail for a single highlight including score breakdown.""" creator_id = await _verify_creator(current_user) highlight = await _get_highlight_for_creator( highlight_id, creator_id, db, eager_key_moment=True, ) return HighlightDetailResponse.model_validate(highlight) @router.patch("/highlights/{highlight_id}", response_model=HighlightDetailResponse) async def update_highlight_status( highlight_id: uuid.UUID, body: HighlightStatusUpdate, current_user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_session), ) -> HighlightDetailResponse: """Approve or reject a highlight. Maps UI 'discard' to 'rejected'.""" creator_id = await _verify_creator(current_user) highlight = await _get_highlight_for_creator( highlight_id, creator_id, db, eager_key_moment=True, ) old_status = highlight.status highlight.status = HighlightStatus(body.status) await db.commit() await db.refresh(highlight) logger.info( "Highlight %s status: %s → %s (creator %s)", highlight_id, old_status.value, body.status, creator_id, ) return HighlightDetailResponse.model_validate(highlight) @router.patch("/highlights/{highlight_id}/trim", response_model=HighlightDetailResponse) async def update_highlight_trim( highlight_id: uuid.UUID, body: HighlightTrimUpdate, current_user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_session), ) -> HighlightDetailResponse: """Set trim start/end for a highlight clip.""" creator_id = await _verify_creator(current_user) # Validate trim_start < trim_end if body.trim_start >= body.trim_end: raise HTTPException( status_code=400, detail="trim_start must be less than trim_end", ) highlight = await _get_highlight_for_creator( highlight_id, creator_id, db, eager_key_moment=True, ) highlight.trim_start = body.trim_start highlight.trim_end = body.trim_end await db.commit() await db.refresh(highlight) logger.info( "Highlight %s trim: %.2f–%.2f (creator %s)", highlight_id, body.trim_start, body.trim_end, creator_id, ) return HighlightDetailResponse.model_validate(highlight)