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:
parent
a2372788d5
commit
4edb96df2b
3 changed files with 103 additions and 1 deletions
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -44,3 +44,24 @@ export function fetchTranscript(videoId: string): Promise<TranscriptResponse> {
|
|||
`${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`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue