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."""
|
"""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],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue