""" Timeline + Job management endpoints. POST /detect/timeline — create timeline from chunk selection GET /detect/timeline — list timelines GET /detect/timeline/{id} — timeline detail DELETE /detect/timeline/{id}/cache — clear frame cache GET /detect/jobs — list jobs (optionally by timeline) GET /detect/jobs/{id} — job detail + checkpoints + stage outputs """ from __future__ import annotations import logging from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel logger = logging.getLogger(__name__) router = APIRouter(prefix="/detect", tags=["detect"]) # --- Request/Response models --- class CreateTimelineRequest(BaseModel): chunk_paths: list[str] profile_name: str = "soccer_broadcast" name: str = "" source_asset_id: str = "" fps: float = 2.0 class TimelineResponse(BaseModel): id: str name: str chunk_paths: list[str] profile_name: str status: str fps: float frame_count: int source_ephemeral: bool created_at: str | None = None class JobResponse(BaseModel): id: str timeline_id: str | None source_asset_id: str video_path: str profile_name: str run_type: str status: str current_stage: str | None config_overrides: dict error_message: str | None created_at: str | None started_at: str | None completed_at: str | None class JobDetailResponse(JobResponse): checkpoints: list[dict] stage_outputs: dict[str, dict] # --- Timeline endpoints --- @router.post("/timeline", response_model=TimelineResponse) def create_timeline_endpoint(req: CreateTimelineRequest): """Create a timeline from a chunk selection.""" from uuid import UUID from core.detect.checkpoint.storage import create_timeline source_asset_id = UUID(req.source_asset_id) if req.source_asset_id else None tid = create_timeline( chunk_paths=req.chunk_paths, profile_name=req.profile_name, name=req.name, source_asset_id=source_asset_id, fps=req.fps, ) from core.detect.checkpoint.storage import get_timeline tl = get_timeline(tid) return TimelineResponse( id=tl["id"], name=tl["name"], chunk_paths=tl["chunk_paths"], profile_name=tl["profile_name"], status=tl["status"], fps=tl["fps"], frame_count=0, source_ephemeral=False, created_at=tl["created_at"], ) @router.get("/timeline", response_model=list[TimelineResponse]) def list_timelines(): """List all timelines.""" from sqlmodel import select from core.db.models import Timeline from core.db.connection import get_session with get_session() as session: stmt = select(Timeline).order_by(Timeline.created_at.desc()) timelines = session.exec(stmt).all() return [ TimelineResponse( id=str(t.id), name=t.name, chunk_paths=t.chunk_paths or [], profile_name=t.profile_name, status=t.status, fps=t.fps, frame_count=t.frame_count, source_ephemeral=t.source_ephemeral, created_at=str(t.created_at) if t.created_at else None, ) for t in timelines ] @router.get("/timeline/{timeline_id}", response_model=TimelineResponse) def get_timeline_endpoint(timeline_id: str): """Get timeline detail.""" from core.detect.checkpoint.storage import get_timeline try: tl = get_timeline(timeline_id) except ValueError: raise HTTPException(status_code=404, detail=f"Timeline not found: {timeline_id}") from core.detect.checkpoint.frames import cache_exists from uuid import UUID from core.db.models import Timeline from core.db.connection import get_session with get_session() as session: timeline = session.get(Timeline, UUID(timeline_id)) return TimelineResponse( id=tl["id"], name=tl["name"], chunk_paths=tl["chunk_paths"], profile_name=tl["profile_name"], status=tl["status"], fps=tl["fps"], frame_count=timeline.frame_count if timeline else 0, source_ephemeral=timeline.source_ephemeral if timeline else False, created_at=tl["created_at"], ) @router.delete("/timeline/{timeline_id}/cache") def clear_timeline_cache(timeline_id: str): """Clear the frame cache for a timeline.""" from core.detect.checkpoint.frames import clear_cache from core.detect.checkpoint.storage import update_timeline_status clear_cache(timeline_id) update_timeline_status(timeline_id, "created") return {"status": "cleared", "timeline_id": timeline_id} # --- Job endpoints --- def _job_to_response(job) -> JobResponse: return JobResponse( id=str(job.id), timeline_id=str(job.timeline_id) if job.timeline_id else None, source_asset_id=str(job.source_asset_id), video_path=job.video_path, profile_name=job.profile_name, run_type=job.run_type, status=job.status, current_stage=job.current_stage, config_overrides=job.config_overrides or {}, error_message=job.error_message, created_at=str(job.created_at) if job.created_at else None, started_at=str(job.started_at) if job.started_at else None, completed_at=str(job.completed_at) if job.completed_at else None, ) @router.get("/jobs", response_model=list[JobResponse]) def list_jobs_endpoint(timeline_id: str | None = Query(None)): """List jobs, optionally filtered by timeline.""" from uuid import UUID from core.db.connection import get_session from core.db.job import list_jobs tid = UUID(timeline_id) if timeline_id else None with get_session() as session: jobs = list_jobs(session, timeline_id=tid) return [_job_to_response(j) for j in jobs] @router.get("/jobs/{job_id}", response_model=JobDetailResponse) def get_job_endpoint(job_id: str): """Get job detail with checkpoints and stage outputs.""" from uuid import UUID from core.db.connection import get_session from core.db.job import get_job from core.detect.checkpoint.storage import ( get_checkpoints_for_job, load_stage_outputs_for_job, ) with get_session() as session: job = get_job(session, UUID(job_id)) if not job: raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") checkpoints = get_checkpoints_for_job(job_id) stage_outputs = load_stage_outputs_for_job(job_id) base = _job_to_response(job) return JobDetailResponse( **base.model_dump(), checkpoints=checkpoints, stage_outputs=stage_outputs, )