chrysopedia/backend/routers/creator_highlights.py
jlightner c05e4da594 feat: Add creator-scoped highlight review endpoints (list/detail/status…
- "backend/models.py"
- "alembic/versions/021_add_highlight_trim_columns.py"
- "backend/routers/creator_highlights.py"
- "backend/main.py"

GSD-Task: S01/T01
2026-04-04 06:58:28 +00:00

230 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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