Spaces:
Sleeping
Sleeping
File size: 3,633 Bytes
75ba57a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | """
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)}"
)
|