- "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
185 lines
6.2 KiB
Python
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],
|
|
)
|