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
This commit is contained in:
jlightner 2026-04-04 06:58:28 +00:00
parent 2abeb391bd
commit 87f09f0192
4 changed files with 258 additions and 1 deletions

View file

@ -0,0 +1,24 @@
"""Add trim_start and trim_end columns to highlight_candidates.
Revision ID: 021_add_highlight_trim_columns
Revises: 020_add_chapter_status_and_sort_order
"""
from alembic import op
import sqlalchemy as sa
revision = "021_add_highlight_trim_columns"
down_revision = "020_add_chapter_status_and_sort_order"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("highlight_candidates", sa.Column("trim_start", sa.Float(), nullable=True))
op.add_column("highlight_candidates", sa.Column("trim_end", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("highlight_candidates", "trim_end")
op.drop_column("highlight_candidates", "trim_start")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None:
@ -84,6 +84,7 @@ app.include_router(chat.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creator_highlights.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1")

View file

@ -725,6 +725,8 @@ class HighlightCandidate(Base):
default=HighlightStatus.candidate,
server_default="candidate",
)
trim_start: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
trim_end: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)

View file

@ -0,0 +1,230 @@
"""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)