from __future__ import annotations import argparse import hashlib import os import re import shutil import sys import tempfile from dataclasses import dataclass from pathlib import Path from urllib.request import Request, urlopen from zipfile import ZIP_STORED, ZipFile, ZipInfo PLUGIN_ID = "cinema-tools" DEFAULT_INSTANCE = "1.44.1" YT_DLP_REPO = "yt-dlp/yt-dlp" YT_DLP_ASSET = "yt-dlp.exe" YT_DLP_VERSION = "2026.07.04" YT_DLP_URL = f"https://github.com/{YT_DLP_REPO}/releases/download/{YT_DLP_VERSION}/{YT_DLP_ASSET}" YT_DLP_SHA256 = "52fe3c26dcf71fbdc85b528589020bb0b8e383155cfa81b64dd447bbe35e24b8" FFMPEG_VERSION = "8.1.2" FFMPEG_URL = ( f"https://github.com/GyanD/codexffmpeg/releases/download/{FFMPEG_VERSION}/" f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip" ) FFMPEG_ZIP_SHA256 = "db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec" FFMPEG_EXE_SHA256 = "1326dde4c84ff1f96fe6b8916c5bed29e163e9b5dccf995f6f3db069d143ec5e" ASSET_NAME = f"CinemaTools-yt-dlp-{YT_DLP_VERSION}-ffmpeg-{FFMPEG_VERSION}.zip" BUNDLE_SHA256 = "4d299e40f747abb9777838542ca68ec450e0b86e6eb11af341e6bd568f47edf2" ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) _TAG_RE = re.compile(r"^yt-dlp-(?P.+)-ffmpeg-(?P[^-]+)$") def bundle_tag(yt_dlp_version: str) -> str: return f"yt-dlp-{yt_dlp_version}-ffmpeg-{FFMPEG_VERSION}" def asset_name(yt_dlp_version: str) -> str: return f"CinemaTools-{bundle_tag(yt_dlp_version)}.zip" def yt_dlp_url(yt_dlp_version: str) -> str: return f"https://github.com/{YT_DLP_REPO}/releases/download/{yt_dlp_version}/{YT_DLP_ASSET}" def yt_dlp_version_from_tag(tag: str | None) -> str | None: match = _TAG_RE.match(tag or "") return match.group("yt_dlp") if match else None def lock_reason(yt_dlp_version: str, yt_dlp_sha256: str, instance: str) -> str: return ( "Helper-managed Windows executable bundle for Cinema's hardcoded Proton paths " "Libs/yt-dlp.exe and Libs/ffmpeg.exe. Rebuild with PYTHONPATH=src python -m " f"plugin_helper.recipes.cinema_tools --instance {instance} --yt-dlp-version {yt_dlp_version} " f"--yt-dlp-sha256 {yt_dlp_sha256}. Sources: {yt_dlp_url(yt_dlp_version)} with sha256 " f"{yt_dlp_sha256}, and {FFMPEG_URL} with sha256 {FFMPEG_ZIP_SHA256}; the recipe extracts " f"*/bin/ffmpeg.exe with sha256 {FFMPEG_EXE_SHA256} and writes a deterministic root zip." ) @dataclass(frozen=True) class BuiltAsset: path: Path sha256: str size: int def _default_state_root() -> Path: configured = os.environ.get("PLUGIN_HELPER_STATE_DIR") if configured: return Path(configured).expanduser() xdg_state_home = os.environ.get("XDG_STATE_HOME") if xdg_state_home: return Path(xdg_state_home).expanduser() / "plugin-helper" return Path("~/.local/state/plugin-helper").expanduser() def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _download(url: str, destination: Path) -> None: request = Request(url, headers={"User-Agent": "plugin-helper"}) destination.parent.mkdir(parents=True, exist_ok=True) with urlopen(request, timeout=120) as response, destination.open("wb") as handle: shutil.copyfileobj(response, handle) def _verify(path: Path, expected_sha256: str, label: str) -> None: actual = _sha256_file(path) if actual != expected_sha256: raise ValueError(f"{label} sha256 mismatch: expected {expected_sha256}, got {actual}") def _read_ffmpeg_exe(archive_path: Path) -> bytes: with ZipFile(archive_path) as archive: matches = [ info for info in archive.infolist() if not info.is_dir() and info.filename.replace("\\", "/").endswith("/bin/ffmpeg.exe") ] if len(matches) != 1: raise ValueError(f"expected exactly one ffmpeg.exe under */bin/, found {len(matches)}") data = archive.read(matches[0]) actual = hashlib.sha256(data).hexdigest() if actual != FFMPEG_EXE_SHA256: raise ValueError(f"ffmpeg.exe sha256 mismatch: expected {FFMPEG_EXE_SHA256}, got {actual}") return data def _write_member(archive: ZipFile, name: str, data: bytes) -> None: info = ZipInfo(name, date_time=ZIP_TIMESTAMP) info.compress_type = ZIP_STORED info.create_system = 3 # Unix; keep the bundle hash stable across hosts info.external_attr = 0o644 << 16 archive.writestr(info, data) def build_asset( output: Path, *, yt_dlp_version: str = YT_DLP_VERSION, yt_dlp_sha256: str = YT_DLP_SHA256, work_dir: Path | None = None, ) -> BuiltAsset: output = output.expanduser() output.parent.mkdir(parents=True, exist_ok=True) # The bundle hash is only known ahead of time for the pinned yt-dlp release. expected_bundle_sha256 = BUNDLE_SHA256 if yt_dlp_version == YT_DLP_VERSION else None name = asset_name(yt_dlp_version) with tempfile.TemporaryDirectory(dir=work_dir) as tmp: tmp_path = Path(tmp) yt_dlp_path = tmp_path / YT_DLP_ASSET ffmpeg_zip_path = tmp_path / f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip" staged_output = tmp_path / name _download(yt_dlp_url(yt_dlp_version), yt_dlp_path) _verify(yt_dlp_path, yt_dlp_sha256, YT_DLP_ASSET) _download(FFMPEG_URL, ffmpeg_zip_path) _verify(ffmpeg_zip_path, FFMPEG_ZIP_SHA256, "ffmpeg essentials zip") yt_dlp_data = yt_dlp_path.read_bytes() ffmpeg_data = _read_ffmpeg_exe(ffmpeg_zip_path) with ZipFile(staged_output, "w") as archive: _write_member(archive, "Libs/yt-dlp.exe", yt_dlp_data) _write_member(archive, "Libs/ffmpeg.exe", ffmpeg_data) actual_bundle_sha256 = _sha256_file(staged_output) if expected_bundle_sha256 and actual_bundle_sha256 != expected_bundle_sha256: raise ValueError( f"{name} sha256 mismatch: expected {expected_bundle_sha256}, got {actual_bundle_sha256}" ) shutil.move(str(staged_output), output) return BuiltAsset(path=output, sha256=_sha256_file(output), size=output.stat().st_size) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Rebuild the helper-managed Cinema tools bundle.") parser.add_argument("--instance", default=DEFAULT_INSTANCE, help="Beat Saber instance/version cache key") parser.add_argument( "--state-dir", type=Path, default=_default_state_root(), help="plugin-helper state root; defaults to PLUGIN_HELPER_STATE_DIR, XDG_STATE_HOME, or ~/.local/state/plugin-helper", ) parser.add_argument( "--output", type=Path, help="Exact output path; defaults to /cache/downloads//cinema-tools/", ) parser.add_argument("--work-dir", type=Path, help="Parent directory for temporary downloads") parser.add_argument("--yt-dlp-version", default=YT_DLP_VERSION, help="yt-dlp release tag to bundle") parser.add_argument( "--yt-dlp-sha256", help="Expected yt-dlp.exe sha256; required when --yt-dlp-version differs from the pinned release", ) return parser def run(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) yt_dlp_sha256 = args.yt_dlp_sha256 or (YT_DLP_SHA256 if args.yt_dlp_version == YT_DLP_VERSION else None) if not yt_dlp_sha256: parser.error("--yt-dlp-sha256 is required with a non-default --yt-dlp-version") output = args.output or ( args.state_dir / "cache" / "downloads" / args.instance / PLUGIN_ID / asset_name(args.yt_dlp_version) ) try: built = build_asset( output, yt_dlp_version=args.yt_dlp_version, yt_dlp_sha256=yt_dlp_sha256, work_dir=args.work_dir, ) except Exception as exc: print(f"error: {exc}", file=sys.stderr) return 2 print(f"Wrote: {built.path}") print(f"Size: {built.size}") print(f"SHA-256: {built.sha256}") print("Members:") print(f" Libs/yt-dlp.exe {yt_dlp_sha256}") print(f" Libs/ffmpeg.exe {FFMPEG_EXE_SHA256}") return 0 def main() -> None: raise SystemExit(run()) if __name__ == "__main__": main()