"""Build the offline Windows x64 installer with only the system C# compiler. Build-time downloads are cached and recorded. The resulting installer has no network code, pip, or dependency on tools installed on the recipient's machine. """ from __future__ import annotations import argparse import concurrent.futures import hashlib import json import os import shutil import subprocess import sys import urllib.request import zipfile from pathlib import Path import requests ROOT = Path(__file__).resolve().parent.parent CACHE = ROOT / ".build-cache" BUILD = ROOT / ".build" KIT = BUILD / "kit" PYTHON = "3.13.14" PYTHON_SHA256 = "90b4e5b9898b72d744650524bff92377c367f44bd5fbd09e3148656c080ad907" NODE = "v24.21.0" FFMPEG_ARCHIVE = "ffmpeg-master-latest-win64-gpl-shared.zip" FFMPEG_SHA256 = "eb7afd4ecced72ab7d5d6fdd36dbc359173aa2daf6081817f7b678345898838b" def digest(path): value = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): value.update(chunk) return value.hexdigest() def fetch(url, name, expected=None): destination = CACHE / name if not destination.is_file(): print(f"Downloading {name}", flush=True) partial = destination.with_suffix(destination.suffix + ".partial") with requests.get(url, stream=True, timeout=90) as source, partial.open("wb") as output: source.raise_for_status() for chunk in source.iter_content(1024 * 1024): output.write(chunk) partial.replace(destination) actual = digest(destination) if expected and actual != expected: raise ValueError(f"Checksum mismatch: {name}") return {"url": url, "file": name, "sha256": actual} def safe_extract(archive, destination): root = destination.resolve() with zipfile.ZipFile(archive) as package: for item in package.infolist(): candidate = (root / item.filename).resolve() if root not in candidate.parents and candidate != root: raise ValueError("Unsafe archive path") package.extract(item, root) def prepare(): CACHE.mkdir(exist_ok=True); BUILD.mkdir(exist_ok=True) if KIT.exists(): if KIT.resolve().parent != BUILD.resolve() or KIT.is_symlink(): raise ValueError("Unsafe build cleanup path") shutil.rmtree(KIT) KIT.mkdir() sources = [ (f"https://www.python.org/ftp/python/{PYTHON}/python-{PYTHON}-embed-amd64.zip", "python.zip", PYTHON_SHA256), (f"https://nodejs.org/dist/{NODE}/node-{NODE}-win-x64.zip", "node.zip", None), (f"https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/{FFMPEG_ARCHIVE}", FFMPEG_ARCHIVE, FFMPEG_SHA256), ] # Node publishes a SHA256 manifest for every official release. response = requests.get(f"https://nodejs.org/dist/{NODE}/SHASUMS256.txt", timeout=30); response.raise_for_status() checksums = response.text node_hash = next(line.split()[0] for line in checksums.splitlines() if line.endswith(f"node-{NODE}-win-x64.zip")) sources[1] = (*sources[1][:2], node_hash) with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: records = list(executor.map(lambda item: fetch(*item), sources)) python = KIT / "runtime" / "python" python.mkdir(parents=True) safe_extract(CACHE / "python.zip", python) (python / "python313._pth").write_text("python313.zip\n.\nLib/site-packages\n../../\nimport site\n", encoding="ascii") # Verify the signed Python executable after extracting the official ZIP. verifier = os.environ.get("YTDL_BUILD_POWERSHELL") or "powershell.exe" signature = subprocess.check_output([verifier, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "$taskSignature=Get-AuthenticodeSignature -LiteralPath $env:YTDL_VERIFY_PYTHON; Write-Output $taskSignature.Status"], env={**os.environ, "YTDL_VERIFY_PYTHON": str(python / "python.exe")}, text=True).strip() if signature != "Valid": raise ValueError("Embedded Python signature verification failed: " + signature) binary = KIT / "runtime" / "bin"; binary.mkdir() licenses = KIT / "licenses"; licenses.mkdir() with zipfile.ZipFile(CACHE / "node.zip") as package: for name in ("node.exe", "LICENSE"): item = next(entry for entry in package.namelist() if entry.endswith("/" + name)) (binary / name if name.endswith(".exe") else licenses / "Node-LICENSE.txt").write_bytes(package.read(item)) with zipfile.ZipFile(CACHE / FFMPEG_ARCHIVE) as package: for item in package.namelist(): name = Path(item).name if "/bin/" in item and (name.endswith(".dll") or name in {"ffmpeg.exe", "ffprobe.exe"}): (binary / name).write_bytes(package.read(item)) elif name == "LICENSE.txt": (licenses / "FFmpeg-LICENSE.txt").write_bytes(package.read(item)) wheels = CACHE / "wheels"; wheels.mkdir(exist_ok=True) options = ["--platform", "win_amd64", "--python-version", "3.13", "--implementation", "cp", "--abi", "cp313", "--abi", "abi3", "--abi", "none", "--only-binary", ":all:"] requirement = "yt-dlp[default]==2026.8.19" subprocess.run([sys.executable, "-m", "pip", "download", "--disable-pip-version-check", "-d", str(wheels), *options, requirement], check=True) subprocess.run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--no-index", "--find-links", str(wheels), "--target", str(python / "Lib" / "site-packages"), "--no-compile", "--ignore-requires-python", *options, requirement], check=True) (licenses / "THIRD-PARTY.txt").write_text( "Python: https://www.python.org/ ; license: runtime/python/LICENSE.txt\n" "Node.js: https://nodejs.org/ ; license: Node-LICENSE.txt\n" "FFmpeg Windows build and corresponding source/build instructions: https://github.com/yt-dlp/FFmpeg-Builds ; GPL license: FFmpeg-LICENSE.txt\n" "yt-dlp and Python libraries: runtime/python/Lib/site-packages/*.dist-info (licenses and metadata)\n" "yt-dlp source: https://github.com/yt-dlp/yt-dlp/tree/2026.08.19\n", encoding="utf-8") (BUILD / "sources.json").write_text(json.dumps(records, indent=2), encoding="utf-8") def build(): if not KIT.is_dir(): raise ValueError("Run with --prepare first.") scripts = KIT / "runtime/python/Lib/site-packages/bin" if scripts.exists(): if KIT.resolve() not in scripts.resolve().parents or scripts.is_symlink(): raise ValueError("Unsafe console-script cleanup path") # pip's generated EXE launchers refer to the build interpreter. The helper # imports these libraries directly, so no console launchers are shipped. shutil.rmtree(scripts) shutil.copytree(ROOT / "host", KIT / "host", dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) sys.path.insert(0, str(ROOT)) from host.common import VERSION version = VERSION metadata = {"product": "YouTubeDL.Helper", "mode": "autonomous", "version": version, "platform": "windows-x64", "tools": {"python": "runtime/python/python.exe", "node": "runtime/bin/node.exe", "ffmpeg": "runtime/bin/ffmpeg.exe", "ffprobe": "runtime/bin/ffprobe.exe"}, "sources": json.loads((BUILD / "sources.json").read_text(encoding="utf-8"))} (KIT / "bundle.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8") environment = {**os.environ, "PATH": str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"), "PYTHONPATH": "C:/NONEXISTENT", "PYTHONHOME": "C:/NONEXISTENT", "PYTHONNOUSERSITE": "1"} subprocess.run([str(KIT / "runtime/python/python.exe"), "-I", "-B", "-m", "host.bootstrap", "verify"], env=environment, check=True) files = {str(path.relative_to(KIT)).replace("\\", "/"): digest(path) for path in KIT.rglob("*") if path.is_file() and path.name != "bundle-files.json"} (KIT / "bundle-files.json").write_text(json.dumps(files, indent=2), encoding="utf-8") archive = BUILD / "bundle.zip" with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as package: for file in KIT.rglob("*"): if file.is_file(): package.write(file, str(file.relative_to(KIT))) code = (ROOT / "tools" / "setup.cs").read_text(encoding="utf-8").replace("@BUNDLE_VERSION@", version).replace("@BUNDLE_HASH@", digest(archive)) source = BUILD / "setup.cs"; source.write_text(code, encoding="utf-8-sig") compiler = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Microsoft.NET/Framework64/v4.0.30319/csc.exe" common = [str(compiler), "/nologo", "/target:winexe", "/platform:x64", "/optimize+", "/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", "/r:System.IO.Compression.dll", "/r:System.IO.Compression.FileSystem.dll", "/r:System.Web.Extensions.dll", f"/win32manifest:{ROOT / 'tools/setup.manifest'}"] manager = BUILD / "YouTubeDL-Manager.exe" subprocess.run([*common, f"/out:{manager}", str(source)], check=True) (ROOT / "dist").mkdir(exist_ok=True) installer = ROOT / "dist/YouTubeDL-Helper-Setup.exe" subprocess.run([*common, "/define:SETUP", f"/resource:{archive},bundle.zip", f"/resource:{manager},manager.exe", f"/out:{installer}", str(source)], check=True) destination = ROOT / "dist" / f"YouTubeDL-Helper-{version}-Setup.exe" shutil.copy2(installer, destination) (ROOT / "dist" / f"YouTubeDL-Helper-{version}-manifest.json").write_text(json.dumps({**metadata, "installerSHA256": digest(installer), "installerBytes": installer.stat().st_size, "bundleSHA256": digest(archive)}, indent=2), encoding="utf-8") print(f"Offline installer built: {destination} ({installer.stat().st_size / 1024**2:.1f} MiB)", flush=True) if __name__ == "__main__": parser = argparse.ArgumentParser(); parser.add_argument("--prepare", action="store_true"); parser.add_argument("--prepare-only", action="store_true") args = parser.parse_args() if args.prepare or args.prepare_only: prepare() if not args.prepare_only: build()