- "backend/models.py" - "backend/schemas.py" - "alembic/versions/020_add_chapter_status_and_sort_order.py" - "backend/routers/creator_chapters.py" - "backend/routers/videos.py" - "backend/main.py" GSD-Task: S06/T01
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""Creator chapter management endpoints — review, edit, reorder, approve chapters.
|
|
|
|
Auth-guarded endpoints for creators to manage auto-detected chapters for
|
|
their videos before publication.
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from auth import get_current_user
|
|
from database import get_session
|
|
from models import ChapterStatus, KeyMoment, SourceVideo, User
|
|
from schemas import (
|
|
ChapterBulkApproveRequest,
|
|
ChapterMarkerRead,
|
|
ChapterReorderRequest,
|
|
ChapterUpdate,
|
|
ChaptersResponse,
|
|
)
|
|
|
|
logger = logging.getLogger("chrysopedia.creator_chapters")
|
|
|
|
router = APIRouter(prefix="/creator", tags=["creator-chapters"])
|
|
|
|
|
|
async def _verify_creator_owns_video(
|
|
current_user: User,
|
|
video_id: uuid.UUID,
|
|
db: AsyncSession,
|
|
) -> None:
|
|
"""Verify the user is a creator and owns the specified video."""
|
|
if current_user.creator_id is None:
|
|
raise HTTPException(status_code=403, detail="No creator profile linked")
|
|
|
|
video = (await db.execute(
|
|
select(SourceVideo).where(
|
|
SourceVideo.id == video_id,
|
|
SourceVideo.creator_id == current_user.creator_id,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if video is None:
|
|
raise HTTPException(status_code=404, detail="Video not found or not owned by you")
|
|
|
|
|
|
@router.get("/{video_id}/chapters", response_model=ChaptersResponse)
|
|
async def get_creator_chapters(
|
|
video_id: uuid.UUID,
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ChaptersResponse:
|
|
"""Return all chapters for a creator's video (all statuses)."""
|
|
await _verify_creator_owns_video(current_user, video_id, db)
|
|
|
|
stmt = (
|
|
select(KeyMoment)
|
|
.where(KeyMoment.source_video_id == video_id)
|
|
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
|
|
)
|
|
result = await db.execute(stmt)
|
|
moments = result.scalars().all()
|
|
logger.debug("Creator chapters for %s: %d", video_id, len(moments))
|
|
return ChaptersResponse(
|
|
video_id=video_id,
|
|
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
|
|
)
|
|
|
|
|
|
@router.patch("/chapters/{chapter_id}", response_model=ChapterMarkerRead)
|
|
async def update_chapter(
|
|
chapter_id: uuid.UUID,
|
|
body: ChapterUpdate,
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ChapterMarkerRead:
|
|
"""Update a single chapter (title, times, status)."""
|
|
if current_user.creator_id is None:
|
|
raise HTTPException(status_code=403, detail="No creator profile linked")
|
|
|
|
# Fetch the chapter and verify ownership via the video
|
|
chapter = (await db.execute(
|
|
select(KeyMoment).where(KeyMoment.id == chapter_id)
|
|
)).scalar_one_or_none()
|
|
if chapter is None:
|
|
raise HTTPException(status_code=404, detail="Chapter not found")
|
|
|
|
await _verify_creator_owns_video(current_user, chapter.source_video_id, db)
|
|
|
|
# Apply partial updates
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
if "chapter_status" in update_data:
|
|
update_data["chapter_status"] = ChapterStatus(update_data["chapter_status"])
|
|
for field, value in update_data.items():
|
|
setattr(chapter, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(chapter)
|
|
logger.info("Updated chapter %s: %s", chapter_id, list(update_data.keys()))
|
|
return ChapterMarkerRead.model_validate(chapter)
|
|
|
|
|
|
@router.put("/{video_id}/chapters/reorder", response_model=ChaptersResponse)
|
|
async def reorder_chapters(
|
|
video_id: uuid.UUID,
|
|
body: ChapterReorderRequest,
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ChaptersResponse:
|
|
"""Reorder chapters for a video by setting sort_order values."""
|
|
await _verify_creator_owns_video(current_user, video_id, db)
|
|
|
|
for item in body.chapters:
|
|
await db.execute(
|
|
update(KeyMoment)
|
|
.where(KeyMoment.id == item.id, KeyMoment.source_video_id == video_id)
|
|
.values(sort_order=item.sort_order)
|
|
)
|
|
await db.commit()
|
|
|
|
# Return updated list
|
|
stmt = (
|
|
select(KeyMoment)
|
|
.where(KeyMoment.source_video_id == video_id)
|
|
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
|
|
)
|
|
result = await db.execute(stmt)
|
|
moments = result.scalars().all()
|
|
logger.info("Reordered %d chapters for video %s", len(body.chapters), video_id)
|
|
return ChaptersResponse(
|
|
video_id=video_id,
|
|
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
|
|
)
|
|
|
|
|
|
@router.post("/{video_id}/chapters/approve", response_model=ChaptersResponse)
|
|
async def bulk_approve_chapters(
|
|
video_id: uuid.UUID,
|
|
body: ChapterBulkApproveRequest,
|
|
current_user: Annotated[User, Depends(get_current_user)],
|
|
db: AsyncSession = Depends(get_session),
|
|
) -> ChaptersResponse:
|
|
"""Bulk-approve chapters by ID list."""
|
|
await _verify_creator_owns_video(current_user, video_id, db)
|
|
|
|
if body.chapter_ids:
|
|
await db.execute(
|
|
update(KeyMoment)
|
|
.where(
|
|
KeyMoment.id.in_(body.chapter_ids),
|
|
KeyMoment.source_video_id == video_id,
|
|
)
|
|
.values(chapter_status=ChapterStatus.approved)
|
|
)
|
|
await db.commit()
|
|
logger.info("Bulk-approved %d chapters for video %s", len(body.chapter_ids), video_id)
|
|
|
|
# Return updated list
|
|
stmt = (
|
|
select(KeyMoment)
|
|
.where(KeyMoment.source_video_id == video_id)
|
|
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
|
|
)
|
|
result = await db.execute(stmt)
|
|
moments = result.scalars().all()
|
|
return ChaptersResponse(
|
|
video_id=video_id,
|
|
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
|
|
)
|