lecturelens-api / app /utils.py
aliSaac510's picture
feat: initial LectureLens API v1.0 β€” full audio/video quality analysis service
75ba57a
Raw
History Blame Contribute Delete
3.63 kB
"""
LectureLens β€” FFmpeg / FFprobe Utilities
All subprocess helpers and temp-file management live here.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
# ── FFmpeg runner ─────────────────────────────────────────────────────────────
def run_ffmpeg(args: list[str], *, capture_stderr: bool = True) -> str:
"""
Run FFmpeg synchronously and return stderr (where FFmpeg writes its output).
Raises RuntimeError if the process exits with a non-zero code.
"""
cmd = ["ffmpeg", "-hide_banner", "-y"] + args
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0 and result.returncode != 1:
# FFmpeg uses exit code 1 for warnings in some filter chains β€” tolerate it
raise RuntimeError(
f"FFmpeg failed (exit {result.returncode}):\n{result.stderr}"
)
return result.stderr if capture_stderr else result.stdout
def run_ffprobe(args: list[str]) -> dict[str, Any]:
"""
Run FFprobe with JSON output and return the parsed dict.
"""
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json"] + args
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"FFprobe failed (exit {result.returncode}):\n{result.stderr}"
)
return json.loads(result.stdout)
async def run_ffmpeg_async(args: list[str]) -> str:
"""Async wrapper around run_ffmpeg for use inside async route handlers."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, run_ffmpeg, args)
# ── Temporary file helpers ────────────────────────────────────────────────────
@asynccontextmanager
async def save_upload_to_tempfile(data: bytes, suffix: str):
"""
Async context manager: write upload bytes to a temp file and yield its path.
Guarantees cleanup even if an exception occurs.
"""
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
try:
tmp.write(data)
tmp.flush()
tmp.close()
yield Path(tmp.name)
finally:
try:
os.unlink(tmp.name)
except FileNotFoundError:
pass
# ── Allowed media extensions ──────────────────────────────────────────────────
AUDIO_EXTENSIONS = {".m4a", ".wav", ".mp3", ".aac", ".ogg", ".flac"}
VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
def detect_extension(filename: str) -> str:
"""Return lowercased file extension including the dot, e.g. '.mp4'."""
return Path(filename).suffix.lower()
def validate_extension(filename: str, media_type: str) -> None:
"""Raise ValueError if the extension does not match the declared media_type."""
ext = detect_extension(filename)
allowed = AUDIO_EXTENSIONS if media_type == "audio" else VIDEO_EXTENSIONS
if ext not in allowed:
raise ValueError(
f"File extension '{ext}' is not supported for media_type='{media_type}'. "
f"Allowed: {sorted(allowed)}"
)