From 30005f84c9cb59c7d30c78ac80164cd9fae33262 Mon Sep 17 00:00:00 2001 From: Azeem Date: Tue, 25 Aug 2026 02:03:22 +0000 Subject: [PATCH] fix(transcribe): let download_audio accept cookies and JS runtimes from the environment YouTube answers datacenter IPs with 'Sign in to confirm you're not a bot'; yt-dlp's FAQ prescribes browser cookies, and its YouTube extractor wants a JS runtime for full format coverage. Both are CLI-reachable but the embedded YoutubeDL call in download_audio() hardcoded its options, so a cookies file the user exported had no way in. Mirror the transcriber's existing env convention (GRAPHIFY_WHISPER_MODEL): - GRAPHIFY_YTDLP_COOKIES -> cookiefile (yt-dlp --cookies) - GRAPHIFY_YTDLP_JS_RUNTIMES -> js_runtimes (yt-dlp --js-runtimes), e.g. 'node' No env vars set -> options unchanged. Co-Authored-By: Claude Fable 5 --- graphify/transcribe.py | 27 ++++++++++++++++++ tests/test_transcribe.py | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/graphify/transcribe.py b/graphify/transcribe.py index 8e45bcaa2f..1adec4944d 100644 --- a/graphify/transcribe.py +++ b/graphify/transcribe.py @@ -47,6 +47,32 @@ def is_url(path: str) -> bool: return any(path.startswith(p) for p in URL_PREFIXES) +def _ytdlp_env_opts() -> dict: + """Optional yt-dlp options passed through the environment. + + YouTube serves ``Sign in to confirm you're not a bot`` to datacenter IPs; + yt-dlp's own FAQ prescribes browser cookies for that, and its extractor + needs a JavaScript runtime for full format coverage. Both are reachable + from the CLI but were impossible to pass through the embedded API here, + so mirror the transcriber's env convention (GRAPHIFY_WHISPER_MODEL): + + - ``GRAPHIFY_YTDLP_COOKIES``: path to a Netscape cookies.txt + (same as ``yt-dlp --cookies``). + - ``GRAPHIFY_YTDLP_JS_RUNTIMES``: comma-separated runtime names, + e.g. ``node`` or ``deno,node`` (same as ``yt-dlp --js-runtimes``). + """ + opts: dict = {} + cookies = os.environ.get("GRAPHIFY_YTDLP_COOKIES") + if cookies: + opts['cookiefile'] = cookies + runtimes = os.environ.get("GRAPHIFY_YTDLP_JS_RUNTIMES") + if runtimes: + opts['js_runtimes'] = { + r.strip(): {'path': None} for r in runtimes.split(',') if r.strip() + } + return opts + + def download_audio(url: str, output_dir: Path) -> Path: """Download audio-only stream from a URL using yt-dlp. @@ -78,6 +104,7 @@ def download_audio(url: str, output_dir: Path) -> Path: 'noplaylist': True, 'postprocessors': [], # no ffmpeg needed — use native audio } + ydl_opts.update(_ytdlp_env_opts()) print(f" downloading audio: {url[:80]} ...", flush=True) with yt_dlp.YoutubeDL(ydl_opts) as ydl: diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 8e35f2aafc..d32966f052 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -145,3 +145,62 @@ def raise_import(*args, **kwargs): results = transcribe_all([str(video)], output_dir=tmp_path / "out") assert results == [] + + +# --------------------------------------------------------------------------- +# _ytdlp_env_opts — cookies / JS-runtime pass-through (GRAPHIFY_YTDLP_*) +# --------------------------------------------------------------------------- + +def test_ytdlp_env_opts_empty_by_default(monkeypatch): + """No env vars set -> no extra yt-dlp options (behaviour unchanged).""" + from graphify.transcribe import _ytdlp_env_opts + monkeypatch.delenv("GRAPHIFY_YTDLP_COOKIES", raising=False) + monkeypatch.delenv("GRAPHIFY_YTDLP_JS_RUNTIMES", raising=False) + assert _ytdlp_env_opts() == {} + + +def test_ytdlp_env_opts_cookies(monkeypatch): + """GRAPHIFY_YTDLP_COOKIES maps to yt-dlp's cookiefile option.""" + from graphify.transcribe import _ytdlp_env_opts + monkeypatch.setenv("GRAPHIFY_YTDLP_COOKIES", "/home/user/cookies.txt") + monkeypatch.delenv("GRAPHIFY_YTDLP_JS_RUNTIMES", raising=False) + assert _ytdlp_env_opts() == {"cookiefile": "/home/user/cookies.txt"} + + +def test_ytdlp_env_opts_js_runtimes(monkeypatch): + """GRAPHIFY_YTDLP_JS_RUNTIMES maps to yt-dlp's js_runtimes dict.""" + from graphify.transcribe import _ytdlp_env_opts + monkeypatch.delenv("GRAPHIFY_YTDLP_COOKIES", raising=False) + monkeypatch.setenv("GRAPHIFY_YTDLP_JS_RUNTIMES", "node, deno") + assert _ytdlp_env_opts() == { + "js_runtimes": {"node": {"path": None}, "deno": {"path": None}}, + } + + +def test_download_audio_applies_env_opts(monkeypatch, tmp_path): + """download_audio() forwards the env-derived options into YoutubeDL.""" + from graphify import transcribe as tr + + monkeypatch.setenv("GRAPHIFY_YTDLP_COOKIES", str(tmp_path / "c.txt")) + monkeypatch.setenv("GRAPHIFY_YTDLP_JS_RUNTIMES", "node") + + captured = {} + + class FakeYDL: + def __init__(self, opts): + captured.update(opts) + def __enter__(self): + return self + def __exit__(self, *a): + return False + def extract_info(self, url, download=True): + return {"ext": "m4a"} + + fake_mod = MagicMock() + fake_mod.YoutubeDL = FakeYDL + with patch("graphify.transcribe._get_yt_dlp", return_value=fake_mod): + tr.download_audio("https://www.youtube.com/watch?v=x", tmp_path / "dl") + + assert captured["cookiefile"] == str(tmp_path / "c.txt") + assert captured["js_runtimes"] == {"node": {"path": None}} + assert captured["noplaylist"] is True # base options still intact