Release downloads
Lifetime GitHub release-asset downloads by operating system · generated {generated}
+{legend}
{chart_svg(rows)}
+By release
| Release | Published | macOS | Windows | Linux |
{table_rows}
+Country data is unavailable
The GitHub Releases API publishes a lifetime count for each asset, but no downloader location or country. A country chart requires first-party download telemetry (for example, a redirect endpoint or CDN logs) with an explicit privacy policy. GitHub counts are cumulative and do not provide a daily history; save regular snapshots if you need downloads over time.
+Source: github.com/{html.escape(repository)}/releases. Counts include app packages only; checksum, signature, and runtime-support assets are excluded.
"""
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repository", default=REPOSITORY)
+ parser.add_argument("--input", type=Path, help="Read GitHub Releases API JSON from a file")
+ parser.add_argument("--output", type=Path, default=Path(".docs/release-downloads.html"))
+ args = parser.parse_args()
+ releases = (
+ json.loads(args.input.read_text(encoding="utf-8"))
+ if args.input
+ else fetch_releases(args.repository)
+ )
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(render(releases, args.repository), encoding="utf-8")
+ print(f"Wrote {args.output} from {len(releases)} releases")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/update_vendored_ejs.py b/scripts/update_vendored_ejs.py
new file mode 100644
index 00000000..0370bbf4
--- /dev/null
+++ b/scripts/update_vendored_ejs.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Refresh the vendored yt-dlp-ejs payload in app/_vendor.
+
+Vendoring normally means remembering to update something, which is a bad trade.
+This exists so it does not: run with no arguments to check whether PyPI has a
+newer release, and with --apply to take it.
+
+ python scripts/update_vendored_ejs.py # check only, exit 1 if stale
+ python scripts/update_vendored_ejs.py --apply # fetch and replace
+
+Why the package is vendored at all rather than declared as a dependency: the
+desktop updater derives runtimeId from sha256(uv.lock) and stands down when it
+changes, because it can replace backend/ but never python/. Adding a dependency
+would have sent every existing desktop install to a manual reinstall. This is a
+53 KB pure-Python payload that StemDeck never imports and yt-dlp discovers at
+runtime, so it belongs in the app layer the updater does replace.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import io
+import json
+import re
+import shutil
+import sys
+import urllib.request
+import zipfile
+from pathlib import Path
+
+PACKAGE = "yt-dlp-ejs"
+VENDOR = Path(__file__).resolve().parents[1] / "app" / "_vendor"
+PKG_DIR = VENDOR / "yt_dlp_ejs"
+LICENSE_DEST = VENDOR / "LICENSE.yt-dlp-ejs"
+
+
+def installed_version() -> str | None:
+ version_file = PKG_DIR / "_version.py"
+ if not version_file.is_file():
+ return None
+ # setuptools-scm's _version.py lists "__version__" in __all__ before it
+ # assigns it, so match the assignment specifically rather than the name.
+ match = re.search(
+ r"""^__version__(?:\s*:\s*str)?\s*=.*?["']([^"']+)["']""",
+ version_file.read_text(encoding="utf-8"),
+ re.M,
+ )
+ return match.group(1) if match else None
+
+
+def latest_release() -> tuple[str, str, str]:
+ """(version, wheel url, sha256) for the newest release on PyPI."""
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{PACKAGE}/json", timeout=30) as fh:
+ data = json.load(fh)
+ version = data["info"]["version"]
+ for entry in data["urls"]:
+ if entry["filename"].endswith(".whl"):
+ return version, entry["url"], entry["digests"]["sha256"]
+ raise SystemExit(f"{PACKAGE} {version} publishes no wheel")
+
+
+def apply(url: str, sha256: str) -> None:
+ with urllib.request.urlopen(url, timeout=60) as fh:
+ blob = fh.read()
+ got = hashlib.sha256(blob).hexdigest()
+ if got != sha256:
+ raise SystemExit(f"checksum mismatch: expected {sha256}, got {got}")
+
+ archive = zipfile.ZipFile(io.BytesIO(blob))
+ if PKG_DIR.exists():
+ shutil.rmtree(PKG_DIR)
+ VENDOR.mkdir(parents=True, exist_ok=True)
+
+ for name in archive.namelist():
+ if name.startswith("yt_dlp_ejs/") and not name.endswith("/"):
+ dest = VENDOR / name
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(archive.read(name))
+ elif name.lower().endswith("licenses/license"):
+ LICENSE_DEST.write_bytes(archive.read(name))
+
+ # Bytecode from whoever ran this has no business in the repo or the package.
+ for cache in PKG_DIR.rglob("__pycache__"):
+ shutil.rmtree(cache, ignore_errors=True)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--apply", action="store_true", help="fetch and replace the payload")
+ args = parser.parse_args()
+
+ have = installed_version()
+ version, url, sha256 = latest_release()
+
+ if have == version:
+ print(f"{PACKAGE} {version} vendored, up to date")
+ return 0
+
+ if not args.apply:
+ print(f"{PACKAGE}: vendored {have or '(none)'}, latest {version}")
+ print("run with --apply to update")
+ return 1
+
+ apply(url, sha256)
+ print(f"{PACKAGE}: {have or '(none)'} -> {version}")
+ print(f" sha256 {sha256}")
+ print(" review the diff and commit app/_vendor")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1
index 4a071035..4288810c 100644
--- a/scripts/windows/make-portable.ps1
+++ b/scripts/windows/make-portable.ps1
@@ -168,6 +168,36 @@ New-Item -ItemType Directory -Force (Join-Path $Stage "data") | Out-Null
foreach ($Dir in @("cache", "downloads", "ffmpeg", "jobs", "logs", "models")) {
New-Item -ItemType Directory -Force (Join-Path $Stage "data\$Dir") | Out-Null
}
+# QuickJS, for YouTube's signature/n-challenge solver (#438). yt-dlp ships the
+# solver script (the yt-dlp-ejs dependency) but needs a JavaScript engine to run
+# it, and a portable install has nothing on PATH.
+#
+# quickjs-ng rather than deno: 2 MB against deno's ~110 MB, times two bundles,
+# against a 2 GiB release asset cap this project has already hit once (#318).
+#
+# It lives in backend/, not data/. The in-app updater replaces the executable
+# and backend/ and leaves data/ alone, so a binary in data/ would only ever
+# reach a fresh install. Here it arrives through an ordinary update, which is
+# the whole point: shipping this must not cost existing users a reinstall.
+#
+# Pinned by version and SHA256, the same rule as the macOS FFmpeg download
+# (#172). An unverified binary fetched at package time is a supply-chain hole
+# whether or not it is small.
+$QjsVersion = "v0.16.2"
+$QjsSha256 = "7b27412de844403545bd151fbe49191b4d5b91a9e15b5db7c863fea54639a82b"
+$QjsUrl = "https://github.com/quickjs-ng/quickjs/releases/download/$QjsVersion/qjs-windows-x86_64.exe"
+$QjsDir = Join-Path $BackendDir "jsruntime"
+New-Item -ItemType Directory -Force $QjsDir | Out-Null
+$QjsExe = Join-Path $QjsDir "qjs.exe"
+Write-Host "Fetching QuickJS $QjsVersion ..."
+Invoke-WebRequest -Uri $QjsUrl -OutFile $QjsExe -UseBasicParsing
+$QjsActual = (Get-FileHash -Path $QjsExe -Algorithm SHA256).Hash.ToLower()
+if ($QjsActual -ne $QjsSha256) {
+ Remove-Item -Force $QjsExe
+ throw "QuickJS checksum mismatch: expected $QjsSha256, got $QjsActual"
+}
+Write-Host "QuickJS verified."
+
# Portable marker: present in every zip (CPU and NVIDIA alike) so double-
# clicking StemDeck.exe uses .\data next to the exe for ffmpeg/models/config/
# logs instead of AppData (#399). Root-only trust, mirroring cpu-only below.
diff --git a/tests/test_js_runtime.py b/tests/test_js_runtime.py
new file mode 100644
index 00000000..beffce52
--- /dev/null
+++ b/tests/test_js_runtime.py
@@ -0,0 +1,149 @@
+"""Finding the bundled JS runtime, on every packaging layout (#438).
+
+yt-dlp ships the challenge solver as a Python package (the yt-dlp-ejs
+dependency) but needs a JavaScript engine to execute it. The three packages do
+not put that engine in the same place:
+
+ Windows / Linux a staged tree, binary in data/jsruntime
+ macOS a downloaded runtime pack, binary next to backend/
+ Docker deno on PATH, nothing bundled
+
+Getting this wrong is silent. bundled_js_runtime() returns None, yt-dlp falls
+back to the client that skips the challenge, and imports keep working until
+that client goes away.
+"""
+
+from __future__ import annotations
+
+import sys
+
+import pytest
+
+from app.core import config as cfg
+
+
+def _touch(directory, stem):
+ directory.mkdir(parents=True, exist_ok=True)
+ exe = directory / (f"{stem}.exe" if sys.platform.startswith("win") else stem)
+ exe.write_bytes(b"#!/bin/sh\n")
+ return exe
+
+
+def test_nothing_bundled_is_not_an_error(tmp_path, monkeypatch):
+ """Docker and source checkouts bundle nothing and resolve deno from PATH.
+ None is the correct answer, not a failure."""
+ monkeypatch.setattr(cfg, "JS_RUNTIME_DIR", tmp_path / "absent")
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (tmp_path / "absent",))
+ assert cfg.bundled_js_runtime() is None
+
+
+def test_the_staged_layout_is_found(tmp_path, monkeypatch):
+ """Windows and Linux: data/jsruntime, which is JS_RUNTIME_DIR's default."""
+ exe = _touch(tmp_path / "data" / "jsruntime", "qjs")
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (tmp_path / "data" / "jsruntime",))
+ assert cfg.bundled_js_runtime() == ("quickjs", exe)
+
+
+def test_the_runtime_pack_layout_is_found(tmp_path, monkeypatch):
+ """macOS: beside backend/, because the app's data directory is user-owned
+ and a binary there would have to be installed rather than shipped."""
+ exe = _touch(tmp_path / "runtime" / "jsruntime", "qjs")
+ monkeypatch.setattr(
+ cfg, "_js_runtime_dirs", lambda: (tmp_path / "absent", tmp_path / "runtime" / "jsruntime")
+ )
+ assert cfg.bundled_js_runtime() == ("quickjs", exe)
+
+
+def test_the_real_search_path_covers_both_layouts():
+ """The list itself, not a monkeypatched stand-in: a refactor that drops the
+ second location would leave macOS silently unbundled."""
+ dirs = cfg._js_runtime_dirs()
+ assert cfg.JS_RUNTIME_DIR in dirs
+ assert len(dirs) >= 2
+ assert len(set(dirs)) == len(dirs), "duplicate directories mean a wasted stat per lookup"
+
+
+def test_ytdlp_preference_order_is_honoured(tmp_path, monkeypatch):
+ """deno 1000 > node 900 > quickjs 850 in yt-dlp's own provider ranking. A
+ build shipping more than one should get the solver yt-dlp would pick."""
+ directory = tmp_path / "jsruntime"
+ _touch(directory, "qjs")
+ deno = _touch(directory, "deno")
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (directory,))
+ assert cfg.bundled_js_runtime() == ("deno", deno)
+
+
+def test_an_unreadable_directory_does_not_raise(tmp_path, monkeypatch):
+ """Never raises: this runs inside every YoutubeDL construction, and a
+ permissions problem must degrade to 'not bundled', not fail an import."""
+
+ class Boom:
+ def is_dir(self):
+ raise OSError("permission denied")
+
+ good = _touch(tmp_path / "ok", "qjs")
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (Boom(), tmp_path / "ok"))
+ assert cfg.bundled_js_runtime() == ("quickjs", good)
+
+
+def test_solver_availability_reports_a_bundled_runtime(tmp_path, monkeypatch):
+ directory = tmp_path / "jsruntime"
+ _touch(directory, "qjs")
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (directory,))
+ assert cfg.js_solver_available() is True
+
+
+def test_the_ejs_solver_resolves_from_the_vendored_copy():
+ """The engine is useless without the script. yt-dlp resolves this package
+ before any remote source, which is what keeps --remote-components off, and
+ it must come from app/_vendor rather than site-packages: a dependency would
+ change uv.lock, shift runtimeId, and stand the desktop updater down."""
+ import yt_dlp.dependencies
+ import yt_dlp_ejs
+
+ assert yt_dlp.dependencies.yt_dlp_ejs is not None, (
+ "the solver is missing; yt-dlp would fall back to fetching it at runtime"
+ )
+ assert "_vendor" in yt_dlp_ejs.__file__.replace("\\", "/"), (
+ f"resolved from {yt_dlp_ejs.__file__}, not the vendored copy"
+ )
+
+
+def test_the_vendored_payload_is_complete():
+ """The Python shim alone is not the solver. Without the .js files yt-dlp
+ finds the package, believes it has a solver, and fails at solve time."""
+ from pathlib import Path
+
+ import app
+
+ vendor = Path(app.__file__).resolve().parent / "_vendor" / "yt_dlp_ejs"
+ scripts = list((vendor / "yt" / "solver").glob("*.js"))
+ assert scripts, "no solver scripts vendored"
+ assert all(p.stat().st_size > 0 for p in scripts)
+
+
+def test_the_vendor_path_insert_is_idempotent():
+ """app/__init__ runs on every import of the package. Re-importing must not
+ keep growing sys.path."""
+ import importlib
+ import sys
+
+ import app
+
+ before = sys.path.count(
+ str(__import__("pathlib").Path(app.__file__).resolve().parent / "_vendor")
+ )
+ importlib.reload(app)
+ after = sys.path.count(
+ str(__import__("pathlib").Path(app.__file__).resolve().parent / "_vendor")
+ )
+ assert after == before == 1
+
+
+@pytest.mark.parametrize("stem", ["deno", "node", "qjs"])
+def test_every_supported_runtime_is_recognised(tmp_path, monkeypatch, stem):
+ directory = tmp_path / stem
+ exe = _touch(directory, stem)
+ monkeypatch.setattr(cfg, "_js_runtime_dirs", lambda: (directory,))
+ found = cfg.bundled_js_runtime()
+ assert found is not None and found[1] == exe
From 03acbf44992e516ab478e1c16f92effaf663dfb6 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 16:34:38 +0100
Subject: [PATCH 2/3] Assert the build scripts and the runtime lookup still
agree
A cross-language contract with nothing checking it. Three shell and PowerShell
scripts decide where the JS engine lands; Python decides where to look for it.
A mismatch does not fail a build or a test, it shows up as YouTube imports
quietly degrading on one platform, because yt-dlp just falls back to the
client that skips the challenge.
Two tests. One pins the packaged layout: in a package this file is
backend/app/core/config.py, so parents[2] is backend/, and a refactor of that
index would leave every desktop package without an engine. The other reads the
three scripts and asserts they still install into backend/ and still verify a
checksum before trusting the binary.
---
tests/test_js_runtime.py | 46 +++++++++++++++++++++++++++++++++++-----
1 file changed, 41 insertions(+), 5 deletions(-)
diff --git a/tests/test_js_runtime.py b/tests/test_js_runtime.py
index beffce52..d45d5b09 100644
--- a/tests/test_js_runtime.py
+++ b/tests/test_js_runtime.py
@@ -54,15 +54,51 @@ def test_the_runtime_pack_layout_is_found(tmp_path, monkeypatch):
assert cfg.bundled_js_runtime() == ("quickjs", exe)
-def test_the_real_search_path_covers_both_layouts():
- """The list itself, not a monkeypatched stand-in: a refactor that drops the
- second location would leave macOS silently unbundled."""
+def test_the_real_search_path_covers_the_packaged_layout():
+ """The list itself, not a monkeypatched stand-in.
+
+ In a package this file is backend/app/core/config.py, so parents[2] is
+ backend/ and backend/jsruntime is where all three build scripts put the
+ binary. A refactor that changes that index leaves every desktop package
+ silently without an engine, and silently is the whole problem: yt-dlp just
+ falls back to the client that skips the challenge.
+ """
+ from pathlib import Path
+
dirs = cfg._js_runtime_dirs()
- assert cfg.JS_RUNTIME_DIR in dirs
- assert len(dirs) >= 2
+ assert cfg.JS_RUNTIME_DIR in dirs, "the env-var override must still win"
+ package_root = Path(cfg.__file__).resolve().parents[2]
+ assert package_root / "jsruntime" in dirs, (
+ "backend/jsruntime is not searched; the build scripts put the engine there"
+ )
assert len(set(dirs)) == len(dirs), "duplicate directories mean a wasted stat per lookup"
+def test_the_build_scripts_agree_with_the_lookup():
+ """A cross-language contract: three shell/PowerShell scripts choose where
+ the engine lands, and Python decides where to look. Nothing else checks
+ that those two facts still match, and a mismatch only shows up as YouTube
+ imports quietly degrading on one platform.
+ """
+ from pathlib import Path
+
+ repo = Path(cfg.__file__).resolve().parents[2]
+ scripts = {
+ "windows": repo / "scripts" / "windows" / "make-portable.ps1",
+ "linux": repo / "scripts" / "linux" / "make-portable.sh",
+ "macos": repo / "scripts" / "macos" / "make-runtime-pack.sh",
+ }
+ for name, path in scripts.items():
+ if not path.is_file():
+ continue # running from an installed package, not the repo
+ text = path.read_text(encoding="utf-8")
+ assert "jsruntime" in text, f"{name} no longer installs a JS runtime"
+ assert "BackendDir" in text or "BACKEND_DIR" in text, (
+ f"{name} puts the engine outside backend/, where the updater cannot reach it"
+ )
+ assert "sha256" in text.lower(), f"{name} fetches a binary without verifying it"
+
+
def test_ytdlp_preference_order_is_honoured(tmp_path, monkeypatch):
"""deno 1000 > node 900 > quickjs 850 in yt-dlp's own provider ranking. A
build shipping more than one should get the solver yt-dlp would pick."""
From 8a0a94dd50f45c0d0b432250ffbb3a3c2c9f3781 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 16:39:06 +0100
Subject: [PATCH 3/3] Drop scripts/release_download_chart.py from this branch
Swept in by a careless 'git add scripts/'. It is unrelated work in progress
that was untracked before this branch started, and it belongs to whoever is
writing it, not to a change about YouTube's challenge solver. The file is
untouched on disk.
---
scripts/release_download_chart.py | 149 ------------------------------
1 file changed, 149 deletions(-)
delete mode 100644 scripts/release_download_chart.py
diff --git a/scripts/release_download_chart.py b/scripts/release_download_chart.py
deleted file mode 100644
index ea9cdb77..00000000
--- a/scripts/release_download_chart.py
+++ /dev/null
@@ -1,149 +0,0 @@
-#!/usr/bin/env python3
-"""Build a standalone GitHub release-download report for StemDeck.
-
-GitHub exposes a lifetime download_count for each release asset. It does not
-expose downloader IP addresses or countries, so this report deliberately does
-not manufacture a geographic breakdown.
-"""
-
-from __future__ import annotations
-
-import argparse
-import html
-import json
-import os
-import urllib.request
-from collections import defaultdict
-from datetime import UTC, datetime
-from pathlib import Path
-
-REPOSITORY = "stemdeckapp/stemdeck"
-PLATFORMS = ("macOS", "Windows", "Linux")
-COLORS = {"macOS": "#a78bfa", "Windows": "#38bdf8", "Linux": "#fbbf24"}
-
-
-def platform_for_asset(name: str) -> str | None:
- lower = name.lower()
- # Count installable application bundles only. Checksums and runtime packs
- # are support artifacts and would inflate the number of app downloads.
- if lower.endswith((".sha256", ".txt", ".sig")) or "runtime" in lower:
- return None
- if "macos" in lower and lower.endswith(".dmg"):
- return "macOS"
- if "windows" in lower and lower.endswith((".zip", ".exe", ".msi")):
- return "Windows"
- if "linux" in lower and lower.endswith((".tar.gz", ".appimage", ".deb", ".rpm")):
- return "Linux"
- return None
-
-
-def release_rows(releases: list[dict]) -> list[dict]:
- rows = []
- for release in releases:
- counts = defaultdict(int)
- for asset in release.get("assets", []):
- platform = platform_for_asset(asset.get("name", ""))
- if platform:
- counts[platform] += int(asset.get("download_count", 0))
- if counts:
- rows.append(
- {
- "tag": release.get("tag_name", "untagged"),
- "date": (release.get("published_at") or release.get("created_at") or "")[:10],
- **{platform: counts[platform] for platform in PLATFORMS},
- }
- )
- return sorted(rows, key=lambda row: (row["date"], row["tag"]))
-
-
-def fetch_releases(repository: str) -> list[dict]:
- url = f"https://api.github.com/repos/{repository}/releases?per_page=100"
- headers = {"Accept": "application/vnd.github+json", "User-Agent": "release-download-chart"}
- token = os.environ.get("GITHUB_TOKEN")
- if token:
- headers["Authorization"] = f"Bearer {token}"
- request = urllib.request.Request(url, headers=headers)
- with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
- return json.load(response)
-
-
-def chart_svg(rows: list[dict]) -> str:
- width, height = 1040, 430
- left, right, top, bottom = 72, 24, 28, 76
- plot_w, plot_h = width - left - right, height - top - bottom
- maximum = max((row[p] for row in rows for p in PLATFORMS), default=1)
- maximum = max(maximum, 1)
- x = lambda i: left + (plot_w / max(len(rows) - 1, 1)) * i
- y = lambda value: top + plot_h - (value / maximum) * plot_h
- parts = [
- f'Release downloads
Lifetime GitHub release-asset downloads by operating system · generated {generated}
-{legend}
{chart_svg(rows)}
-By release
| Release | Published | macOS | Windows | Linux |
{table_rows}
-Country data is unavailable
The GitHub Releases API publishes a lifetime count for each asset, but no downloader location or country. A country chart requires first-party download telemetry (for example, a redirect endpoint or CDN logs) with an explicit privacy policy. GitHub counts are cumulative and do not provide a daily history; save regular snapshots if you need downloads over time.
-Source: github.com/{html.escape(repository)}/releases. Counts include app packages only; checksum, signature, and runtime-support assets are excluded.
"""
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--repository", default=REPOSITORY)
- parser.add_argument("--input", type=Path, help="Read GitHub Releases API JSON from a file")
- parser.add_argument("--output", type=Path, default=Path(".docs/release-downloads.html"))
- args = parser.parse_args()
- releases = (
- json.loads(args.input.read_text(encoding="utf-8"))
- if args.input
- else fetch_releases(args.repository)
- )
- args.output.parent.mkdir(parents=True, exist_ok=True)
- args.output.write_text(render(releases, args.repository), encoding="utf-8")
- print(f"Wrote {args.output} from {len(releases)} releases")
-
-
-if __name__ == "__main__":
- main()