diff --git a/ofscraper/commands/scraper/actions/download/managers/alt_download.py b/ofscraper/commands/scraper/actions/download/managers/alt_download.py index 08b6fbc3..e3b095c1 100755 --- a/ofscraper/commands/scraper/actions/download/managers/alt_download.py +++ b/ofscraper/commands/scraper/actions/download/managers/alt_download.py @@ -25,8 +25,8 @@ import ofscraper.utils.cache.cache as cache import ofscraper.utils.dates as dates import ofscraper.utils.of_env.of_env as of_env +import ffmpeg as ffmpeg_lib import ofscraper.utils.system.system as system -from ofscraper.utils.system.subprocess import async_run from ofscraper.utils.system.ffprobe import verify_media_integrity import ofscraper.utils.live.updater as progress_updater @@ -152,7 +152,7 @@ async def _alt_download_downloader(self, item, c, ele): async def _alt_download_sendreq(self, item, c, ele, placeholderObj): try: _attempt = self._alt_attempt_get(item) - base_url = re.sub("[0-9a-z]*\.mpd$", "", ele.mpd, re.IGNORECASE) + base_url = re.sub(r"[0-9a-z]*\.mpd$", "", ele.mpd, re.IGNORECASE) url = f"{base_url}{item['origname']}" common_globals.log.debug( f"{get_medialog(ele)} Attempting to download media {item['origname']} with {url}" @@ -176,7 +176,7 @@ async def _send_req_inner(self, c, ele, item, placeholderObj): total = None common_globals.log.debug(f"{get_medialog(ele)} resume header {headers}") params = get_alt_params(ele) - base_url = re.sub("[0-9a-z]*\.mpd$", "", ele.mpd, re.IGNORECASE) + base_url = re.sub(r"[0-9a-z]*\.mpd$", "", ele.mpd, re.IGNORECASE) url = f"{base_url}{item['origname']}" headers = {"Cookie": f"{ele.hls_header}{auth_requests.get_cookies_str()}"} common_globals.log.debug( @@ -303,40 +303,31 @@ async def _handle_result_alt( temp_path = tempPlaceholder.tempfilepath temp_path.unlink(missing_ok=True) - # Dynamically build FFmpeg command based on available tracks - ffmpeg_cmd = [get_ffmpeg()] + # Dynamically build FFmpeg mux command based on available tracks + inputs = [] if video: - ffmpeg_cmd.extend(["-i", str(video["path"])]) + inputs.append(ffmpeg_lib.input(str(video["path"]))) if audio: - ffmpeg_cmd.extend(["-i", str(audio["path"])]) - - ffmpeg_cmd.extend( - [ - "-c", - "copy", - "-movflags", - "use_metadata_tags", - str(temp_path), - "-y", - ] - ) + inputs.append(ffmpeg_lib.input(str(audio["path"]))) - # Async run FFmpeg with the -y flag - t = await async_run( - ffmpeg_cmd, - name="ffmpeg", - level=of_env.getattr("FFMPEG_SUBPROCESS_LEVEL"), - ) + stream = ffmpeg_lib.output( + *inputs, + filename=str(temp_path), + c="copy", + extra_options={"movflags": "use_metadata_tags"}, + ).overwrite_output() - # Fallback error check if stderr is captured and Output is missing - if t.stderr and t.stderr.decode().find("Output") == -1: - common_globals.log.debug(f"{common_logs.get_medialog(ele)} ffmpeg failed") - common_globals.log.debug( - f"{common_logs.get_medialog(ele)} ffmpeg {t.stderr.decode()}" - ) - common_globals.log.debug( - f"{common_logs.get_medialog(ele)} ffmpeg {t.stdout.decode()}" + ffmpeg_cmd = get_ffmpeg() + try: + await asyncio.to_thread( + stream.run, + cmd=ffmpeg_cmd, + capture_stdout=True, + capture_stderr=True, ) + except ffmpeg_lib.FFMpegExecuteError as e: + stderr = e.stderr.decode() if e.stderr else str(e) + common_globals.log.debug(f"{common_logs.get_medialog(ele)} ffmpeg mux failed: {stderr}") # Clean up temp tracks if video: diff --git a/ofscraper/commands/scraper/actions/download/utils/keyhelpers.py b/ofscraper/commands/scraper/actions/download/utils/keyhelpers.py index f0e0db93..3a9ad553 100755 --- a/ofscraper/commands/scraper/actions/download/utils/keyhelpers.py +++ b/ofscraper/commands/scraper/actions/download/utils/keyhelpers.py @@ -5,6 +5,7 @@ import traceback from functools import partial +import ffmpeg as ffmpeg_lib from pywidevine.cdm import Cdm from pywidevine.device import Device from pywidevine.pssh import PSSH @@ -18,7 +19,6 @@ get_cmd_download_req_retries, ) from ofscraper.commands.scraper.actions.utils.log import get_medialog -from ofscraper.utils.system.subprocess import async_run from ofscraper.utils.system.ffmpeg import get_ffmpeg import ofscraper.managers.manager as manager @@ -63,42 +63,56 @@ async def un_encrypt(item, c, ele, input_=None): log.debug( f"{get_medialog(ele)} renaming {pathlib.Path(item['path']).absolute()} -> {newpath}" ) - r = await async_run( - [ - get_ffmpeg(), - "-decryption_key", - ffmpeg_key, - "-i", + ffmpeg_cmd = get_ffmpeg() + stream = ( + ffmpeg_lib.input( str(item["path"]), - "-codec", - "copy", - str(newpath), - "-y", - ], - level=of_env.getattr("FFMPEG_SUBPROCESS_LEVEL"), - name="ffmpeg", + extra_options={"decryption_key": ffmpeg_key}, + ) + .output( + filename=str(newpath), + codec="copy", + ) + .overwrite_output() ) - if not pathlib.Path(newpath).exists(): - log.debug(f"{get_medialog(ele)} ffmpeg {r.stderr.decode()}") - log.debug(f"{get_medialog(ele)} ffmpeg {r.stdout.decode()}") + try: + await asyncio.to_thread( + stream.run, + cmd=ffmpeg_cmd, + capture_stdout=True, + capture_stderr=True, + ) + except ffmpeg_lib.FFMpegExecuteError as e: + stderr = e.stderr.decode() if e.stderr else str(e) + log.debug(f"{get_medialog(ele)} ffmpeg decrypt stderr: {stderr}") await asyncio.get_event_loop().run_in_executor( common_globals.thread, partial( cache.set, ele.license, None, expire=of_env.getattr("KEY_EXPIRY") ), ) - raise Exception(f"{get_medialog(ele)} ffmpeg decryption failed") - else: - log.debug(f"{get_medialog(ele)} ffmpeg decrypt success {newpath}") - pathlib.Path(item["path"]).unlink(missing_ok=True) - item["path"] = newpath + raise Exception(f"{get_medialog(ele)} ffmpeg decryption failed") from e + + if not pathlib.Path(newpath).exists(): + log.debug(f"{get_medialog(ele)} ffmpeg produced no output file at {newpath}") await asyncio.get_event_loop().run_in_executor( common_globals.thread, partial( - cache.set, ele.license, key, expire=of_env.getattr("KEY_EXPIRY") + cache.set, ele.license, None, expire=of_env.getattr("KEY_EXPIRY") ), ) - return item + raise Exception(f"{get_medialog(ele)} ffmpeg decryption failed — output file missing") + + log.debug(f"{get_medialog(ele)} ffmpeg decrypt success {newpath}") + pathlib.Path(item["path"]).unlink(missing_ok=True) + item["path"] = newpath + await asyncio.get_event_loop().run_in_executor( + common_globals.thread, + partial( + cache.set, ele.license, key, expire=of_env.getattr("KEY_EXPIRY") + ), + ) + return item except Exception as E: raise E diff --git a/ofscraper/utils/system/ffmpeg.py b/ofscraper/utils/system/ffmpeg.py index 2d2e4b44..8f2b8d98 100644 --- a/ofscraper/utils/system/ffmpeg.py +++ b/ofscraper/utils/system/ffmpeg.py @@ -3,8 +3,8 @@ import subprocess import re import logging + import ofscraper.utils.settings as settings -from ofscraper.utils.system.subprocess import run import ofscraper.utils.of_env.of_env as env log = logging.getLogger("shared") @@ -19,7 +19,7 @@ def _is_valid_ffmpeg(path: str | None) -> bool: """ Checks if a given path is a real, executable FFmpeg binary and validates - that its version is >= 6 and < 8 for DRM compatibility. Logs the process. + that its version is >= 6 for DRM compatibility. Logs the process. """ if not path or not shutil.which(path): log.debug(f"Path '{path}' is not a valid or executable file.") @@ -27,27 +27,26 @@ def _is_valid_ffmpeg(path: str | None) -> bool: log.debug(f"Running validation check on candidate path: {path}") try: - result = run( + proc = subprocess.run( [path, "-version"], capture_output=True, text=True, check=False, encoding="utf-8", - level=env.getattr("FFMPEG_SUBPROCESS_LEVEL"), - name="ffmpeg", + timeout=10, ) - output = result.stdout + result.stderr + output = proc.stdout + proc.stderr if re.search(r"ffmpeg version", output, re.IGNORECASE): # Extract the major version number (e.g., "ffmpeg version 6.1.1" -> "6") version_match = re.search(r"ffmpeg version\s+([0-9]+)\.", output, re.IGNORECASE) - + if version_match: major_version = int(version_match.group(1)) - if major_version < 6 or major_version >= 8: + if major_version < 6: log.warning( - f"⚠️ Invalid FFmpeg version {major_version}.x detected at '{path}'.\n" - f"DRM decryption requires FFmpeg version >= 6 and < 8.\n" + f"⚠️ FFmpeg version {major_version}.x detected at '{path}'.\n" + f"DRM decryption requires FFmpeg version >= 6.\n" f"Skipping this binary and looking for an alternative..." ) return False diff --git a/ofscraper/utils/system/ffprobe.py b/ofscraper/utils/system/ffprobe.py index 77bd88f3..01824c02 100644 --- a/ofscraper/utils/system/ffprobe.py +++ b/ofscraper/utils/system/ffprobe.py @@ -1,52 +1,41 @@ import logging import pathlib -import re -from ofscraper.utils.system.subprocess import run -import ofscraper.utils.of_env.of_env as env -# Import both binaries! +import ffmpeg as ffmpeg_lib + from ofscraper.utils.system.ffmpeg import get_ffmpeg, get_ffprobe log = logging.getLogger("shared") -def _get_duration_ffprobe(file_path, ffprobe_path): - """Primary method: Clean metadata extraction using ffprobe.""" - cmd = [ - ffprobe_path, - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", +def _get_duration_probe(file_path, ffprobe_path): + """Primary method: Clean metadata extraction using ffmpeg.probe().""" + probe = ffmpeg_lib.probe( str(file_path), - ] - result = run( - cmd, - capture_output=True, - text=True, - check=True, - level=env.getattr("FFPROBE_SUBPROCESS_LEVEL"), - name="ffprobe", + cmd=ffprobe_path, + show_format=True, + show_streams=False, ) - return float(result.stdout.strip()) + return float(probe["format"]["duration"]) + +def _get_duration_ffmpeg_probe(file_path, ffmpeg_path): + """Fallback method: Use ffmpeg.probe() pointed at the ffmpeg binary's sibling ffprobe, + or parse duration from a null-output transcode if ffprobe is unavailable.""" + # ffmpeg.probe() requires ffprobe; if we don't have it, fall back to + # running ffmpeg -i and parsing the Duration line from stderr. + import subprocess + import re -def _get_duration_ffmpeg(file_path, ffmpeg_path): - """Fallback method: Regex scraping from ffmpeg stderr output.""" - cmd = [ffmpeg_path, "-i", str(file_path)] - result = run( - cmd, + proc = subprocess.run( + [ffmpeg_path, "-i", str(file_path)], capture_output=True, text=True, - check=False, # Must be False because ffmpeg exits with code 1 here - level=env.getattr("FFMPEG_SUBPROCESS_LEVEL"), - name="ffmpeg", + check=False, + timeout=30, ) - # FFmpeg prints metadata to stderr - match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", result.stderr) + match = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", proc.stderr) if match: hours, minutes, seconds = match.groups() return (int(hours) * 3600) + (int(minutes) * 60) + float(seconds) @@ -56,19 +45,19 @@ def _get_duration_ffmpeg(file_path, ffmpeg_path): def get_media_duration(file_path): """Gets media duration, preferring ffprobe but falling back to ffmpeg if needed.""" try: - # Attempt 1: The Proper Way (ffprobe) + # Attempt 1: The Proper Way (ffmpeg.probe via ffprobe binary) ffprobe_path = get_ffprobe() if ffprobe_path: try: - return _get_duration_ffprobe(file_path, ffprobe_path) - except Exception as e: + return _get_duration_probe(file_path, ffprobe_path) + except (ffmpeg_lib.FFMpegExecuteError, KeyError, ValueError) as e: log.debug( f"ffprobe failed for {file_path}, trying ffmpeg fallback. Error: {e}" ) - # Attempt 2: (ffmpeg Fallback) + # Attempt 2: ffmpeg -i fallback ffmpeg_path = get_ffmpeg() - duration = _get_duration_ffmpeg(file_path, ffmpeg_path) + duration = _get_duration_ffmpeg_probe(file_path, ffmpeg_path) if duration is not None: return duration @@ -110,9 +99,8 @@ def verify_media_integrity(file_path, expected_duration_seconds=None): return False log.debug( - f"Integrity Check Succeed: {pathlib.Path(file_path).name}\n" - f"Expected: {expected_duration_seconds}s | Actual: {actual_duration:.2f}s " - f"| Diff: {diff:.2f}s (Limit: 3.0s)" + f"Integrity Check Passed: {pathlib.Path(file_path).name}\n" + f"Expected: {expected_duration_seconds}s | Actual: {actual_duration:.2f}s" ) return True diff --git a/pyproject.toml b/pyproject.toml index 029a1241..1ed060a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "xxhash~=3.4.1", "pyyaml>=6.0.2", "dotenv>=0.9.9", + "typed-ffmpeg>=3.11", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 215952dd..a074e474 100644 --- a/uv.lock +++ b/uv.lock @@ -1028,6 +1028,7 @@ dependencies = [ { name = "tenacity" }, { name = "textual" }, { name = "tqdm" }, + { name = "typed-ffmpeg" }, { name = "uvloop", marker = "sys_platform == 'linux' or sys_platform == 'linux2'" }, { name = "win32-setctime" }, { name = "xxhash" }, @@ -1103,6 +1104,7 @@ requires-dist = [ { name = "tenacity", specifier = "~=8.2.3" }, { name = "textual", specifier = "==1.0.0" }, { name = "tqdm", specifier = "~=4.66.4" }, + { name = "typed-ffmpeg", specifier = ">=3.11" }, { name = "uvloop", marker = "sys_platform == 'linux' or sys_platform == 'linux2'", specifier = "~=0.21.0" }, { name = "win32-setctime", specifier = "~=1.1.0" }, { name = "xxhash", specifier = "~=3.4.1" }, @@ -1925,6 +1927,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/73/02342de9c2d20922115f787e101527b831c0cffd2105c946c4a4826bcfd4/tqdm-4.66.6-py3-none-any.whl", hash = "sha256:223e8b5359c2efc4b30555531f09e9f2f3589bcd7fdd389271191031b49b7a63", size = 78326, upload-time = "2024-10-28T12:49:56.931Z" }, ] +[[package]] +name = "typed-ffmpeg" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/9f/b8451850981c5d8f1cb828ddb8776490ca85ebdc9231d34e66f7b1438c4a/typed_ffmpeg-3.11.tar.gz", hash = "sha256:0a37a2803fe5a1e309fdca4f4377f06c5147d9032fcbb93a177a65195d2ce6f5", size = 646795, upload-time = "2026-01-21T05:43:18.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/aa4635a8bc319dc5ff80965d807864ce4b25ebb59ba59dc0d8e2f0ef0b10/typed_ffmpeg-3.11-py3-none-any.whl", hash = "sha256:78b4d7b8613d9cee0a5517e8f0e7556c3e77e64498eac0f7fe082097d34fa412", size = 698409, upload-time = "2026-01-21T05:43:16.77Z" }, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20260305"