From 4edb96df2b547e1b339f218b7ac8048f9473fc88 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:47:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20media=20streaming=20endpoint=20?= =?UTF-8?q?and=20chapters=20endpoint=20to=20videos=20ro=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/routers/videos.py" - "backend/schemas.py" - "frontend/src/api/videos.ts" GSD-Task: S05/T01 --- backend/routers/videos.py | 64 +++++++++++++++++++++++++++++++++++++- backend/schemas.py | 19 +++++++++++ frontend/src/api/videos.ts | 21 +++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 59a0546..6b3f878 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -1,17 +1,22 @@ """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 SourceVideo, TranscriptSegment +from models import KeyMoment, SourceVideo, TranscriptSegment from schemas import ( + ChapterMarkerRead, + ChaptersResponse, SourceVideoDetail, SourceVideoRead, TranscriptForPlayerResponse, @@ -102,3 +107,60 @@ async def get_video_transcript( 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], + ) diff --git a/backend/schemas.py b/backend/schemas.py index e92b1a5..c33e11f 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -659,3 +659,22 @@ class CreatorDashboardResponse(BaseModel): search_impressions: int = 0 techniques: list[CreatorDashboardTechnique] = 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) diff --git a/frontend/src/api/videos.ts b/frontend/src/api/videos.ts index 025786c..6ec3416 100644 --- a/frontend/src/api/videos.ts +++ b/frontend/src/api/videos.ts @@ -44,3 +44,24 @@ export function fetchTranscript(videoId: string): Promise { `${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 { + return request( + `${BASE}/videos/${encodeURIComponent(videoId)}/chapters`, + ); +}