chrysopedia/backend/routers/videos.py
jlightner ed9aa7a83a feat: Added ChapterStatus enum, sort_order column, migration 020, chapt…
- "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
2026-04-04 06:03:49 +00:00

185 lines
6.2 KiB
Python

"""Source video endpoints for Chrysopedia API."""
import logging
import mimetypes
import os.path
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_session
from models import KeyMoment, SourceVideo, TranscriptSegment
from schemas import (
ChapterMarkerRead,
ChaptersResponse,
SourceVideoDetail,
SourceVideoRead,
TranscriptForPlayerResponse,
TranscriptSegmentRead,
VideoListResponse,
)
logger = logging.getLogger("chrysopedia.videos")
router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("", response_model=VideoListResponse)
async def list_videos(
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
creator_id: str | None = None,
db: AsyncSession = Depends(get_session),
) -> VideoListResponse:
"""List source videos with optional filtering by creator."""
base_stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc())
if creator_id:
base_stmt = base_stmt.where(SourceVideo.creator_id == creator_id)
# Total count (before offset/limit)
count_stmt = select(func.count()).select_from(base_stmt.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base_stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
videos = result.scalars().all()
logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit)
return VideoListResponse(
items=[SourceVideoRead.model_validate(v) for v in videos],
total=total,
offset=offset,
limit=limit,
)
@router.get("/{video_id}", response_model=SourceVideoDetail)
async def get_video_detail(
video_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> SourceVideoDetail:
"""Get a single video with creator info for the player page."""
stmt = (
select(SourceVideo)
.where(SourceVideo.id == video_id)
.options(selectinload(SourceVideo.creator))
)
result = await db.execute(stmt)
video = result.scalar_one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
detail = SourceVideoDetail.model_validate(video)
detail.creator_name = video.creator.name if video.creator else ""
detail.creator_slug = video.creator.slug if video.creator else ""
logger.debug("Video detail %s (creator=%s)", video_id, detail.creator_name)
return detail
@router.get("/{video_id}/transcript", response_model=TranscriptForPlayerResponse)
async def get_video_transcript(
video_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> TranscriptForPlayerResponse:
"""Get all transcript segments for a video, ordered by segment_index."""
# Verify video exists
video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id)
video_result = await db.execute(video_stmt)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Video not found")
stmt = (
select(TranscriptSegment)
.where(TranscriptSegment.source_video_id == video_id)
.order_by(TranscriptSegment.segment_index)
)
result = await db.execute(stmt)
segments = result.scalars().all()
logger.debug("Transcript for %s: %d segments", video_id, len(segments))
return TranscriptForPlayerResponse(
video_id=video_id,
segments=[TranscriptSegmentRead.model_validate(s) for s in segments],
total=len(segments),
)
@router.get("/{video_id}/stream")
async def stream_video(
video_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> FileResponse:
"""Serve the media file at SourceVideo.file_path.
Returns 404 if the video record is missing, file_path is unset,
or the file does not exist on disk.
"""
stmt = select(SourceVideo).where(SourceVideo.id == video_id)
result = await db.execute(stmt)
video = result.scalar_one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
if not video.file_path or not os.path.isfile(video.file_path):
raise HTTPException(status_code=404, detail="Media file not found on disk")
media_type, _ = mimetypes.guess_type(video.file_path)
if media_type is None:
media_type = "application/octet-stream"
logger.debug("Streaming %s (%s) for video %s", video.file_path, media_type, video_id)
return FileResponse(
video.file_path,
media_type=media_type,
filename=video.filename,
)
@router.get("/{video_id}/chapters", response_model=ChaptersResponse)
async def get_video_chapters(
video_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Return KeyMoment records for a video as chapter markers.
Prefers approved chapters if any exist; otherwise returns all chapters.
Sorted by sort_order then start_time.
"""
# Verify video exists
video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id)
video_result = await db.execute(video_stmt)
if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Video not found")
# Try approved-only first
approved_stmt = (
select(KeyMoment)
.where(
KeyMoment.source_video_id == video_id,
KeyMoment.chapter_status == "approved",
)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(approved_stmt)
moments = result.scalars().all()
# Fallback to all if none are approved
if not moments:
all_stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(all_stmt)
moments = result.scalars().all()
logger.debug("Chapters for %s: %d key moments", video_id, len(moments))
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)