Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ with clean URLs and never needs JavaScript at all.
auto-detect". `download_video` logs which engine it used instead, so a build
that shipped without the binary — or lost its executable bit in packaging —
is visible in the log rather than silently back to 360p-or-nothing.
- ⚠⚠ **An engine is only HALF of it.** yt-dlp runs a *vendored JavaScript file*
(`yt.solver.core.js`) through the engine, and those `.js` files are the only
non-Python assets in the whole `yt_dlp` package — so a plain PyInstaller
freeze **drops them**, and `vendor.load_script()` reports that by returning
`None` **silently**. The result is indistinguishable from having no engine at
all: the same misleading "This video is not available".
**This is what shipped in v1.6.1** — QuickJS bundled, resolved and logged,
with no script to run. `youtubeweekly.spec` now calls
`collect_data_files("yt_dlp")`; `tests/test_downloader.py` pins that, and
`download_video` logs `solver script: ok|MISSING` beside the engine path.
- ⚠ **Verify a packaging fix in a frozen build, never from source.** From a
checkout those `.js` files are always present, so the bug is invisible — the
first fix passed every source-level test and still shipped broken. Freeze a
probe (`PyInstaller` a script that calls `vendor.load_script` and downloads a
kids video) or parse the shipped exe's PYZ; see
[[feedback_verify_which_build_operator_tested]].
- ⚠ **`js_runtimes` must be a dict** of `{runtime: {config}}`. A list raises a
bare `ValueError` from `YoutubeDL.__init__` that names no option.
- ⚠ **yt-dlp auto-enables only `deno`.** Node can sit on `PATH` completely
Expand Down
24 changes: 23 additions & 1 deletion app/backend/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@
# "is it already downloaded?" check.
PARTIAL_SUFFIXES = (".part", ".ytdl", ".temp")


def js_solver_status():
"""Whether yt-dlp can actually reach its vendored JS challenge solver.

A JS engine is only half of what solving YouTube's signature / "n"
challenge takes: yt-dlp runs a *vendored JavaScript file* through it, and
`vendor.load_script()` returns None **silently** when that file is missing.
v1.6.1 shipped exactly that way - QuickJS bundled and configured, no script
to run - and the only symptom was YouTube's misleading "This video is not
available", indistinguishable from having no engine at all.

Reaches into yt-dlp internals on purpose, and never raises: this is
diagnostics, and a yt-dlp refactor must not be able to break a download.
"""
try:
from yt_dlp.extractor.youtube.jsc._builtin import vendor
return "ok" if vendor.load_script("yt.solver.core.js") else "MISSING"
except Exception as e:
return f"unknown ({type(e).__name__})"

# YouTube does not serve every video to every InnerTube client, and yt-dlp only
# asks the handful it defaults to. Some channels answer UNPLAYABLE - which
# surfaces as the flatly wrong "This video is not available" - on exactly those
Expand Down Expand Up @@ -351,7 +371,9 @@ def download_video(video_url, video_folder, quality_pref="1080p", protect=False,
# Must be a DICT of {runtime: {config}}. A list raises a bare
# ValueError from YoutubeDL.__init__ that names no option.
ydl_opts['js_runtimes'] = {'quickjs': {'path': js_runtime_path}}
logging.info(f"JS engine: {js_runtime_path}")
# Both halves, on one line: a path without a solver script is useless,
# and that combination is precisely what v1.6.1 shipped.
logging.info(f"JS engine: {js_runtime_path} | solver script: {js_solver_status()}")
else:
# Logged rather than warned so a build that shipped without the binary
# (or lost its executable bit in packaging) is visible in the log
Expand Down
50 changes: 50 additions & 0 deletions tests/test_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,53 @@ def test_folder_snapshot_and_newly_downloaded_file(tmp_path):
assert newly_downloaded_file(str(tmp_path), before) == "big.mp4"
assert newly_downloaded_file(str(tmp_path), folder_snapshot(str(tmp_path))) is None
assert folder_snapshot(str(tmp_path / "nope")) == set()


# --- the JS challenge solver's vendored script ----------------------------
#
# A JS engine alone is not enough. yt-dlp runs a vendored JavaScript file
# through it, that file is the only non-Python asset in the yt_dlp package, and
# a plain PyInstaller freeze drops it. v1.6.1 shipped that way - engine present
# and configured, no script to run - and failed identically to having no engine.

def test_vendored_js_solver_script_is_reachable():
"""Guards against a yt-dlp upgrade renaming or moving the script.

Cannot catch the packaging bug itself (from source the file is always
there) - `youtubeweekly.spec` collecting yt_dlp's data files is what does
that, and the test below pins it. What this catches is the *other* way the
same silent failure returns: the filename changing under us, since
`load_script` reports absence by returning None.
"""
from app.backend.downloader import js_solver_status
assert js_solver_status() == "ok"


def test_js_solver_status_never_raises(monkeypatch):
"""It is diagnostics on a private yt-dlp path - a refactor there must not
be able to break a download."""
import app.backend.downloader as dl

def boom(*a, **k):
raise RuntimeError("yt-dlp moved things")

monkeypatch.setattr("importlib.import_module", boom, raising=False)
# Force the import inside the function to fail however it can.
monkeypatch.setitem(__import__("sys").modules,
"yt_dlp.extractor.youtube.jsc._builtin", None)
assert dl.js_solver_status().startswith(("unknown", "MISSING", "ok"))


def test_spec_bundles_yt_dlp_data_files():
"""The actual fix for the v1.6.1 packaging bug.

Asserted against the spec text because the failure mode is invisible from
source and only appears in a frozen build - so there is nothing else here
to pin it to.
"""
import os
spec = os.path.join(os.path.dirname(__file__), "..", "youtubeweekly.spec")
with open(spec, encoding="utf-8") as f:
content = f.read()
assert "collect_data_files" in content
assert 'collect_data_files("yt_dlp")' in content
16 changes: 15 additions & 1 deletion youtubeweekly.spec
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ Build with: pyinstaller youtubeweekly.spec
import os
import sys

from PyInstaller.utils.hooks import collect_data_files

# yt-dlp solves YouTube's signature / "n" challenge by running a *vendored
# JavaScript file* (yt.solver.core.js) through the JS engine - and those .js
# files are the only non-Python files in the whole yt_dlp package, so a plain
# freeze drops them. `vendor.load_script()` then returns None **silently**, the
# challenge is never solved, every adaptive format is discarded, and the
# download dies with YouTube's misleading "This video is not available".
#
# That is exactly what shipped in v1.6.1: the bundled QuickJS was present and
# correctly configured, but had no script to run. Verified by freezing a probe
# both ways - without this call the solver is missing and the download fails;
# with it the script loads and the same video comes down at 1080p.

# The .ico carries every size Windows asks for (16-256) and the .icns is the
# high-resolution master both are generated from - see scripts/build_icon.py.
# macOS wants the .icns natively rather than a converted .ico.
Expand All @@ -24,7 +38,7 @@ a = Analysis(
("app/tools", "app/tools"),
("app/frontend/assets", "assets"),
("docs", "docs"),
],
] + collect_data_files("yt_dlp"),
hiddenimports=[
"yt_dlp",
"plyer",
Expand Down