submatch-backend / main.py
Janesh's picture
fix: add root / route for HuggingFace Spaces health check
d16d626
Raw
History Blame Contribute Delete
13.3 kB
"""
SubMatch β€” Audio-Subtitle Mismatch Detector
FastAPI backend with REST + WebSocket + file upload support.
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import (
BackgroundTasks, FastAPI, File, Form, HTTPException,
UploadFile, WebSocket, WebSocketDisconnect,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from config.settings import settings, TEMP_PATH, SUPPORTED_LANGUAGES
from modules.downloader import VideoDownloader
from modules.transcriber import AudioTranscriber
from modules.subtitle_parser import SubtitleParser
from modules.ocr_extractor import OCRExtractor
from modules.mismatch_detector import MismatchDetector
from modules.report_generator import ReportGenerator
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI(title="SubMatch", description="Audio-Subtitle Mismatch Detector", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tightened per-origin via FRONTEND_URL in production env
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ── In-memory job store ───────────────────────────────────────────────────────
jobs: dict[str, dict] = {}
job_ws_clients: dict[str, list[WebSocket]] = {}
MAX_UPLOAD_SIZE_MB = 500
# ── Helpers ───────────────────────────────────────────────────────────────────
async def _broadcast(job_id: str):
if job_id not in job_ws_clients:
return
payload = {k: v for k, v in jobs[job_id].items() if k != "report_data"}
dead = []
for ws in job_ws_clients[job_id]:
try:
await ws.send_json(payload)
except Exception:
dead.append(ws)
for ws in dead:
job_ws_clients[job_id].remove(ws)
def _push(job_id: str, updates: dict, loop: asyncio.AbstractEventLoop):
jobs[job_id].update(updates)
asyncio.run_coroutine_threadsafe(_broadcast(job_id), loop).result(timeout=5)
def _new_job(job_id: str, source_label: str) -> dict:
return {
"job_id": job_id,
"status": "pending",
"progress": 0,
"current_step": "Initializing…",
"steps_completed": [],
"error": None,
"created_at": datetime.now().isoformat(),
"source": source_label,
"report_data": None,
}
# ── Core pipeline (shared by URL and file-upload paths) ─────────────────────
def _run_pipeline(
job_id: str,
video_path: str,
subtitle_paths: list[str],
source_language: str,
subtitle_language: str,
whisper_model: str,
similarity_threshold: float,
use_ocr_fallback: bool,
loop: asyncio.AbstractEventLoop,
):
job_dir = TEMP_PATH / job_id
steps_completed: list[str] = []
def push(step: str, progress: int, done: str | None = None):
if done and done not in steps_completed:
steps_completed.append(done)
_push(job_id, {
"status": "processing",
"progress": progress,
"current_step": step,
"steps_completed": list(steps_completed),
}, loop)
try:
# ── Transcribe ───────────────────────────────────────────────────────
push("Transcribing audio with Whisper…", 30)
transcriber = AudioTranscriber(model_size=whisper_model)
whisper_lang = None if source_language == "auto" else source_language
audio_segments = transcriber.transcribe(video_path, language=whisper_lang)
push("Transcription complete", 55, "Audio Transcription")
# ── Extract subtitles ─────────────────────────────────────────────────
subtitle_segments: list[dict] = []
if subtitle_paths:
push("Parsing subtitle file…", 60)
parser = SubtitleParser()
subtitle_segments = parser.parse(subtitle_paths[0])
push("Subtitle parsing complete", 72, "Subtitle Extraction")
elif use_ocr_fallback:
push("No subtitle file β€” running OCR on frames…", 62)
ocr = OCRExtractor(language=subtitle_language)
subtitle_segments = ocr.extract_from_video(video_path, audio_segments)
push("OCR extraction complete", 72, "Subtitle Extraction (OCR)")
else:
push("No subtitles available", 72, "Subtitle Extraction")
# ── Detect mismatches ─────────────────────────────────────────────────
push("Detecting mismatches…", 78)
low_threshold = max(0.0, similarity_threshold - 0.20)
detector = MismatchDetector(high_threshold=similarity_threshold, low_threshold=low_threshold)
results = detector.compare(audio_segments, subtitle_segments)
push("Mismatch detection complete", 88, "Mismatch Detection")
# ── Generate report ───────────────────────────────────────────────────
push("Generating report…", 93)
report_path = str(job_dir / "report.html")
source_label = jobs[job_id].get("source", "")
generator = ReportGenerator()
report_data = generator.generate(results=results, video_url=source_label, output_path=report_path)
(job_dir / "report.json").write_text(json.dumps(report_data, ensure_ascii=False, indent=2), encoding="utf-8")
_push(job_id, {
"status": "completed",
"progress": 100,
"current_step": "Analysis complete!",
"steps_completed": ["Download / Upload", "Audio Transcription", "Subtitle Extraction", "Mismatch Detection", "Report Generation"],
"report_data": report_data,
}, loop)
except Exception as exc:
logger.exception("Pipeline failed for job %s", job_id)
_push(job_id, {
"status": "failed",
"progress": jobs[job_id].get("progress", 0),
"current_step": "Error occurred",
"error": str(exc),
}, loop)
# ── URL-based job ─────────────────────────────────────────────────────────────
class AnalyzeRequest(BaseModel):
url: str
source_language: str = "auto"
subtitle_language: str = "hi"
whisper_model: str = "medium"
similarity_threshold: float = 0.75
use_ocr_fallback: bool = True
@app.post("/api/jobs")
async def create_job_from_url(req: AnalyzeRequest):
job_id = str(uuid.uuid4())
job_dir = TEMP_PATH / job_id
job_dir.mkdir(parents=True, exist_ok=True)
jobs[job_id] = _new_job(job_id, req.url)
loop = asyncio.get_event_loop()
def _download_then_run():
try:
_push(job_id, {"status": "processing", "progress": 5, "current_step": "Downloading video and subtitles…"}, loop)
downloader = VideoDownloader(str(job_dir))
video_path, subtitle_paths = downloader.download(req.url, subtitle_language=req.subtitle_language)
_push(job_id, {"progress": 25, "current_step": "Download complete", "steps_completed": ["Download / Upload"]}, loop)
_run_pipeline(
job_id, video_path, subtitle_paths,
req.source_language, req.subtitle_language,
req.whisper_model, req.similarity_threshold, req.use_ocr_fallback, loop,
)
except Exception as exc:
logger.exception("Download failed for job %s", job_id)
_push(job_id, {"status": "failed", "progress": 0, "current_step": "Download failed", "error": str(exc)}, loop)
threading.Thread(target=_download_then_run, daemon=True).start()
return {"job_id": job_id}
# ── File-upload job ───────────────────────────────────────────────────────────
@app.post("/api/jobs/upload")
async def create_job_from_upload(
video: UploadFile = File(...),
subtitle_file: Optional[UploadFile] = File(None),
source_language: str = Form("auto"),
subtitle_language: str = Form("hi"),
whisper_model: str = Form("medium"),
similarity_threshold: float = Form(0.75),
use_ocr_fallback: bool = Form(True),
):
job_id = str(uuid.uuid4())
job_dir = TEMP_PATH / job_id
job_dir.mkdir(parents=True, exist_ok=True)
# Save uploaded video
video_suffix = Path(video.filename or "video.mp4").suffix or ".mp4"
video_path = job_dir / f"video{video_suffix}"
with video_path.open("wb") as f:
shutil.copyfileobj(video.file, f)
# Save optional subtitle file
subtitle_paths: list[str] = []
if subtitle_file and subtitle_file.filename:
sub_suffix = Path(subtitle_file.filename).suffix or ".vtt"
sub_path = job_dir / f"subtitle{sub_suffix}"
with sub_path.open("wb") as f:
shutil.copyfileobj(subtitle_file.file, f)
subtitle_paths = [str(sub_path)]
source_label = video.filename or "uploaded_video"
jobs[job_id] = _new_job(job_id, source_label)
loop = asyncio.get_event_loop()
def _run():
_push(job_id, {
"status": "processing", "progress": 25,
"current_step": "File uploaded, starting transcription…",
"steps_completed": ["Download / Upload"],
}, loop)
_run_pipeline(
job_id, str(video_path), subtitle_paths,
source_language, subtitle_language,
whisper_model, similarity_threshold, use_ocr_fallback, loop,
)
threading.Thread(target=_run, daemon=True).start()
return {"job_id": job_id}
# ── Read endpoints ────────────────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
if job_id not in jobs:
raise HTTPException(status_code=404, detail="Job not found")
return {k: v for k, v in jobs[job_id].items() if k != "report_data"}
@app.get("/api/jobs/{job_id}/report")
async def get_report_json(job_id: str):
if job_id not in jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = jobs[job_id]
if job["status"] != "completed":
raise HTTPException(status_code=400, detail=f"Job not completed (status: {job['status']})")
return job.get("report_data") or {}
@app.get("/api/jobs/{job_id}/report.html")
async def get_report_html(job_id: str):
report_path = TEMP_PATH / job_id / "report.html"
if not report_path.exists():
raise HTTPException(status_code=404, detail="Report not generated yet")
return FileResponse(str(report_path), media_type="text/html")
@app.get("/api/languages")
async def get_languages():
return [{"code": c, "name": i["name"]} for c, i in SUPPORTED_LANGUAGES.items()]
@app.get("/")
async def root():
return {
"name": "SubMatch API",
"version": "2.0.0",
"status": "running",
"docs": "/docs",
"health": "/health",
}
@app.get("/health")
async def health():
return {"status": "ok", "version": "2.0.0", "timestamp": datetime.now().isoformat()}
# ── WebSocket ─────────────────────────────────────────────────────────────────
@app.websocket("/ws/{job_id}")
async def ws_endpoint(websocket: WebSocket, job_id: str):
await websocket.accept()
if job_id not in job_ws_clients:
job_ws_clients[job_id] = []
job_ws_clients[job_id].append(websocket)
if job_id in jobs:
await websocket.send_json({k: v for k, v in jobs[job_id].items() if k != "report_data"})
try:
while True:
await asyncio.wait_for(websocket.receive_text(), timeout=30)
except (WebSocketDisconnect, asyncio.TimeoutError):
pass
finally:
if job_id in job_ws_clients and websocket in job_ws_clients[job_id]:
job_ws_clients[job_id].remove(websocket)
if __name__ == "__main__":
import os
import uvicorn
# Railway injects PORT; fall back to settings value for local dev
port = int(os.environ.get("PORT", settings.BACKEND_PORT))
uvicorn.run("main:app", host=settings.BACKEND_HOST, port=port, reload=False)