173 lines
10 KiB
Python
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()
|