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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
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:
|
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(consent.router, prefix="/api/v1")
|
||||||
app.include_router(creator_dashboard.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_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(creators.router, prefix="/api/v1")
|
||||||
app.include_router(highlights.router, prefix="/api/v1")
|
app.include_router(highlights.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,8 @@ class HighlightCandidate(Base):
|
||||||
default=HighlightStatus.candidate,
|
default=HighlightStatus.candidate,
|
||||||
server_default="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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
default=_now, server_default=func.now()
|
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