chrysopedia/backend/routers/videos.py
jlightner e44ec1d1d5 feat: Added media streaming endpoint and chapters endpoint to videos ro…
- "backend/routers/videos.py"
- "backend/schemas.py"
- "frontend/src/api/videos.ts"

GSD-Task: S05/T01
2026-04-04 05:47:16 +00:00

166 lines
5.7 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, sorted by 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")
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.start_time)
)
result = await db.execute(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],
)