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:
parent
2abeb391bd
commit
87f09f0192
4 changed files with 258 additions and 1 deletions
24
alembic/versions/021_add_highlight_trim_columns.py
Normal file
24
alembic/versions/021_add_highlight_trim_columns.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
230
backend/routers/creator_highlights.py
Normal file
230
backend/routers/creator_highlights.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue