Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}"
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 38 additions & 24 deletions ofscraper/commands/scraper/actions/download/utils/keyhelpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
19 changes: 9 additions & 10 deletions ofscraper/utils/system/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -19,35 +19,34 @@
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.")
return False

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
Expand Down
70 changes: 29 additions & 41 deletions ofscraper/utils/system/ffprobe.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies = [
"xxhash~=3.4.1",
"pyyaml>=6.0.2",
"dotenv>=0.9.9",
"typed-ffmpeg>=3.11",
]

[project.optional-dependencies]
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.