import shutil import time from contextlib import asynccontextmanager import httpx from fastapi import Depends, FastAPI, Header, HTTPException from app.audio import classify_audio from app.config import settings from app.media import download_media from app.profile_store import profile_store from app.schemas import AnalysisRequest, AnalysisResponse, AnalysisType from app.video import VisionModelError, analyze_video def authorize(authorization: str | None = Header(default=None)) -> None: if not settings.api_key: return if authorization != f"Bearer {settings.api_key}": raise HTTPException(status_code=401, detail="Invalid analysis service credential") @asynccontextmanager async def lifespan(_: FastAPI): settings.jobs_dir.mkdir(parents=True, exist_ok=True) settings.artifacts_dir.mkdir(parents=True, exist_ok=True) profile_store.reload() yield app = FastAPI(title="CMVR Media Analysis Service", version="1.0.0", lifespan=lifespan) @app.get("/health") async def health() -> dict: ollama = "DOWN" try: async with httpx.AsyncClient(timeout=3) as client: response = await client.get(f"{settings.ollama_base_url.rstrip('/')}/api/version") response.raise_for_status() ollama = "UP" except Exception: pass return { "status": "UP", "ollama": ollama, "visionModel": settings.ollama_model, "profiles": profile_store.status(), } @app.post( "/api/v1/analysis/run", response_model=AnalysisResponse, dependencies=[Depends(authorize)], ) async def run_analysis(request: AnalysisRequest) -> AnalysisResponse: started = time.monotonic() media_path = None try: profile = profile_store.get(request.profileCode, request.analysisType.value) if request.analysisType == AnalysisType.AUDIO_CLASSIFICATION: media_path = await download_media( str(request.mediaUrl), ".audio", settings.max_audio_bytes ) result = classify_audio(profile, media_path) model = {"provider": "CMVR", "name": "mfcc-dtw-audio-fingerprint-v2"} else: media_path = await download_media( str(request.mediaUrl), ".video", settings.max_video_bytes ) result, duration, video_metadata = await analyze_video( profile, media_path, str(request.options.get("instruction", "")), str(request.options.get("analysisMode", "AUTO")), ) evidence = result.get("evidence") if not isinstance(evidence, dict): evidence = {} result["evidence"] = evidence evidence.update( { "sampledFrameCount": video_metadata["sampledFrameCount"], "maximumWidth": video_metadata["maximumWidth"], "durationSeconds": duration, } ) result["analysisMode"] = video_metadata["mode"] result["fallback"] = video_metadata["fallback"] result["fallbackReason"] = video_metadata["fallbackReason"] model = { "provider": "Ollama", "name": video_metadata["model"], "requestedMode": video_metadata["requestedMode"], "usedMode": video_metadata["mode"], "fallback": video_metadata["fallback"], } return AnalysisResponse( requestId=request.requestId, analysisType=request.analysisType, profileCode=request.profileCode, status="SUCCEEDED", result=result, model=model, timingMs=round((time.monotonic() - started) * 1000), ) except (KeyError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except httpx.HTTPError as exc: raise HTTPException(status_code=502, detail=f"Remote service request failed: {exc}") from exc except VisionModelError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc finally: if media_path is not None: media_path.unlink(missing_ok=True)