- "backend/models.py" - "alembic/versions/021_add_highlight_trim_columns.py" - "backend/routers/creator_highlights.py" - "backend/main.py" GSD-Task: S01/T01
230 lines
8.3 KiB
Python
230 lines
8.3 KiB
Python
"""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)
|