Use ffmpeg parsing for media probe metadata and remove ffprobe bundling
This commit is contained in:
@@ -47,6 +47,8 @@ export interface Torrent {
|
||||
quality: string | null;
|
||||
codec: string | null;
|
||||
audio: string | null;
|
||||
audio_languages: string[] | null;
|
||||
subtitle_languages: string[] | null;
|
||||
encoder: string | null;
|
||||
size: number | null;
|
||||
added_at: number | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ from mediahive.hivescan.showreel import (
|
||||
get_existing_episode_reel_sources,
|
||||
get_existing_showreel_paths,
|
||||
get_existing_showreel_source_sets,
|
||||
probe_media_info,
|
||||
)
|
||||
from mediahive.hivescan.tmdb_client import (
|
||||
fetch_movie_info,
|
||||
@@ -54,6 +55,10 @@ async def _build_torrent_info(
|
||||
) -> Torrent:
|
||||
"""Build torrent info for a single torrent."""
|
||||
playable_file = await find_playable_file(item.path)
|
||||
probe_info = None
|
||||
if playable_file and not str(playable_file).endswith(".bdmv"):
|
||||
probe_info = await probe_media_info(str(playable_file))
|
||||
|
||||
if item.content_hash and item.content_hash.size == 0:
|
||||
item.content_hash.size = await get_directory_size(item.content_hash.path)
|
||||
size = item.content_hash.size if item.content_hash else None
|
||||
@@ -62,10 +67,12 @@ async def _build_torrent_info(
|
||||
return Torrent(
|
||||
title=item.title,
|
||||
playable_file=make_relative_path(playable_file, media_root),
|
||||
resolution=item.resolution,
|
||||
resolution=item.resolution or (probe_info.resolution if probe_info else None),
|
||||
quality=item.quality,
|
||||
codec=item.codec,
|
||||
audio=item.audio,
|
||||
audio_languages=probe_info.audio_languages if probe_info else None,
|
||||
subtitle_languages=probe_info.subtitle_languages if probe_info else None,
|
||||
encoder=item.encoder,
|
||||
size=size,
|
||||
added_at=added_at,
|
||||
@@ -116,6 +123,15 @@ async def _collect_episode_files(
|
||||
Returns dict mapping (season, episode) to list of file info dicts.
|
||||
"""
|
||||
all_episode_files: Dict[Tuple[int, int], List[Dict]] = {}
|
||||
probe_cache: Dict[str, object] = {}
|
||||
|
||||
async def get_probe(path: str):
|
||||
cached = probe_cache.get(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
probe = await probe_media_info(path)
|
||||
probe_cache[path] = probe
|
||||
return probe
|
||||
|
||||
for item in items:
|
||||
episode_files = await find_episode_files(item.path)
|
||||
@@ -125,10 +141,14 @@ async def _collect_episode_files(
|
||||
if key not in all_episode_files:
|
||||
all_episode_files[key] = []
|
||||
for file_path, file_size in files:
|
||||
probe = await get_probe(file_path)
|
||||
all_episode_files[key].append(
|
||||
{
|
||||
"path": file_path,
|
||||
"size": file_size,
|
||||
"probed_resolution": probe.resolution,
|
||||
"audio_languages": probe.audio_languages,
|
||||
"subtitle_languages": probe.subtitle_languages,
|
||||
"resolution": item.resolution,
|
||||
"quality": item.quality,
|
||||
"codec": item.codec,
|
||||
@@ -150,6 +170,7 @@ async def _collect_episode_files(
|
||||
|
||||
playable = await find_playable_file(item.path)
|
||||
if playable:
|
||||
probe = await get_probe(playable)
|
||||
for sn in season_nums:
|
||||
for ep in episode_nums:
|
||||
key = (sn, ep)
|
||||
@@ -169,6 +190,9 @@ async def _collect_episode_files(
|
||||
{
|
||||
"path": playable,
|
||||
"size": size,
|
||||
"probed_resolution": probe.resolution,
|
||||
"audio_languages": probe.audio_languages,
|
||||
"subtitle_languages": probe.subtitle_languages,
|
||||
"resolution": item.resolution,
|
||||
"quality": item.quality,
|
||||
"codec": item.codec,
|
||||
@@ -228,10 +252,12 @@ def _build_episodes_data(
|
||||
torrents[relpath] = Torrent(
|
||||
title=f["torrent_title"],
|
||||
playable_file=make_relative_path(f["path"], media_root),
|
||||
resolution=f.get("resolution"),
|
||||
resolution=f.get("resolution") or f.get("probed_resolution"),
|
||||
quality=f.get("quality"),
|
||||
codec=f.get("codec"),
|
||||
audio=f.get("audio"),
|
||||
audio_languages=f.get("audio_languages"),
|
||||
subtitle_languages=f.get("subtitle_languages"),
|
||||
encoder=f.get("encoder"),
|
||||
size=f.get("size"),
|
||||
)
|
||||
|
||||
+108
-149
@@ -7,13 +7,13 @@ and HDR passthrough.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -325,6 +325,103 @@ def get_encoder_options(encoder: str) -> list[str]:
|
||||
return ["-crf", "38", "-preset", "6"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaProbeInfo:
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
is_hdr: bool = False
|
||||
dovi_profile: int | None = None
|
||||
resolution: str | None = None
|
||||
audio_languages: list[str] | None = None
|
||||
subtitle_languages: list[str] | None = None
|
||||
|
||||
|
||||
_media_probe_cache: dict[str, MediaProbeInfo] = {}
|
||||
_duration_re = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
||||
_dimension_re = re.compile(r"(\d{2,5})x(\d{2,5})")
|
||||
_dovi_profile_re = re.compile(r"DOVI configuration record:.*?profile:\s*(\d+)", re.I)
|
||||
_audio_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Audio:")
|
||||
_subtitle_stream_re = re.compile(r"Stream #\d+:\d+(?:\(([^)]+)\))?:\s+Subtitle:")
|
||||
|
||||
|
||||
def _lang_code(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
code = raw.strip().split(",", 1)[0].lower()
|
||||
return code if code and code != "und" else None
|
||||
|
||||
|
||||
async def probe_media_info(video_path: str) -> MediaProbeInfo:
|
||||
"""Parse key media metadata from `ffmpeg -i` output."""
|
||||
cached = _media_probe_cache.get(video_path)
|
||||
if cached is not None:
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
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 = f"{info.width}x{info.height}"
|
||||
|
||||
info.is_hdr = (
|
||||
"smpte2084" in lower_text
|
||||
or "arib-std-b67" in lower_text
|
||||
or "bt2020" in lower_text
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
audio_languages: list[str] = []
|
||||
for match in _audio_stream_re.finditer(text):
|
||||
lang = _lang_code(match.group(1))
|
||||
if lang and lang not in audio_languages:
|
||||
audio_languages.append(lang)
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning(" ffmpeg probe error: %s", e)
|
||||
|
||||
_media_probe_cache[video_path] = info
|
||||
return info
|
||||
|
||||
|
||||
async def detect_dovi_profile(video_path: str) -> Optional[int]:
|
||||
"""
|
||||
Detect Dolby Vision profile from a video file.
|
||||
@@ -335,72 +432,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]:
|
||||
Profile 8: Single-layer HDR10 compatible (usually OK)
|
||||
"""
|
||||
try:
|
||||
# Check for Dolby Vision configuration record in video stream
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream_side_data_list",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
]
|
||||
logger.debug(" $ %s", shlex.join(cmd))
|
||||
proc = await _subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(stdout)
|
||||
streams = data.get("streams", [])
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
# Look for DOVI configuration in side data
|
||||
side_data_list = streams[0].get("side_data_list", [])
|
||||
for side_data in side_data_list:
|
||||
side_data_type = side_data.get("side_data_type", "")
|
||||
if "DOVI" in side_data_type or "Dolby Vision" in side_data_type:
|
||||
# Try to extract profile from dv_profile field
|
||||
dv_profile = side_data.get("dv_profile")
|
||||
if dv_profile is not None:
|
||||
return int(dv_profile)
|
||||
|
||||
# Alternative: check using mediainfo-style detection via codec tag
|
||||
# Some DoVi content has "dvhe" or "dvh1" codec tags
|
||||
codec_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_tag_string,codec_name",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
video_path,
|
||||
]
|
||||
codec_proc = await _subprocess_exec(
|
||||
*codec_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
codec_stdout, _ = await asyncio.wait_for(codec_proc.communicate(), timeout=30)
|
||||
if codec_proc.returncode == 0:
|
||||
codec_info = codec_stdout.decode("utf-8").lower()
|
||||
if "dvhe" in codec_info or "dvh1" in codec_info or "dav1" in codec_info:
|
||||
# DoVi detected but profile unknown, assume needs conversion
|
||||
return 7 # Conservative: treat as dual-layer
|
||||
|
||||
return None
|
||||
return (await probe_media_info(video_path)).dovi_profile
|
||||
except Exception as e:
|
||||
logger.warning(" DoVi detection error: %s", e)
|
||||
return None
|
||||
@@ -423,43 +455,12 @@ def get_dovi_to_hdr10_filter() -> str:
|
||||
|
||||
async def is_hdr_video(video_path: str) -> bool:
|
||||
"""
|
||||
Check if a video file is HDR using ffprobe.
|
||||
Check if a video file is HDR using ffmpeg probe output.
|
||||
|
||||
Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.)
|
||||
"""
|
||||
try:
|
||||
proc = await _subprocess_exec(
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=color_transfer,color_primaries,color_space",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
|
||||
data = json.loads(stdout)
|
||||
streams = data.get("streams", [])
|
||||
if not streams:
|
||||
return False
|
||||
|
||||
stream = streams[0]
|
||||
color_transfer = stream.get("color_transfer", "")
|
||||
color_primaries = stream.get("color_primaries", "")
|
||||
|
||||
# HDR indicators
|
||||
hdr_transfers = ["smpte2084", "arib-std-b67"] # PQ and HLG
|
||||
hdr_primaries = ["bt2020"]
|
||||
|
||||
return color_transfer in hdr_transfers or color_primaries in hdr_primaries
|
||||
return (await probe_media_info(video_path)).is_hdr
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -484,34 +485,10 @@ async def detect_crop(video_path: str) -> Optional[str]:
|
||||
"""
|
||||
try:
|
||||
# First, get source video dimensions to check if it's 16:9
|
||||
dim_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
video_path,
|
||||
]
|
||||
logger.debug(" $ %s", shlex.join(dim_cmd))
|
||||
dim_proc = await _subprocess_exec(
|
||||
*dim_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
dim_stdout, _ = await asyncio.wait_for(dim_proc.communicate(), timeout=30)
|
||||
if dim_proc.returncode != 0:
|
||||
probe_info = await probe_media_info(video_path)
|
||||
if not probe_info.width or not probe_info.height:
|
||||
return None
|
||||
|
||||
# Parse "width,height" output (take first line only, ffprobe may emit multiple)
|
||||
first_line = dim_stdout.decode("utf-8").strip().splitlines()[0].strip()
|
||||
parts = first_line.split(",")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
src_width, src_height = int(parts[0]), int(parts[1])
|
||||
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
|
||||
@@ -521,7 +498,7 @@ async def detect_crop(video_path: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# Use ffmpeg to run cropdetect on just 2 seconds at 5-minute mark
|
||||
# This is much faster than scanning 60 seconds with ffprobe lavfi
|
||||
# This is much faster than scanning a long window with lavfi analysis.
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
@@ -620,28 +597,10 @@ async def detect_crop(video_path: str) -> Optional[str]:
|
||||
|
||||
async def get_video_duration(video_path: str) -> Optional[float]:
|
||||
"""
|
||||
Get the duration of a video file in seconds using ffprobe.
|
||||
Get the duration of a video file in seconds using ffmpeg probe output.
|
||||
"""
|
||||
try:
|
||||
proc = await _subprocess_exec(
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(stdout)
|
||||
duration = data.get("format", {}).get("duration")
|
||||
return float(duration) if duration else None
|
||||
return (await probe_media_info(video_path)).duration
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -685,7 +644,7 @@ async def generate_showreel_images(
|
||||
return []
|
||||
ffmpeg_input = video_path
|
||||
|
||||
# Fast path: check if all showreel clips already exist before any ffprobe calls
|
||||
# Fast path: check if all showreel clips already exist before any probe calls
|
||||
existing_paths = []
|
||||
all_exist = True
|
||||
extension = get_reel_extension()
|
||||
|
||||
@@ -24,6 +24,8 @@ class Torrent(msgspec.Struct):
|
||||
quality: str | None = None
|
||||
codec: str | None = None
|
||||
audio: str | None = None
|
||||
audio_languages: list[str] | None = None
|
||||
subtitle_languages: list[str] | None = None
|
||||
encoder: str | None = None
|
||||
size: int | None = None
|
||||
added_at: int | None = None
|
||||
|
||||
@@ -19,7 +19,7 @@ _frontend_build = _pkg / "frontend-build"
|
||||
_icon_win = _pkg / "assets" / "mediahive.ico"
|
||||
_icon_mac = _pkg / "assets" / "mediahive.icns"
|
||||
_tools_dir = Path(SPECPATH).parent / "build" / "ffmpeg"
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg", "ffprobe"]
|
||||
_tool_names = ["ffmpeg.exe"] if sys.platform == "win32" else ["ffmpeg"]
|
||||
|
||||
_binaries = []
|
||||
for _tool_name in _tool_names:
|
||||
|
||||
+2
-3
@@ -9,7 +9,7 @@ This script:
|
||||
1. Reads the version from pyproject.toml
|
||||
2. Runs `uv build` to produce the wheel/sdist
|
||||
3. On Windows, downloads the latest ffmpeg.exe for bundling
|
||||
4. On macOS arm64, downloads prebuilt ffmpeg/ffprobe binaries for bundling
|
||||
4. On macOS arm64, downloads a prebuilt ffmpeg binary for bundling
|
||||
4. Builds MediaHive using PyInstaller
|
||||
5. Creates a ZIP file with the version number
|
||||
"""
|
||||
@@ -33,7 +33,6 @@ _FFMPEG_URL = (
|
||||
)
|
||||
_MACOS_ARM64_TOOL_URLS = {
|
||||
"ffmpeg": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||
"ffprobe": "https://www.osxexperts.net/ffprobe81arm.zip",
|
||||
}
|
||||
_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg"
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
@@ -82,7 +81,7 @@ def fetch_ffmpeg() -> Path:
|
||||
|
||||
|
||||
def fetch_macos_arm64_binaries() -> dict[str, Path]:
|
||||
"""Download prebuilt macOS arm64 ffmpeg/ffprobe binaries into build/ffmpeg/."""
|
||||
"""Download prebuilt macOS arm64 ffmpeg binary into build/ffmpeg/."""
|
||||
if sys.platform != "darwin" or platform.machine().lower() not in {
|
||||
"arm64",
|
||||
"aarch64",
|
||||
|
||||
Reference in New Issue
Block a user