phase 4
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
SSE endpoint for chunker pipeline events.
|
||||
|
||||
Uses Redis as the event bus. Pipeline pushes events via core.events,
|
||||
SSE endpoint polls them.
|
||||
|
||||
GET /chunker/stream/{job_id} → text/event-stream
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from core.events import poll_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chunker", tags=["chunker"])
|
||||
|
||||
|
||||
async def _event_generator(job_id: str) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Generate SSE events by polling Redis for chunk job events.
|
||||
"""
|
||||
cursor = 0
|
||||
timeout = time.monotonic() + 600 # 10 min max
|
||||
|
||||
while time.monotonic() < timeout:
|
||||
events, cursor = poll_events(job_id, cursor)
|
||||
|
||||
if not events:
|
||||
yield f"event: waiting\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
|
||||
for data in events:
|
||||
event_type = data.pop("event", "update")
|
||||
payload = {**data, "job_id": job_id}
|
||||
|
||||
yield f"event: {event_type}\ndata: {json.dumps(payload)}\n\n"
|
||||
|
||||
if event_type in ("pipeline_complete", "pipeline_error", "cancelled"):
|
||||
yield f"event: done\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||
return
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
yield f"event: timeout\ndata: {json.dumps({'job_id': job_id})}\n\n"
|
||||
|
||||
|
||||
@router.get("/stream/{job_id}")
|
||||
async def stream_chunk_job(job_id: str):
|
||||
"""
|
||||
SSE stream for a chunk pipeline job.
|
||||
|
||||
The UI connects via native EventSource:
|
||||
const es = new EventSource('/api/chunker/stream/<job_id>');
|
||||
es.addEventListener('processing', (e) => { ... });
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_event_generator(job_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -58,32 +58,30 @@ def write_config(update: ConfigUpdate):
|
||||
|
||||
|
||||
@router.get("/config/profiles")
|
||||
def list_profiles():
|
||||
def get_profiles():
|
||||
"""List available detection profiles."""
|
||||
from detect.profiles import _PROFILES
|
||||
return [{"name": name} for name in _PROFILES]
|
||||
from core.detect.profile import list_profiles as _list
|
||||
return [{"name": name} for name in _list()]
|
||||
|
||||
|
||||
@router.get("/config/profiles/{profile_name}/pipeline")
|
||||
def get_pipeline_config(profile_name: str):
|
||||
"""Return the pipeline composition for a profile."""
|
||||
from detect.profiles import get_profile
|
||||
from core.detect.profile import get_profile
|
||||
from fastapi import HTTPException
|
||||
from dataclasses import asdict
|
||||
|
||||
try:
|
||||
profile = get_profile(profile_name)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown profile: {profile_name}")
|
||||
|
||||
config = profile.pipeline_config()
|
||||
return asdict(config)
|
||||
return profile["pipeline"]
|
||||
|
||||
|
||||
@router.get("/config/stages", response_model=list[StageConfigInfo])
|
||||
def list_stage_configs():
|
||||
"""Return the stage palette with config field metadata for the editor."""
|
||||
from detect.stages import list_stages
|
||||
from core.detect.stages import list_stages
|
||||
|
||||
result = []
|
||||
for stage in list_stages():
|
||||
@@ -95,7 +93,7 @@ def list_stage_configs():
|
||||
@router.get("/config/stages/{stage_name}", response_model=StageConfigInfo)
|
||||
def get_stage_config(stage_name: str):
|
||||
"""Return config field metadata for a single stage."""
|
||||
from detect.stages import get_stage
|
||||
from core.detect.stages import get_stage
|
||||
|
||||
try:
|
||||
stage = get_stage(stage_name)
|
||||
|
||||
@@ -105,7 +105,7 @@ class ReplaySingleStageResponse(BaseModel):
|
||||
@router.get("/checkpoints/{timeline_id}")
|
||||
def list_checkpoints(timeline_id: str) -> list[CheckpointInfo]:
|
||||
"""List available checkpoint stages for a job."""
|
||||
from detect.checkpoint import list_checkpoints as _list
|
||||
from core.detect.checkpoint import list_checkpoints as _list
|
||||
|
||||
try:
|
||||
stages = _list(timeline_id)
|
||||
@@ -139,10 +139,10 @@ class CheckpointData(BaseModel):
|
||||
def get_checkpoint_data(timeline_id: str, stage: str):
|
||||
"""Load checkpoint frames + metadata for the editor UI."""
|
||||
from uuid import UUID
|
||||
from core.db.tables import Timeline, Checkpoint
|
||||
from core.db.models import Timeline, Checkpoint
|
||||
from core.db.connection import get_session
|
||||
from core.db.checkpoint import list_checkpoints
|
||||
from detect.checkpoint.frames import load_frames_b64
|
||||
from core.detect.checkpoint.frames import load_frames_b64
|
||||
|
||||
with get_session() as session:
|
||||
timeline = session.get(Timeline, UUID(timeline_id))
|
||||
@@ -184,7 +184,7 @@ def get_checkpoint_data(timeline_id: str, stage: str):
|
||||
@router.get("/scenarios", response_model=list[ScenarioInfo])
|
||||
def list_scenarios_endpoint():
|
||||
"""List all available scenarios (bookmarked checkpoints)."""
|
||||
from core.db.tables import Timeline
|
||||
from core.db.models import Timeline
|
||||
from core.db.connection import get_session
|
||||
from core.db.checkpoint import list_scenarios
|
||||
|
||||
@@ -212,7 +212,7 @@ def list_scenarios_endpoint():
|
||||
@router.post("/replay", response_model=ReplayResponse)
|
||||
def replay(req: ReplayRequest):
|
||||
"""Replay pipeline from a specific stage with optional config overrides."""
|
||||
from detect.checkpoint import replay_from
|
||||
from core.detect.checkpoint import replay_from
|
||||
|
||||
try:
|
||||
result = replay_from(
|
||||
@@ -242,7 +242,7 @@ def replay(req: ReplayRequest):
|
||||
@router.post("/retry", response_model=RetryResponse)
|
||||
def retry(req: RetryRequest):
|
||||
"""Queue an async retry of unresolved candidates with different config."""
|
||||
from detect.checkpoint.tasks import retry_candidates
|
||||
from core.detect.checkpoint.tasks import retry_candidates
|
||||
|
||||
kwargs = {
|
||||
"timeline_id": req.timeline_id,
|
||||
@@ -266,7 +266,7 @@ def retry(req: RetryRequest):
|
||||
@router.post("/replay-stage", response_model=ReplaySingleStageResponse)
|
||||
def replay_single_stage(req: ReplaySingleStageRequest):
|
||||
"""Replay a single stage on specific frames — fast path for interactive tuning."""
|
||||
from detect.checkpoint.replay import replay_single_stage as _replay
|
||||
from core.detect.checkpoint.replay import replay_single_stage as _replay
|
||||
|
||||
try:
|
||||
result = _replay(
|
||||
@@ -361,3 +361,41 @@ async def gpu_detect_edges_debug(request: Request):
|
||||
media_type="application/json")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"GPU server unreachable: {e}")
|
||||
|
||||
|
||||
@router.post("/gpu/segment_field")
|
||||
async def gpu_segment_field(request: Request):
|
||||
"""Proxy to GPU inference server — field segmentation."""
|
||||
import httpx
|
||||
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{_gpu_url()}/segment_field",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return Response(content=resp.content, status_code=resp.status_code,
|
||||
media_type="application/json")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"GPU server unreachable: {e}")
|
||||
|
||||
|
||||
@router.post("/gpu/segment_field/debug")
|
||||
async def gpu_segment_field_debug(request: Request):
|
||||
"""Proxy to GPU inference server — field segmentation with debug overlay."""
|
||||
import httpx
|
||||
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{_gpu_url()}/segment_field/debug",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return Response(content=resp.content, status_code=resp.status_code,
|
||||
media_type="application/json")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"GPU server unreachable: {e}")
|
||||
|
||||
@@ -60,9 +60,9 @@ def _resolve_video_path(video_path: str) -> str:
|
||||
@router.post("/run", response_model=RunResponse)
|
||||
def run_pipeline(req: RunRequest):
|
||||
"""Launch a detection pipeline run on a source chunk."""
|
||||
from detect import emit
|
||||
from detect.graph import get_pipeline
|
||||
from detect.state import DetectState
|
||||
from core.detect import emit
|
||||
from core.detect.graph import get_pipeline
|
||||
from core.detect.state import DetectState
|
||||
|
||||
local_path = _resolve_video_path(req.video_path)
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -79,7 +79,7 @@ def run_pipeline(req: RunRequest):
|
||||
|
||||
# Clear any stale events from a previous run with same job_id
|
||||
from core.events import _get_redis
|
||||
from detect.events import DETECT_EVENTS_PREFIX
|
||||
from core.detect.events import DETECT_EVENTS_PREFIX
|
||||
r = _get_redis()
|
||||
r.delete(f"{DETECT_EVENTS_PREFIX}:{job_id}")
|
||||
|
||||
@@ -97,7 +97,7 @@ def run_pipeline(req: RunRequest):
|
||||
source_asset_id=req.source_asset_id,
|
||||
)
|
||||
|
||||
from detect.graph import (
|
||||
from core.detect.graph import (
|
||||
PipelineCancelled, set_cancel_check, clear_cancel_check,
|
||||
init_pause, clear_pause,
|
||||
)
|
||||
@@ -117,7 +117,7 @@ def run_pipeline(req: RunRequest):
|
||||
emit.job_complete(job_id, {"status": "cancelled"})
|
||||
except Exception as e:
|
||||
logger.exception("Pipeline run %s failed: %s", job_id, e)
|
||||
from detect.graph import _node_states, NODES
|
||||
from core.detect.graph import _node_states, NODES
|
||||
if job_id in _node_states:
|
||||
states = _node_states[job_id]
|
||||
for node in reversed(NODES):
|
||||
@@ -145,7 +145,7 @@ def run_pipeline(req: RunRequest):
|
||||
@router.post("/stop/{job_id}")
|
||||
def stop_pipeline(job_id: str):
|
||||
"""Stop a running pipeline. Signals cancellation; the thread checks on next stage."""
|
||||
from detect import emit
|
||||
from core.detect import emit
|
||||
|
||||
if job_id not in _running_jobs:
|
||||
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
|
||||
@@ -158,7 +158,7 @@ def stop_pipeline(job_id: str):
|
||||
@router.post("/pause/{job_id}")
|
||||
def pause(job_id: str):
|
||||
"""Pause a running pipeline after the current stage completes."""
|
||||
from detect.graph import pause_pipeline
|
||||
from core.detect.graph import pause_pipeline
|
||||
|
||||
if job_id not in _running_jobs:
|
||||
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
|
||||
@@ -170,7 +170,7 @@ def pause(job_id: str):
|
||||
@router.post("/resume/{job_id}")
|
||||
def resume(job_id: str):
|
||||
"""Resume a paused pipeline."""
|
||||
from detect.graph import resume_pipeline
|
||||
from core.detect.graph import resume_pipeline
|
||||
|
||||
if job_id not in _running_jobs:
|
||||
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
|
||||
@@ -182,7 +182,7 @@ def resume(job_id: str):
|
||||
@router.post("/step/{job_id}")
|
||||
def step(job_id: str):
|
||||
"""Run one stage then pause again."""
|
||||
from detect.graph import step_pipeline
|
||||
from core.detect.graph import step_pipeline
|
||||
|
||||
if job_id not in _running_jobs:
|
||||
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
|
||||
@@ -194,7 +194,7 @@ def step(job_id: str):
|
||||
@router.post("/pause-after-stage/{job_id}")
|
||||
def toggle_pause_after_stage(job_id: str, enabled: bool = True):
|
||||
"""Toggle pause-after-each-stage mode."""
|
||||
from detect.graph import set_pause_after_stage
|
||||
from core.detect.graph import set_pause_after_stage
|
||||
|
||||
if job_id not in _running_jobs:
|
||||
raise HTTPException(status_code=404, detail=f"No running pipeline: {job_id}")
|
||||
@@ -206,7 +206,7 @@ def toggle_pause_after_stage(job_id: str, enabled: bool = True):
|
||||
@router.get("/status/{job_id}")
|
||||
def pipeline_status(job_id: str):
|
||||
"""Get pipeline run status."""
|
||||
from detect.graph import is_paused
|
||||
from core.detect.graph import is_paused
|
||||
|
||||
running = job_id in _running_jobs
|
||||
paused = is_paused(job_id)
|
||||
@@ -224,11 +224,23 @@ def pipeline_status(job_id: str):
|
||||
return {"status": status, "job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/timeline/{job_id}")
|
||||
def get_timeline_for_job(job_id: str):
|
||||
"""Get the timeline_id for a running or completed job."""
|
||||
from core.detect.checkpoint.runner_bridge import get_timeline_id
|
||||
|
||||
tid = get_timeline_id(job_id)
|
||||
if tid is None:
|
||||
raise HTTPException(status_code=404, detail=f"No timeline for job: {job_id}")
|
||||
|
||||
return {"timeline_id": tid, "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/clear/{job_id}")
|
||||
def clear_pipeline(job_id: str):
|
||||
"""Clear events for a job from Redis."""
|
||||
from core.events import _get_redis
|
||||
from detect.events import DETECT_EVENTS_PREFIX
|
||||
from core.detect.events import DETECT_EVENTS_PREFIX
|
||||
|
||||
r = _get_redis()
|
||||
r.delete(f"{DETECT_EVENTS_PREFIX}:{job_id}")
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi import APIRouter
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from core.events import poll_events
|
||||
from detect.events import DETECT_EVENTS_PREFIX, TERMINAL_EVENTS
|
||||
from core.detect.events import DETECT_EVENTS_PREFIX, TERMINAL_EVENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
"""
|
||||
GraphQL API using strawberry, served via FastAPI.
|
||||
|
||||
Primary API for MPR — all client interactions go through GraphQL.
|
||||
Uses core.db for data access.
|
||||
Types are generated from schema/ via modelgen — see api/schema/graphql.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import strawberry
|
||||
from strawberry.schema.config import StrawberryConfig
|
||||
from strawberry.types import Info
|
||||
|
||||
from core.api.schema.graphql import (
|
||||
CancelResultType,
|
||||
ChunkJobType,
|
||||
ChunkOutputFileType,
|
||||
CreateChunkJobInput,
|
||||
CreateJobInput,
|
||||
DeleteResultType,
|
||||
MediaAssetType,
|
||||
ScanResultType,
|
||||
SystemStatusType,
|
||||
TranscodeJobType,
|
||||
TranscodePresetType,
|
||||
UpdateAssetInput,
|
||||
)
|
||||
from core.storage import BUCKET_IN, list_objects, upload_file
|
||||
|
||||
VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv", ".wmv", ".m4v"}
|
||||
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"}
|
||||
MEDIA_EXTS = VIDEO_EXTS | AUDIO_EXTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
@strawberry.field
|
||||
def assets(
|
||||
self,
|
||||
info: Info,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> List[MediaAssetType]:
|
||||
from core.db import list_assets
|
||||
|
||||
return list_assets(status=status, search=search)
|
||||
|
||||
@strawberry.field
|
||||
def asset(self, info: Info, id: UUID) -> Optional[MediaAssetType]:
|
||||
from core.db import get_asset
|
||||
|
||||
try:
|
||||
return get_asset(id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@strawberry.field
|
||||
def jobs(
|
||||
self,
|
||||
info: Info,
|
||||
status: Optional[str] = None,
|
||||
source_asset_id: Optional[UUID] = None,
|
||||
) -> List[TranscodeJobType]:
|
||||
from core.db import list_jobs
|
||||
|
||||
return list_jobs(status=status, source_asset_id=source_asset_id)
|
||||
|
||||
@strawberry.field
|
||||
def job(self, info: Info, id: UUID) -> Optional[TranscodeJobType]:
|
||||
from core.db import get_job
|
||||
|
||||
try:
|
||||
return get_job(id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@strawberry.field
|
||||
def presets(self, info: Info) -> List[TranscodePresetType]:
|
||||
from core.db import list_presets
|
||||
|
||||
return list_presets()
|
||||
|
||||
@strawberry.field
|
||||
def system_status(self, info: Info) -> SystemStatusType:
|
||||
return SystemStatusType(status="ok", version="0.1.0")
|
||||
|
||||
@strawberry.field
|
||||
def chunk_output_files(self, info: Info, job_id: str) -> List[ChunkOutputFileType]:
|
||||
"""List output chunk files for a completed job from media/out/."""
|
||||
from pathlib import Path
|
||||
|
||||
media_out = os.environ.get("MEDIA_OUT_DIR", "/app/media/out")
|
||||
output_dir = Path(media_out) / "chunks" / job_id
|
||||
if not output_dir.is_dir():
|
||||
return []
|
||||
return [
|
||||
ChunkOutputFileType(
|
||||
key=f.name,
|
||||
size=f.stat().st_size,
|
||||
url=f"/media/out/chunks/{job_id}/{f.name}",
|
||||
)
|
||||
for f in sorted(output_dir.iterdir())
|
||||
if f.is_file()
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation
|
||||
def scan_media_folder(self, info: Info) -> ScanResultType:
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.db import create_asset, get_asset_filenames
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sync local media/in/ files to MinIO (handles fresh installs / pruned volumes)
|
||||
local_media = Path("/app/media/in")
|
||||
if local_media.is_dir():
|
||||
existing_keys = {o["key"] for o in list_objects(BUCKET_IN)}
|
||||
for f in local_media.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in MEDIA_EXTS:
|
||||
if f.name not in existing_keys:
|
||||
try:
|
||||
upload_file(str(f), BUCKET_IN, f.name)
|
||||
logger.info("Uploaded %s to MinIO", f.name)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload %s: %s", f.name, e)
|
||||
|
||||
objects = list_objects(BUCKET_IN, extensions=MEDIA_EXTS)
|
||||
existing = get_asset_filenames()
|
||||
|
||||
registered = []
|
||||
skipped = []
|
||||
|
||||
for obj in objects:
|
||||
if obj["filename"] in existing:
|
||||
skipped.append(obj["filename"])
|
||||
continue
|
||||
try:
|
||||
create_asset(
|
||||
filename=obj["filename"],
|
||||
file_path=obj["key"],
|
||||
file_size=obj["size"],
|
||||
)
|
||||
registered.append(obj["filename"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ScanResultType(
|
||||
found=len(objects),
|
||||
registered=len(registered),
|
||||
skipped=len(skipped),
|
||||
files=registered,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
def create_job(self, info: Info, input: CreateJobInput) -> TranscodeJobType:
|
||||
from pathlib import Path
|
||||
|
||||
from core.db import create_job, get_asset, get_preset
|
||||
|
||||
try:
|
||||
source = get_asset(input.source_asset_id)
|
||||
except Exception:
|
||||
raise Exception("Source asset not found")
|
||||
|
||||
preset = None
|
||||
preset_snapshot = {}
|
||||
if input.preset_id:
|
||||
try:
|
||||
preset = get_preset(input.preset_id)
|
||||
preset_snapshot = {
|
||||
"name": preset.name,
|
||||
"container": preset.container,
|
||||
"video_codec": preset.video_codec,
|
||||
"audio_codec": preset.audio_codec,
|
||||
}
|
||||
except Exception:
|
||||
raise Exception("Preset not found")
|
||||
|
||||
if not preset and not input.trim_start and not input.trim_end:
|
||||
raise Exception("Must specify preset_id or trim_start/trim_end")
|
||||
|
||||
output_filename = input.output_filename
|
||||
if not output_filename:
|
||||
stem = Path(source.filename).stem
|
||||
ext = preset_snapshot.get("container", "mp4") if preset else "mp4"
|
||||
output_filename = f"{stem}_output.{ext}"
|
||||
|
||||
job = create_job(
|
||||
source_asset_id=source.id,
|
||||
preset_id=preset.id if preset else None,
|
||||
preset_snapshot=preset_snapshot,
|
||||
trim_start=input.trim_start,
|
||||
trim_end=input.trim_end,
|
||||
output_filename=output_filename,
|
||||
output_path=output_filename,
|
||||
priority=input.priority or 0,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"source_key": source.file_path,
|
||||
"output_key": output_filename,
|
||||
"preset": preset_snapshot or None,
|
||||
"trim_start": input.trim_start,
|
||||
"trim_end": input.trim_end,
|
||||
"duration": source.duration,
|
||||
}
|
||||
|
||||
executor_mode = os.environ.get("MPR_EXECUTOR", "local")
|
||||
if executor_mode in ("lambda", "gcp"):
|
||||
from core.jobs.executor import get_executor
|
||||
|
||||
get_executor().run(
|
||||
job_type="transcode",
|
||||
job_id=str(job.id),
|
||||
payload=payload,
|
||||
)
|
||||
else:
|
||||
from core.jobs.task import run_job
|
||||
|
||||
result = run_job.delay(
|
||||
job_type="transcode",
|
||||
job_id=str(job.id),
|
||||
payload=payload,
|
||||
)
|
||||
job.celery_task_id = result.id
|
||||
job.save(update_fields=["celery_task_id"])
|
||||
|
||||
return job
|
||||
|
||||
@strawberry.mutation
|
||||
def cancel_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
||||
from core.db import get_job, update_job
|
||||
|
||||
try:
|
||||
job = get_job(id)
|
||||
except Exception:
|
||||
raise Exception("Job not found")
|
||||
|
||||
if job.status not in ("pending", "processing"):
|
||||
raise Exception(f"Cannot cancel job with status: {job.status}")
|
||||
|
||||
return update_job(job, status="cancelled")
|
||||
|
||||
@strawberry.mutation
|
||||
def retry_job(self, info: Info, id: UUID) -> TranscodeJobType:
|
||||
from core.db import get_job, update_job
|
||||
|
||||
try:
|
||||
job = get_job(id)
|
||||
except Exception:
|
||||
raise Exception("Job not found")
|
||||
|
||||
if job.status != "failed":
|
||||
raise Exception("Only failed jobs can be retried")
|
||||
|
||||
return update_job(job, status="pending", progress=0, error_message=None)
|
||||
|
||||
@strawberry.mutation
|
||||
def update_asset(self, info: Info, id: UUID, input: UpdateAssetInput) -> MediaAssetType:
|
||||
from core.db import get_asset, update_asset
|
||||
|
||||
try:
|
||||
asset = get_asset(id)
|
||||
except Exception:
|
||||
raise Exception("Asset not found")
|
||||
|
||||
fields = {}
|
||||
if input.comments is not None:
|
||||
fields["comments"] = input.comments
|
||||
if input.tags is not None:
|
||||
fields["tags"] = input.tags
|
||||
|
||||
if fields:
|
||||
asset = update_asset(asset, **fields)
|
||||
|
||||
return asset
|
||||
|
||||
@strawberry.mutation
|
||||
def delete_asset(self, info: Info, id: UUID) -> DeleteResultType:
|
||||
from core.db import delete_asset, get_asset
|
||||
|
||||
try:
|
||||
asset = get_asset(id)
|
||||
delete_asset(asset)
|
||||
return DeleteResultType(ok=True)
|
||||
except Exception:
|
||||
raise Exception("Asset not found")
|
||||
|
||||
@strawberry.mutation
|
||||
def create_chunk_job(self, info: Info, input: CreateChunkJobInput) -> ChunkJobType:
|
||||
"""Create and dispatch a chunk pipeline job."""
|
||||
import uuid
|
||||
|
||||
from core.db import get_asset
|
||||
|
||||
try:
|
||||
source = get_asset(input.source_asset_id)
|
||||
except Exception:
|
||||
raise Exception("Source asset not found")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
payload = {
|
||||
"source_key": source.file_path,
|
||||
"chunk_duration": input.chunk_duration,
|
||||
"num_workers": input.num_workers,
|
||||
"max_retries": input.max_retries,
|
||||
"processor_type": input.processor_type,
|
||||
"start_time": input.start_time,
|
||||
"end_time": input.end_time,
|
||||
}
|
||||
|
||||
executor_mode = os.environ.get("MPR_EXECUTOR", "local")
|
||||
celery_task_id = None
|
||||
|
||||
if executor_mode in ("lambda", "gcp"):
|
||||
from core.jobs.executor import get_executor
|
||||
|
||||
get_executor().run(
|
||||
job_type="chunk",
|
||||
job_id=job_id,
|
||||
payload=payload,
|
||||
)
|
||||
else:
|
||||
from core.jobs.task import run_job
|
||||
|
||||
result = run_job.delay(
|
||||
job_type="chunk",
|
||||
job_id=job_id,
|
||||
payload=payload,
|
||||
)
|
||||
celery_task_id = result.id
|
||||
|
||||
return ChunkJobType(
|
||||
id=uuid.UUID(job_id),
|
||||
source_asset_id=input.source_asset_id,
|
||||
chunk_duration=input.chunk_duration,
|
||||
num_workers=input.num_workers,
|
||||
max_retries=input.max_retries,
|
||||
processor_type=input.processor_type,
|
||||
status="pending",
|
||||
progress=0.0,
|
||||
priority=input.priority,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
def cancel_chunk_job(self, info: Info, celery_task_id: str) -> CancelResultType:
|
||||
"""Cancel a running chunk job by revoking its Celery task."""
|
||||
try:
|
||||
from admin.mpr.celery import app as celery_app
|
||||
|
||||
celery_app.control.revoke(celery_task_id, terminate=True, signal="SIGTERM")
|
||||
return CancelResultType(ok=True, message="Task revoked")
|
||||
except Exception as e:
|
||||
return CancelResultType(ok=False, message=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
schema = strawberry.Schema(
|
||||
query=Query,
|
||||
mutation=Mutation,
|
||||
config=StrawberryConfig(auto_camel_case=False),
|
||||
)
|
||||
@@ -1,48 +1,38 @@
|
||||
"""
|
||||
MPR FastAPI Application
|
||||
|
||||
Serves GraphQL API and Lambda callback endpoint.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from strawberry.fastapi import GraphQLRouter
|
||||
|
||||
from core.api.chunker_sse import router as chunker_router
|
||||
from core.api.detect import router as detect_router
|
||||
from core.api.graphql import schema as graphql_schema
|
||||
|
||||
CALLBACK_API_KEY = os.environ.get("CALLBACK_API_KEY", "")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Create/reset DB tables on startup
|
||||
from core.db.connection import create_tables
|
||||
from core.db.seed import seed_profiles
|
||||
create_tables()
|
||||
seed_profiles()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MPR API",
|
||||
description="Media Processor — GraphQL API",
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://mpr.local.ar", "http://k8s.mpr.local.ar", "http://localhost:5173"],
|
||||
@@ -51,13 +41,6 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# GraphQL
|
||||
graphql_router = GraphQLRouter(schema=graphql_schema, graphql_ide="graphiql")
|
||||
app.include_router(graphql_router, prefix="/graphql")
|
||||
|
||||
# Chunker SSE
|
||||
app.include_router(chunker_router)
|
||||
|
||||
# Detection API (sources, run, SSE, replay, config)
|
||||
app.include_router(detect_router)
|
||||
|
||||
@@ -69,48 +52,7 @@ def health():
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""API root."""
|
||||
return {
|
||||
"name": "MPR API",
|
||||
"version": "0.1.0",
|
||||
"graphql": "/graphql",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/callback")
|
||||
def job_callback(
|
||||
job_id: UUID,
|
||||
payload: dict,
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
):
|
||||
"""
|
||||
Callback endpoint for Lambda to report job completion.
|
||||
Protected by API key.
|
||||
"""
|
||||
if CALLBACK_API_KEY and x_api_key != CALLBACK_API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from core.db import get_job, update_job
|
||||
|
||||
try:
|
||||
job = get_job(job_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
status = payload.get("status", "failed")
|
||||
fields = {
|
||||
"status": status,
|
||||
"progress": 100.0 if status == "completed" else job.progress,
|
||||
}
|
||||
|
||||
if payload.get("error"):
|
||||
fields["error_message"] = payload["error"]
|
||||
|
||||
if status in ("completed", "failed"):
|
||||
fields["completed_at"] = timezone.now()
|
||||
|
||||
update_job(job, **fields)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Strawberry Types - GENERATED FILE
|
||||
|
||||
Do not edit directly. Regenerate using modelgen.
|
||||
"""
|
||||
|
||||
import strawberry
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from strawberry.scalars import JSON
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
class AssetStatus(Enum):
|
||||
PENDING = "pending"
|
||||
READY = "ready"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
class JobStatus(Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class MediaAssetType:
|
||||
"""A video/audio file registered in the system."""
|
||||
|
||||
id: Optional[UUID] = None
|
||||
filename: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
file_size: Optional[float] = None
|
||||
duration: Optional[float] = None
|
||||
video_codec: Optional[str] = None
|
||||
audio_codec: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
framerate: Optional[float] = None
|
||||
bitrate: Optional[int] = None
|
||||
properties: Optional[JSON] = None
|
||||
comments: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class TranscodePresetType:
|
||||
"""A reusable transcoding configuration (like Handbrake presets)."""
|
||||
|
||||
id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_builtin: Optional[bool] = None
|
||||
container: Optional[str] = None
|
||||
video_codec: Optional[str] = None
|
||||
video_bitrate: Optional[str] = None
|
||||
video_crf: Optional[int] = None
|
||||
video_preset: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
framerate: Optional[float] = None
|
||||
audio_codec: Optional[str] = None
|
||||
audio_bitrate: Optional[str] = None
|
||||
audio_channels: Optional[int] = None
|
||||
audio_samplerate: Optional[int] = None
|
||||
extra_args: Optional[List[str]] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class TranscodeJobType:
|
||||
"""A transcoding or trimming job in the queue."""
|
||||
|
||||
id: Optional[UUID] = None
|
||||
source_asset_id: Optional[UUID] = None
|
||||
preset_id: Optional[UUID] = None
|
||||
preset_snapshot: Optional[JSON] = None
|
||||
trim_start: Optional[float] = None
|
||||
trim_end: Optional[float] = None
|
||||
output_filename: Optional[str] = None
|
||||
output_path: Optional[str] = None
|
||||
output_asset_id: Optional[UUID] = None
|
||||
status: Optional[str] = None
|
||||
progress: Optional[float] = None
|
||||
current_frame: Optional[int] = None
|
||||
current_time: Optional[float] = None
|
||||
speed: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
celery_task_id: Optional[str] = None
|
||||
execution_arn: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CreateJobInput:
|
||||
"""Request body for creating a transcode/trim job."""
|
||||
|
||||
source_asset_id: UUID
|
||||
preset_id: Optional[UUID] = None
|
||||
trim_start: Optional[float] = None
|
||||
trim_end: Optional[float] = None
|
||||
output_filename: Optional[str] = None
|
||||
priority: int = 0
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class UpdateAssetInput:
|
||||
"""Request body for updating asset metadata."""
|
||||
|
||||
comments: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class SystemStatusType:
|
||||
"""System status response."""
|
||||
|
||||
status: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ScanResultType:
|
||||
"""Result of scanning the media input bucket."""
|
||||
|
||||
found: Optional[int] = None
|
||||
registered: Optional[int] = None
|
||||
skipped: Optional[int] = None
|
||||
files: Optional[List[str]] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class DeleteResultType:
|
||||
"""Result of a delete operation."""
|
||||
|
||||
ok: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class WorkerStatusType:
|
||||
"""Worker health and capabilities."""
|
||||
|
||||
available: Optional[bool] = None
|
||||
active_jobs: Optional[int] = None
|
||||
supported_codecs: Optional[List[str]] = None
|
||||
gpu_available: Optional[bool] = None
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
class ChunkJobStatus(Enum):
|
||||
PENDING = "pending"
|
||||
CHUNKING = "chunking"
|
||||
PROCESSING = "processing"
|
||||
COLLECTING = "collecting"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ChunkJobType:
|
||||
"""A chunk pipeline job."""
|
||||
|
||||
id: Optional[UUID] = None
|
||||
source_asset_id: Optional[UUID] = None
|
||||
chunk_duration: Optional[float] = None
|
||||
num_workers: Optional[int] = None
|
||||
max_retries: Optional[int] = None
|
||||
processor_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
progress: Optional[float] = None
|
||||
total_chunks: Optional[int] = None
|
||||
processed_chunks: Optional[int] = None
|
||||
failed_chunks: Optional[int] = None
|
||||
retry_count: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
throughput_mbps: Optional[float] = None
|
||||
elapsed_seconds: Optional[float] = None
|
||||
celery_task_id: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CreateChunkJobInput:
|
||||
"""Request body for creating a chunk pipeline job."""
|
||||
|
||||
source_asset_id: UUID
|
||||
chunk_duration: float = 10.0
|
||||
num_workers: int = 4
|
||||
max_retries: int = 3
|
||||
processor_type: str = "ffmpeg"
|
||||
priority: int = 0
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class CancelResultType:
|
||||
"""Result of cancelling a chunk job."""
|
||||
|
||||
ok: bool = False
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ChunkOutputFileType:
|
||||
"""A chunk output file in S3/MinIO with presigned download URL."""
|
||||
|
||||
key: str
|
||||
size: int = 0
|
||||
url: str = ""
|
||||
Reference in New Issue
Block a user