Files
YouTubeDL/tools/build_bundle.py
T
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

173 lines
10 KiB
Python

"""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()