229 lines
8.3 KiB
Python
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()
|