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
This commit is contained in:
jlightner 2026-04-04 05:47:16 +00:00
parent a2372788d5
commit 4edb96df2b
3 changed files with 103 additions and 1 deletions

View file

@ -1,17 +1,22 @@
"""Source video endpoints for Chrysopedia API.""" """Source video endpoints for Chrysopedia API."""
import logging import logging
import mimetypes
import os.path
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from database import get_session from database import get_session
from models import SourceVideo, TranscriptSegment from models import KeyMoment, SourceVideo, TranscriptSegment
from schemas import ( from schemas import (
ChapterMarkerRead,
ChaptersResponse,
SourceVideoDetail, SourceVideoDetail,
SourceVideoRead, SourceVideoRead,
TranscriptForPlayerResponse, TranscriptForPlayerResponse,
@ -102,3 +107,60 @@ async def get_video_transcript(
segments=[TranscriptSegmentRead.model_validate(s) for s in segments], segments=[TranscriptSegmentRead.model_validate(s) for s in segments],
total=len(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],
)

View file

@ -659,3 +659,22 @@ class CreatorDashboardResponse(BaseModel):
search_impressions: int = 0 search_impressions: int = 0
techniques: list[CreatorDashboardTechnique] = Field(default_factory=list) techniques: list[CreatorDashboardTechnique] = Field(default_factory=list)
videos: list[CreatorDashboardVideo] = Field(default_factory=list) videos: list[CreatorDashboardVideo] = Field(default_factory=list)
# ── Chapter Markers (for media player timeline) ─────────────────────────────
class ChapterMarkerRead(BaseModel):
"""A chapter marker derived from a KeyMoment for the player timeline."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
start_time: float
end_time: float
content_type: str
class ChaptersResponse(BaseModel):
"""Chapters (KeyMoments) for a video, sorted by start_time."""
video_id: uuid.UUID
chapters: list[ChapterMarkerRead] = Field(default_factory=list)

View file

@ -44,3 +44,24 @@ export function fetchTranscript(videoId: string): Promise<TranscriptResponse> {
`${BASE}/videos/${encodeURIComponent(videoId)}/transcript`, `${BASE}/videos/${encodeURIComponent(videoId)}/transcript`,
); );
} }
// ── Chapters (KeyMoments as timeline markers) ────────────────────────────────
export interface Chapter {
id: string;
title: string;
start_time: number;
end_time: number;
content_type: string;
}
export interface ChaptersResponse {
video_id: string;
chapters: Chapter[];
}
export function fetchChapters(videoId: string): Promise<ChaptersResponse> {
return request<ChaptersResponse>(
`${BASE}/videos/${encodeURIComponent(videoId)}/chapters`,
);
}