Spaces:
Sleeping
Sleeping
| """ | |
| LectureLens β FastAPI Application | |
| Main entry point: routes, startup, error handlers. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import tempfile | |
| import time | |
| import uuid | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from typing import Optional | |
| from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, UploadFile, status | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from app.alert_engine import ( | |
| compute_audio_score, | |
| compute_video_score, | |
| generate_alerts, | |
| load_thresholds, | |
| ) | |
| from app.analyzers.audio_analyzer import analyze_audio, _get_dnsmos_session | |
| from app.config import Settings, get_settings | |
| from app.schemas import ( | |
| AnalysisResponse, | |
| ErrorResponse, | |
| HealthResponse, | |
| JobStatus, | |
| JobSubmitResponse, | |
| JobResultResponse, | |
| MediaType, | |
| ) | |
| from app.utils import ( | |
| AUDIO_EXTENSIONS, | |
| VIDEO_EXTENSIONS, | |
| detect_extension, | |
| validate_extension, | |
| ) | |
| # ββ Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)-8s | %(name)s β %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| logger = logging.getLogger("lecturelens.main") | |
| # ββ Global Jobs DB (In-Memory) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Since HF Spaces free tier sleeps and loses memory, this is ephemeral. | |
| # Keys are job_ids (UUIDs). | |
| jobs_db: dict[str, dict] = {} | |
| # ββ Lifespan (startup/shutdown) βββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def lifespan(app: FastAPI): | |
| settings = get_settings() | |
| logger.info("π LectureLens API starting upβ¦") | |
| # Pre-load heavy models so first request is fast | |
| logger.info("Pre-loading DNSMOS modelβ¦") | |
| _get_dnsmos_session(settings.dnsmos_model_path) | |
| # Warm up thresholds cache | |
| logger.info("Loading thresholds from %sβ¦", settings.thresholds_config_path) | |
| try: | |
| load_thresholds(settings.thresholds_config_path) | |
| except FileNotFoundError: | |
| logger.warning("thresholds.yaml not found β alert engine will use code defaults") | |
| logger.info("β LectureLens API ready.") | |
| yield | |
| logger.info("LectureLens API shutting down.") | |
| # ββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="LectureLens API", | |
| description=( | |
| "**LectureLens** β Technical audio & video quality analysis for Zoom lecture recordings.\n\n" | |
| "Accepts a single audio or video file per request and returns:\n" | |
| "- Detailed KPI metrics (loudness, SNR, sharpness, brightness, β¦)\n" | |
| "- Structured alerts with severity levels and actionable fixes\n" | |
| "- A composite quality score (0β1)\n\n" | |
| "*(Note: API Key authentication is currently disabled for testing)*" | |
| ), | |
| version="1.0.0", | |
| docs_url="/docs", | |
| redoc_url="/redoc", | |
| lifespan=lifespan, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Custom exception handlers βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def http_exception_handler(request, exc: HTTPException): | |
| detail = exc.detail | |
| if isinstance(detail, dict): | |
| return JSONResponse(status_code=exc.status_code, content=detail) | |
| return JSONResponse( | |
| status_code=exc.status_code, | |
| content={"error_code": "HTTP_ERROR", "detail": str(detail)}, | |
| ) | |
| async def generic_exception_handler(request, exc: Exception): | |
| logger.exception("Unhandled exception: %s", exc) | |
| return JSONResponse( | |
| status_code=500, | |
| content={"error_code": "PROCESSING_FAILED", "detail": str(exc)}, | |
| ) | |
| # ββ Background Task βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def process_job_background( | |
| job_id: str, | |
| tmp_path: Path, | |
| media_type: MediaType, | |
| participant_label: Optional[str], | |
| settings: Settings, | |
| filename: str | |
| ): | |
| """ | |
| Executes the analysis in the background, updates jobs_db, and cleans up the temp file. | |
| Includes extensive logging for debugging. | |
| """ | |
| logger.info(f"[{job_id}] βοΈ Started background processing for file: {filename}") | |
| t_start = time.perf_counter() | |
| try: | |
| if media_type == MediaType.audio: | |
| logger.info(f"[{job_id}] π§ Step 1/3: Starting audio analysis...") | |
| audio_metrics = await analyze_audio(tmp_path, settings.dnsmos_model_path) | |
| logger.info(f"[{job_id}] π§ Step 2/3: Generating alerts against thresholds...") | |
| alerts = generate_alerts(audio_metrics, "audio", settings.thresholds_config_path) | |
| logger.info(f"[{job_id}] π§ Step 3/3: Computing composite audio score...") | |
| audio_score = compute_audio_score(audio_metrics) | |
| video_metrics = None | |
| video_score = None | |
| else: | |
| logger.info(f"[{job_id}] π¬ Step 1/4: Starting video analysis...") | |
| from app.analyzers.video_analyzer import analyze_video | |
| video_metrics = await analyze_video(tmp_path, settings.frame_sample_rate_seconds) | |
| logger.info(f"[{job_id}] π¬ Step 2/4: Extracting audio and starting audio analysis...") | |
| from app.utils import run_ffmpeg_async | |
| tmp_audio_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) | |
| try: | |
| # Extract audio to 16kHz mono WAV | |
| await run_ffmpeg_async([ | |
| "-i", str(tmp_path), | |
| "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", | |
| str(tmp_audio_path) | |
| ]) | |
| audio_metrics = await analyze_audio(tmp_audio_path, settings.dnsmos_model_path) | |
| except Exception as exc: | |
| logger.warning(f"[{job_id}] β οΈ Failed to extract/analyze audio from video: {exc}") | |
| audio_metrics = None | |
| finally: | |
| if tmp_audio_path.exists(): | |
| try: os.unlink(tmp_audio_path) | |
| except: pass | |
| logger.info(f"[{job_id}] π¬ Step 3/4: Generating alerts against thresholds...") | |
| alerts = generate_alerts(video_metrics, "video", settings.thresholds_config_path) | |
| if audio_metrics: | |
| audio_alerts = generate_alerts(audio_metrics, "audio", settings.thresholds_config_path) | |
| alerts.extend(audio_alerts) | |
| logger.info(f"[{job_id}] π¬ Step 4/4: Computing composite video score...") | |
| video_score = compute_video_score(video_metrics) | |
| audio_score = compute_audio_score(audio_metrics) if audio_metrics else None | |
| processing_time = round(time.perf_counter() - t_start, 3) | |
| logger.info(f"[{job_id}] β Analysis completed successfully in {processing_time}s with {len(alerts)} alert(s).") | |
| result = AnalysisResponse( | |
| media_type=media_type, | |
| participant_label=participant_label, | |
| processing_time_seconds=processing_time, | |
| audio_metrics=audio_metrics, | |
| video_metrics=video_metrics, | |
| alerts=alerts, | |
| overall_audio_score=audio_score, | |
| overall_video_score=video_score, | |
| ) | |
| jobs_db[job_id]["status"] = JobStatus.completed | |
| jobs_db[job_id]["result"] = result | |
| except Exception as exc: | |
| logger.exception(f"[{job_id}] β Error processing file {filename}: {exc}") | |
| jobs_db[job_id]["status"] = JobStatus.failed | |
| jobs_db[job_id]["error_detail"] = str(exc) | |
| finally: | |
| logger.info(f"[{job_id}] π§Ή Cleaning up temporary file: {tmp_path}") | |
| try: | |
| os.unlink(tmp_path) | |
| except Exception as e: | |
| logger.warning(f"[{job_id}] β οΈ Failed to delete temp file {tmp_path}: {e}") | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health(): | |
| """Returns service status and version.""" | |
| return HealthResponse() | |
| async def analyze( | |
| background_tasks: BackgroundTasks, | |
| file: UploadFile = File(..., description="Audio (M4A/WAV/MP3) or video (MP4/MOV) file"), | |
| media_type: MediaType = Form(..., description="'audio' or 'video'"), | |
| participant_label: Optional[str] = Form( | |
| None, description="Speaker name/role β returned as-is for easy n8n mapping" | |
| ), | |
| language: str = Form("ar", description="Recording language code (reserved for future use)"), | |
| settings: Settings = Depends(get_settings), | |
| ): | |
| """ | |
| **POST /analyze** β Submit a file and receive a `job_id`. | |
| The file is processed asynchronously in the background. Use `GET /analyze/{job_id}` | |
| to poll for the results to avoid HTTP timeouts. | |
| """ | |
| # ββ 1. File size guard ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| content = await file.read() | |
| if len(content) > settings.max_file_size_bytes: | |
| raise HTTPException( | |
| status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, | |
| detail={ | |
| "error_code": "FILE_TOO_LARGE", | |
| "detail": ( | |
| f"File size {len(content) / 1e6:.1f} MB exceeds " | |
| f"the {settings.max_file_size_mb} MB limit." | |
| ), | |
| }, | |
| ) | |
| # ββ 2. Extension validation βββββββββββββββββββββββββββββββββββββββββββββββ | |
| filename = file.filename or f"upload.{media_type.value}" | |
| ext = detect_extension(filename) | |
| allowed = AUDIO_EXTENSIONS if media_type == MediaType.audio else VIDEO_EXTENSIONS | |
| if ext not in allowed: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail={ | |
| "error_code": "INVALID_MEDIA_TYPE", | |
| "detail": ( | |
| f"Extension '{ext}' is not supported for media_type='{media_type.value}'. " | |
| f"Allowed: {sorted(allowed)}" | |
| ), | |
| }, | |
| ) | |
| # ββ 3. Save to temp file and dispatch background task βββββββββββββββββββββ | |
| try: | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext) | |
| tmp.write(content) | |
| tmp.flush() | |
| tmp.close() | |
| tmp_path = Path(tmp.name) | |
| job_id = str(uuid.uuid4()) | |
| jobs_db[job_id] = { | |
| "status": JobStatus.processing, | |
| "filename": filename, | |
| "result": None, | |
| "error_detail": None | |
| } | |
| logger.info(f"[{job_id}] π₯ Job created for file: {filename} ({len(content) / 1e6:.2f} MB)") | |
| background_tasks.add_task( | |
| process_job_background, | |
| job_id=job_id, | |
| tmp_path=tmp_path, | |
| media_type=media_type, | |
| participant_label=participant_label, | |
| settings=settings, | |
| filename=filename | |
| ) | |
| return JobSubmitResponse( | |
| job_id=job_id, | |
| status=JobStatus.processing, | |
| message="File successfully queued for background analysis." | |
| ) | |
| except Exception as exc: | |
| logger.exception("Failed to queue file '%s': %s", filename, exc) | |
| raise HTTPException( | |
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | |
| detail={"error_code": "QUEUE_FAILED", "detail": f"Could not queue file: {exc}"} | |
| ) | |
| async def get_job_status(job_id: str): | |
| """ | |
| **GET /analyze/{job_id}** β check the status of a background analysis job. | |
| - If `status` is **processing**, wait and poll again. | |
| - If `status` is **completed**, the `result` field will contain the full metrics and alerts. | |
| - If `status` is **failed**, the `error_detail` field will explain why. | |
| """ | |
| job = jobs_db.get(job_id) | |
| if not job: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"error_code": "NOT_FOUND", "detail": f"Job ID {job_id} not found."} | |
| ) | |
| return JobResultResponse( | |
| job_id=job_id, | |
| status=job["status"], | |
| result=job["result"], | |
| error_detail=job["error_detail"] | |
| ) | |