Add cinema-tools recipe

This commit is contained in:
pleb
2026-07-10 08:21:32 -07:00
parent 8419d3a5c9
commit f4fedaeb34
4 changed files with 181 additions and 2 deletions
+8
View File
@@ -152,6 +152,14 @@ Install assets are currently expected to already exist locally, usually under:
<state-dir>/cache/downloads/<instance>/<plugin-id>/
```
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
+2 -2
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Asset reconstruction recipes for helper-managed composite packages."""
+170
View File
@@ -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 <state-dir>/cache/downloads/<instance>/cinema-tools/<asset>",
)
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()