diff --git a/README.md b/README.md index fe5ad2b..efb8e87 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,14 @@ Install assets are currently expected to already exist locally, usually under: /cache/downloads/// ``` +Composite local assets should have an explicit reconstruction recipe checked in +with the helper. For example, Cinema's external Windows downloader tools are a +small deterministic bundle rebuilt from upstream executables with: + +```sh +PYTHONPATH=src python -m plugin_helper.recipes.cinema_tools --instance 1.44.1 +``` + Use `updates` to audit the version lock against obvious public update sources: ```sh diff --git a/locks/1.44.1.lock.toml b/locks/1.44.1.lock.toml index 92ddea3..36bf5a1 100644 --- a/locks/1.44.1.lock.toml +++ b/locks/1.44.1.lock.toml @@ -272,9 +272,9 @@ reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSabe id = "cinema-tools" tag = "yt-dlp-2026.07.04-ffmpeg-8.1.2" asset = "CinemaTools-yt-dlp-2026.07.04-ffmpeg-8.1.2.zip" -sha256 = "52a781344745d36381c58e0fafe790fefb6a2802df8f802e4835ba4443e1edab" +sha256 = "4d299e40f747abb9777838542ca68ec450e0b86e6eb11af341e6bd568f47edf2" install_strategy = "root-zip" -reason = "Helper-managed Windows executable bundle for Cinema's hardcoded Proton paths Libs/yt-dlp.exe and Libs/ffmpeg.exe. Built from yt-dlp/yt-dlp release 2026.07.04 asset yt-dlp.exe with upstream sha256 52fe3c26dcf71fbdc85b528589020bb0b8e383155cfa81b64dd447bbe35e24b8, plus GyanD/codexffmpeg release 8.1.2 asset ffmpeg-8.1.2-essentials_build.zip with upstream sha256 db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec." +reason = "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 plugin_helper.recipes.cinema_tools --instance 1.44.1. Sources: https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp.exe with sha256 52fe3c26dcf71fbdc85b528589020bb0b8e383155cfa81b64dd447bbe35e24b8, and https://github.com/GyanD/codexffmpeg/releases/download/8.1.2/ffmpeg-8.1.2-essentials_build.zip with sha256 db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec; the recipe extracts */bin/ffmpeg.exe with sha256 1326dde4c84ff1f96fe6b8916c5bed29e163e9b5dccf995f6f3db069d143ec5e and writes a deterministic root zip." [[plugins]] id = "gottagofast" diff --git a/src/plugin_helper/recipes/__init__.py b/src/plugin_helper/recipes/__init__.py new file mode 100644 index 0000000..b036233 --- /dev/null +++ b/src/plugin_helper/recipes/__init__.py @@ -0,0 +1 @@ +"""Asset reconstruction recipes for helper-managed composite packages.""" diff --git a/src/plugin_helper/recipes/cinema_tools.py b/src/plugin_helper/recipes/cinema_tools.py new file mode 100644 index 0000000..7084dc3 --- /dev/null +++ b/src/plugin_helper/recipes/cinema_tools.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import argparse +import hashlib +import os +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_VERSION = "2026.07.04" +YT_DLP_URL = f"https://github.com/yt-dlp/yt-dlp/releases/download/{YT_DLP_VERSION}/yt-dlp.exe" +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) + + +@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.external_attr = 0o644 << 16 + archive.writestr(info, data) + + +def build_asset(output: Path, *, work_dir: Path | None = None) -> BuiltAsset: + output = output.expanduser() + output.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(dir=work_dir) as tmp: + tmp_path = Path(tmp) + yt_dlp_path = tmp_path / "yt-dlp.exe" + ffmpeg_zip_path = tmp_path / f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip" + staged_output = tmp_path / ASSET_NAME + + _download(YT_DLP_URL, yt_dlp_path) + _verify(yt_dlp_path, YT_DLP_SHA256, "yt-dlp.exe") + + _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 actual_bundle_sha256 != BUNDLE_SHA256: + raise ValueError( + f"{ASSET_NAME} sha256 mismatch: 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") + return parser + + +def run(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + output = args.output or args.state_dir / "cache" / "downloads" / args.instance / PLUGIN_ID / ASSET_NAME + try: + built = build_asset(output, 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()