Files
plugin-helper/src/plugin_helper/recipes/cinema_tools.py
T

229 lines
8.3 KiB
Python

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<yt_dlp>.+)-ffmpeg-(?P<ffmpeg>[^-]+)$")
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 <state-dir>/cache/downloads/<instance>/cinema-tools/<asset>",
)
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()