Harden ffmpeg subprocess handling and Windows loop policy

This commit is contained in:
2026-05-24 18:06:07 +00:00
parent 94f2f10dcd
commit b574c974a7
4 changed files with 308 additions and 261 deletions
+13
View File
@@ -1,4 +1,5 @@
import argparse
import asyncio
import json
import os
import sys
@@ -10,6 +11,16 @@ DEFAULT_PORT = 8420
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def _derive_name(path: str) -> str:
"""Derive a root name from a path."""
p = Path(path)
@@ -17,6 +28,8 @@ def _derive_name(path: str) -> str:
def main():
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(
description="MediaHive - Media scanning, indexing, and streaming"
)
+14
View File
@@ -1,11 +1,25 @@
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def main():
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(
description="Hivescan server — continuous media scanning with live WS updates.",
formatter_class=argparse.RawDescriptionHelpFormatter,
+268 -261
View File
@@ -22,6 +22,7 @@ from aiopathlib import AsyncPath
from mediahive.hivescan.utils import classify_resolution_from_dimensions
logger = logging.getLogger("hivescan.showreel")
_ffmpeg_not_found_logged = False
# Suppress console windows when spawning subprocesses on Windows
@@ -32,6 +33,69 @@ async def _subprocess_exec(*args, **kwargs):
return await asyncio.create_subprocess_exec(*args, **kwargs)
def _decode_stderr(stderr: bytes | None) -> str:
if not stderr:
return "(no stderr output)"
text = stderr.decode("utf-8", errors="replace").strip()
return text[:1500] if text else "(no stderr output)"
def _log_ffmpeg_not_found_once(cmd: list[str]) -> None:
global _ffmpeg_not_found_logged
if _ffmpeg_not_found_logged:
return
_ffmpeg_not_found_logged = True
logger.error(
"ffmpeg executable was not found on PATH. Install ffmpeg and restart MediaHive. Command: %s",
shlex.join(cmd),
)
async def _run_ffmpeg(cmd: list[str], timeout: float) -> tuple[bytes, bytes] | None:
"""Run ffmpeg with consistent timeout/crash/not-found handling and logging."""
proc: asyncio.subprocess.Process | None = None
try:
logger.debug(" $ %s", shlex.join(cmd))
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
await _kill_proc(proc)
try:
stdout, stderr = await proc.communicate()
except Exception:
stdout, stderr = b"", b""
logger.error(
"ffmpeg command timed out. cmd=%s stderr=%s",
shlex.join(cmd),
_decode_stderr(stderr),
)
return None
if proc.returncode != 0:
logger.error(
"ffmpeg command failed. cmd=%s stderr=%s",
shlex.join(cmd),
_decode_stderr(stderr),
)
return None
return stdout, stderr
except FileNotFoundError:
_log_ffmpeg_not_found_once(cmd)
return None
except asyncio.CancelledError:
await _kill_proc(proc)
raise
except Exception:
logger.exception("Unexpected error running ffmpeg command: %s", shlex.join(cmd))
return None
async def _kill_proc(proc: asyncio.subprocess.Process | None) -> None:
"""Kill a subprocess immediately if it is still running."""
if proc is not None and proc.returncode is None:
@@ -282,18 +346,11 @@ async def get_av1_encoder() -> str:
return _av1_encoder_cache
# Check for NVIDIA AV1 encoder
try:
proc = await _subprocess_exec(
"ffmpeg",
"-hide_banner",
"-encoders",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
if b"av1_nvenc" in stdout:
# Verify it actually works (driver support)
test_proc = await _subprocess_exec(
encoders = await _run_ffmpeg(["ffmpeg", "-hide_banner", "-encoders"], timeout=10)
if encoders and b"av1_nvenc" in encoders[0]:
# Verify it actually works (driver support)
test_run = await _run_ffmpeg(
[
"ffmpeg",
"-f",
"lavfi",
@@ -304,15 +361,12 @@ async def get_av1_encoder() -> str:
"-f",
"null",
"-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(test_proc.communicate(), timeout=10)
if test_proc.returncode == 0:
_av1_encoder_cache = "av1_nvenc"
return _av1_encoder_cache
except Exception:
pass
],
timeout=10,
)
if test_run is not None:
_av1_encoder_cache = "av1_nvenc"
return _av1_encoder_cache
# Default to libsvtav1
_av1_encoder_cache = "libsvtav1"
@@ -373,74 +427,70 @@ async def probe_media_info(video_path: str) -> MediaProbeInfo:
return cached
info = MediaProbeInfo()
try:
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
logger.debug(" $ %s", shlex.join(cmd))
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
text = (stderr + stdout).decode("utf-8", errors="replace")
lower_text = text.lower()
cmd = ["ffmpeg", "-hide_banner", "-i", video_path]
ffmpeg_result = await _run_ffmpeg(cmd, timeout=30)
if ffmpeg_result is None:
_media_probe_cache[video_path] = info
return info
duration_match = _duration_re.search(text)
if duration_match:
hours = int(duration_match.group(1))
minutes = int(duration_match.group(2))
seconds = float(duration_match.group(3))
info.duration = hours * 3600 + minutes * 60 + seconds
stdout, stderr = ffmpeg_result
text = (stderr + stdout).decode("utf-8", errors="replace")
lower_text = text.lower()
video_line = None
for line in text.splitlines():
if "Stream #" in line and "Video:" in line:
video_line = line
break
if video_line:
dim_match = _dimension_re.search(video_line)
if dim_match:
info.width = int(dim_match.group(1))
info.height = int(dim_match.group(2))
info.resolution = classify_resolution_from_dimensions(
info.width, info.height
)
duration_match = _duration_re.search(text)
if duration_match:
hours = int(duration_match.group(1))
minutes = int(duration_match.group(2))
seconds = float(duration_match.group(3))
info.duration = hours * 3600 + minutes * 60 + seconds
info.is_hdr = (
"smpte2084" in lower_text
or "arib-std-b67" in lower_text
or "bt2020" in lower_text
)
video_line = None
for line in text.splitlines():
if "Stream #" in line and "Video:" in line:
video_line = line
break
if video_line:
dim_match = _dimension_re.search(video_line)
if dim_match:
info.width = int(dim_match.group(1))
info.height = int(dim_match.group(2))
info.resolution = classify_resolution_from_dimensions(
info.width, info.height
)
dovi_match = _dovi_profile_re.search(text)
if dovi_match:
info.dovi_profile = int(dovi_match.group(1))
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
info.dovi_profile = 7
info.has_dolby_vision = info.dovi_profile is not None
info.is_hdr = (
"smpte2084" in lower_text
or "arib-std-b67" in lower_text
or "bt2020" in lower_text
)
audio_languages: list[str] = []
for line in text.splitlines():
if "Stream #" not in line or "Audio:" not in line:
continue
match = _audio_stream_re.search(line)
if not match:
continue
lang = _lang_code(match.group(1))
if lang and lang not in audio_languages:
audio_languages.append(lang)
if "atmos" in line.lower():
info.has_dolby_atmos = True
info.audio_languages = audio_languages or None
dovi_match = _dovi_profile_re.search(text)
if dovi_match:
info.dovi_profile = int(dovi_match.group(1))
elif "dvhe" in lower_text or "dvh1" in lower_text or "dav1" in lower_text:
info.dovi_profile = 7
info.has_dolby_vision = info.dovi_profile is not None
subtitle_languages: list[str] = []
for match in _subtitle_stream_re.finditer(text):
lang = _lang_code(match.group(1))
if lang and lang not in subtitle_languages:
subtitle_languages.append(lang)
info.subtitle_languages = subtitle_languages or None
except Exception as e:
logger.warning(" ffmpeg probe error: %s", e)
audio_languages: list[str] = []
for line in text.splitlines():
if "Stream #" not in line or "Audio:" not in line:
continue
match = _audio_stream_re.search(line)
if not match:
continue
lang = _lang_code(match.group(1))
if lang and lang not in audio_languages:
audio_languages.append(lang)
if "atmos" in line.lower():
info.has_dolby_atmos = True
info.audio_languages = audio_languages or None
subtitle_languages: list[str] = []
for match in _subtitle_stream_re.finditer(text):
lang = _lang_code(match.group(1))
if lang and lang not in subtitle_languages:
subtitle_languages.append(lang)
info.subtitle_languages = subtitle_languages or None
_media_probe_cache[video_path] = info
return info
@@ -507,116 +557,108 @@ async def detect_crop(video_path: str) -> Optional[str]:
Crop filter string like "crop=1920:800:0:140" if black bars detected,
or None if no cropping needed or detection failed.
"""
try:
# First, get source video dimensions to check if it's 16:9
probe_info = await probe_media_info(video_path)
if not probe_info.width or not probe_info.height:
return None
src_width, src_height = probe_info.width, probe_info.height
# Check if source is 16:9 (allow small tolerance for weird resolutions)
# 16:9 = 1.777..., typical: 1920x1080, 3840x2160, 1280x720
aspect_ratio = src_width / src_height
if not (1.7 <= aspect_ratio <= 1.85):
# Not 16:9, skip crop detection (already correctly framed)
return None
# Use ffmpeg to run cropdetect on just 2 seconds at 5-minute mark
# This is much faster than scanning a long window with lavfi analysis.
cmd = [
"ffmpeg",
"-hide_banner",
"-ss",
"300",
"-i",
video_path,
"-t",
"2",
"-vf",
"cropdetect=limit=24:round=2:reset=0",
"-f",
"null",
"-",
]
logger.debug(" $ %s", shlex.join(cmd))
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=60)
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
# cropdetect outputs to stderr like: [Parsed_cropdetect_0 @ ...] x1:0 x2:1919 y1:138 y2:941 w:1920 h:800 ...
# We need to parse the crop values from stderr
crop_pattern = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
crop_values = []
for line in stderr_text.split("\n"):
match = crop_pattern.search(line)
if match:
w, h, x, y = (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
int(match.group(4)),
)
if w > 0 and h > 0 and x >= 0 and y >= 0:
crop_values.append((w, h, x, y))
if not crop_values:
return None
# Use the most common crop values (mode) for stability
most_common = Counter(crop_values).most_common(1)
if not most_common:
return None
w, h, x, y = most_common[0][0]
# Only crop if there's meaningful black bar removal (at least 8 pixels offset)
if x < 8 and y < 8:
return None
# Validate symmetry: trust only if cropping is symmetric in one direction
# (same top/bottom for letterbox, OR same left/right for pillarbox)
# Allow up to 4 pixels of rounding error
left_crop = x
right_crop = src_width - (x + w)
top_crop = y
bottom_crop = src_height - (y + h)
horizontal_symmetric = abs(left_crop - right_crop) <= 4
vertical_symmetric = abs(top_crop - bottom_crop) <= 4
# Must be symmetric in at least one direction, but not require both
# (letterbox = vertical symmetric, pillarbox = horizontal symmetric)
if not (horizontal_symmetric or vertical_symmetric):
return None
# If cropping in both directions, both must be symmetric
if x >= 8 and y >= 8:
if not (horizontal_symmetric and vertical_symmetric):
return None
# Align all coordinates to 8 pixels (shrink content area if needed)
# x and y: round UP to next multiple of 8
x_aligned = ((x + 7) // 8) * 8
y_aligned = ((y + 7) // 8) * 8
# w and h: round DOWN to multiple of 8, accounting for adjusted x/y
w_aligned = ((w - (x_aligned - x)) // 8) * 8
h_aligned = ((h - (y_aligned - y)) // 8) * 8
# Ensure we still have valid dimensions
if w_aligned <= 0 or h_aligned <= 0:
return None
crop_result = f"crop={w_aligned}:{h_aligned}:{x_aligned}:{y_aligned}"
logger.debug(" Detected crop: %s", crop_result)
return crop_result
except Exception as e:
logger.warning(" Crop detection error: %s", e)
# First, get source video dimensions to check if it's 16:9
probe_info = await probe_media_info(video_path)
if not probe_info.width or not probe_info.height:
return None
src_width, src_height = probe_info.width, probe_info.height
# Check if source is 16:9 (allow small tolerance for weird resolutions)
# 16:9 = 1.777..., typical: 1920x1080, 3840x2160, 1280x720
aspect_ratio = src_width / src_height
if not (1.7 <= aspect_ratio <= 1.85):
# Not 16:9, skip crop detection (already correctly framed)
return None
# Use ffmpeg to run cropdetect on just 2 seconds at 5-minute mark
# This is much faster than scanning a long window with lavfi analysis.
cmd = [
"ffmpeg",
"-hide_banner",
"-ss",
"300",
"-i",
video_path,
"-t",
"2",
"-vf",
"cropdetect=limit=24:round=2:reset=0",
"-f",
"null",
"-",
]
ffmpeg_result = await _run_ffmpeg(cmd, timeout=60)
if ffmpeg_result is None:
return None
_, stderr_bytes = ffmpeg_result
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
# cropdetect outputs to stderr like: [Parsed_cropdetect_0 @ ...] x1:0 x2:1919 y1:138 y2:941 w:1920 h:800 ...
# We need to parse the crop values from stderr
crop_pattern = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
crop_values = []
for line in stderr_text.split("\n"):
match = crop_pattern.search(line)
if match:
w, h, x, y = (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
int(match.group(4)),
)
if w > 0 and h > 0 and x >= 0 and y >= 0:
crop_values.append((w, h, x, y))
if not crop_values:
return None
# Use the most common crop values (mode) for stability
most_common = Counter(crop_values).most_common(1)
if not most_common:
return None
w, h, x, y = most_common[0][0]
# Only crop if there's meaningful black bar removal (at least 8 pixels offset)
if x < 8 and y < 8:
return None
# Validate symmetry: trust only if cropping is symmetric in one direction
# (same top/bottom for letterbox, OR same left/right for pillarbox)
# Allow up to 4 pixels of rounding error
left_crop = x
right_crop = src_width - (x + w)
top_crop = y
bottom_crop = src_height - (y + h)
horizontal_symmetric = abs(left_crop - right_crop) <= 4
vertical_symmetric = abs(top_crop - bottom_crop) <= 4
# Must be symmetric in at least one direction, but not require both
# (letterbox = vertical symmetric, pillarbox = horizontal symmetric)
if not (horizontal_symmetric or vertical_symmetric):
return None
# If cropping in both directions, both must be symmetric
if x >= 8 and y >= 8:
if not (horizontal_symmetric and vertical_symmetric):
return None
# Align all coordinates to 8 pixels (shrink content area if needed)
# x and y: round UP to next multiple of 8
x_aligned = ((x + 7) // 8) * 8
y_aligned = ((y + 7) // 8) * 8
# w and h: round DOWN to multiple of 8, accounting for adjusted x/y
w_aligned = ((w - (x_aligned - x)) // 8) * 8
h_aligned = ((h - (y_aligned - y)) // 8) * 8
# Ensure we still have valid dimensions
if w_aligned <= 0 or h_aligned <= 0:
return None
crop_result = f"crop={w_aligned}:{h_aligned}:{x_aligned}:{y_aligned}"
logger.debug(" Detected crop: %s", crop_result)
return crop_result
async def get_video_duration(video_path: str) -> Optional[float]:
@@ -633,7 +675,7 @@ async def generate_showreel_images(
video_path: str,
media_folder: Path,
timestamps: list[int] = SHOWREEL_TIMESTAMPS,
title: str = None,
title: str | None = None,
on_progress=None,
) -> list[str]:
"""
@@ -770,49 +812,28 @@ async def generate_showreel_images(
output_path.as_posix(),
]
logger.debug(" $ %s", shlex.join(cmd))
proc = None
try:
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode == 0 and await AsyncPath(output_path).exists():
generated_paths.append(output_path.as_posix())
logger.info(
" Showreel reel%d generated for %s",
reel_num,
title or "unknown",
)
if on_progress:
on_progress(reel_num)
else:
stderr_text = stderr.decode(errors="replace").strip() if stderr else ""
logger.error(
" Showreel reel%d failed (rc=%s) for %s: %s",
reel_num,
proc.returncode,
title or "unknown",
stderr_text[:500] if stderr_text else "(no output)",
)
await AsyncPath(output_path).unlink(missing_ok=True)
# Abort remaining reels - if first one fails, others likely will too
break
except BaseException as e:
await _kill_proc(proc)
ffmpeg_result = await _run_ffmpeg(cmd, timeout=120)
if ffmpeg_result is None:
await AsyncPath(output_path).unlink(missing_ok=True)
if isinstance(e, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
raise
logger.error(
"Error generating showreel for %s at %ds: %s",
break
if await AsyncPath(output_path).exists():
generated_paths.append(output_path.as_posix())
logger.info(
" Showreel reel%d generated for %s",
reel_num,
title or "unknown",
timestamp,
e,
)
# Abort remaining reels
if on_progress:
on_progress(reel_num)
else:
logger.error(
" Showreel reel%d failed for %s: output file was not created. cmd=%s",
reel_num,
title or "unknown",
shlex.join(cmd),
)
await AsyncPath(output_path).unlink(missing_ok=True)
break
if generated_paths:
@@ -939,33 +960,19 @@ async def generate_episode_reel(
output_path.as_posix(),
]
logger.debug(" $ %s", shlex.join(cmd))
proc = None
try:
proc = await _subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode == 0 and await AsyncPath(output_path).exists():
logger.info(" Episode reel generated: %s", ep_code)
return output_path.as_posix()
else:
stderr_text = stderr.decode(errors="replace").strip() if stderr else ""
logger.error(
" Episode reel %s failed (rc=%s): %s",
ep_code,
proc.returncode,
stderr_text[:500] if stderr_text else "(no output)",
)
await AsyncPath(output_path).unlink(missing_ok=True)
return None
except BaseException as e:
await _kill_proc(proc)
ffmpeg_result = await _run_ffmpeg(cmd, timeout=120)
if ffmpeg_result is None:
await AsyncPath(output_path).unlink(missing_ok=True)
if isinstance(e, (KeyboardInterrupt, SystemExit, asyncio.CancelledError)):
raise
logger.error("Error generating episode reel for %s: %s", ep_code, e)
return None
if await AsyncPath(output_path).exists():
logger.info(" Episode reel generated: %s", ep_code)
return output_path.as_posix()
logger.error(
" Episode reel %s failed: output file was not created. cmd=%s",
ep_code,
shlex.join(cmd),
)
await AsyncPath(output_path).unlink(missing_ok=True)
return None
+13
View File
@@ -5,6 +5,7 @@ Or from PyInstaller: MediaHive.exe [media_folder]
"""
import argparse
import asyncio
import ctypes
import html
import json
@@ -752,7 +753,19 @@ def _reserve_backend_port() -> int:
return int(sock.getsockname()[1])
def _configure_windows_event_loop_policy() -> None:
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
if sys.platform != "win32":
return
policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)
if policy_cls is None:
return
asyncio.set_event_loop_policy(policy_cls())
def winmain() -> None:
_configure_windows_event_loop_policy()
parser = argparse.ArgumentParser(description="MediaHive")
parser.add_argument(
"media_folder",