diff --git a/alembic/versions/021_add_highlight_trim_columns.py b/alembic/versions/021_add_highlight_trim_columns.py new file mode 100644 index 0000000..7ccbe2c --- /dev/null +++ b/alembic/versions/021_add_highlight_trim_columns.py @@ -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") diff --git a/backend/main.py b/backend/main.py index b39c31b..69b2f65 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/models.py b/backend/models.py index b580967..de30624 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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() ) diff --git a/backend/routers/creator_highlights.py b/backend/routers/creator_highlights.py new file mode 100644 index 0000000..1b687ed --- /dev/null +++ b/backend/routers/creator_highlights.py @@ -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)