diff --git a/README.md b/README.md index c866f2d..5c08754 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,11 @@ small deterministic bundle rebuilt from upstream executables with: PYTHONPATH=src python -m plugin_helper.recipes.cinema_tools --instance 1.44.1 ``` +`updates` and `update` treat `cinema-tools` as tracking the latest +`yt-dlp/yt-dlp` release: `update --plugin cinema-tools` rebuilds the bundle +with the new `Libs/yt-dlp.exe` (verified against GitHub's release digest), keeps +the pinned ffmpeg, and rewrites the lock entry. + Use `updates` to audit the version lock against obvious public update sources: ```sh diff --git a/src/plugin_helper/recipes/cinema_tools.py b/src/plugin_helper/recipes/cinema_tools.py index 30e80b1..9b897f0 100644 --- a/src/plugin_helper/recipes/cinema_tools.py +++ b/src/plugin_helper/recipes/cinema_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import hashlib import os +import re import shutil import sys import tempfile @@ -15,8 +16,10 @@ 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/yt-dlp/releases/download/{YT_DLP_VERSION}/yt-dlp.exe" +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" @@ -31,6 +34,36 @@ 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: @@ -94,18 +127,27 @@ def _write_member(archive: ZipFile, name: str, data: bytes) -> None: archive.writestr(info, data) -def build_asset(output: Path, *, work_dir: Path | None = None) -> BuiltAsset: +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.exe" + yt_dlp_path = tmp_path / YT_DLP_ASSET ffmpeg_zip_path = tmp_path / f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip" - staged_output = tmp_path / ASSET_NAME + staged_output = tmp_path / name - _download(YT_DLP_URL, yt_dlp_path) - _verify(yt_dlp_path, YT_DLP_SHA256, "yt-dlp.exe") + _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") @@ -118,9 +160,9 @@ def build_asset(output: Path, *, work_dir: Path | None = None) -> BuiltAsset: _write_member(archive, "Libs/ffmpeg.exe", ffmpeg_data) actual_bundle_sha256 = _sha256_file(staged_output) - if actual_bundle_sha256 != BUNDLE_SHA256: + if expected_bundle_sha256 and actual_bundle_sha256 != expected_bundle_sha256: raise ValueError( - f"{ASSET_NAME} sha256 mismatch: expected {BUNDLE_SHA256}, got {actual_bundle_sha256}" + f"{name} sha256 mismatch: expected {expected_bundle_sha256}, got {actual_bundle_sha256}" ) shutil.move(str(staged_output), output) @@ -142,15 +184,30 @@ def build_parser() -> argparse.ArgumentParser: 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) - output = args.output or args.state_dir / "cache" / "downloads" / args.instance / PLUGIN_ID / ASSET_NAME + 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, work_dir=args.work_dir) + 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 @@ -158,7 +215,7 @@ def run(argv: list[str] | None = None) -> int: 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/yt-dlp.exe {yt_dlp_sha256}") print(f" Libs/ffmpeg.exe {FFMPEG_EXE_SHA256}") return 0 diff --git a/src/plugin_helper/update_runner.py b/src/plugin_helper/update_runner.py index 30ea0cf..10973a3 100644 --- a/src/plugin_helper/update_runner.py +++ b/src/plugin_helper/update_runner.py @@ -11,12 +11,14 @@ from .fsutil import sha256_file from .installer import apply_plan from .models import LockedPlugin, Lockfile, Registry, replace_locked_plugins, write_lockfile from .planner import create_plan +from .recipes import cinema_tools from .state import plugin_downloads_dir from .updates import FetchBeatMods, FetchReleases, check_updates DownloadAsset = Callable[[str, Path], dict[str, Any]] ApplyPlan = Callable[[dict[str, Any], Path], dict[str, Any]] +BuildRecipeAsset = Callable[[dict[str, Any], Path], dict[str, Any]] def _download_asset(url: str, destination: Path) -> dict[str, Any]: @@ -31,7 +33,27 @@ def _download_asset(url: str, destination: Path) -> dict[str, Any]: return {"url": url, "path": str(destination), "sha256": sha256_file(destination)} -def _reason_for_update(plugin: dict[str, Any], *, actual_sha256: str, beat_saber_version: str) -> str: +def _build_recipe_asset(plugin: dict[str, Any], destination: Path) -> dict[str, Any]: + if plugin.get("recipe") != cinema_tools.PLUGIN_ID: + raise ValueError(f"{plugin['id']}: unknown asset recipe: {plugin.get('recipe')}") + built = cinema_tools.build_asset( + destination, + yt_dlp_version=plugin["latestYtDlpVersion"], + yt_dlp_sha256=plugin["latestYtDlpSha256"], + ) + return {"path": str(built.path), "sha256": built.sha256} + + +def _reason_for_update( + plugin: dict[str, Any], + *, + actual_sha256: str, + beat_saber_version: str, + instance: str, +) -> str: + if plugin.get("recipe") == cinema_tools.PLUGIN_ID: + return cinema_tools.lock_reason(plugin["latestYtDlpVersion"], plugin["latestYtDlpSha256"], instance) + if plugin.get("latestBeatModsVersionId") is not None: return ( f"BeatMods verified {plugin['name']} " @@ -60,17 +82,20 @@ def _updated_locked_plugin( *, actual_sha256: str, beat_saber_version: str, + instance: str, ) -> LockedPlugin: return replace( locked, tag=plugin.get("latestTag"), asset=plugin.get("latestAsset"), sha256=actual_sha256, - download_url=plugin.get("latestAssetUrl"), + # Recipe bundles are built locally; latestAssetUrl is an input, not the locked artifact. + download_url=locked.download_url if plugin.get("recipe") else plugin.get("latestAssetUrl"), reason=_reason_for_update( plugin, actual_sha256=actual_sha256, beat_saber_version=beat_saber_version, + instance=instance, ), ) @@ -92,6 +117,7 @@ def run_update( apply: bool = False, download_asset: DownloadAsset = _download_asset, apply_plan_func: ApplyPlan = apply_plan, + build_recipe_asset: BuildRecipeAsset = _build_recipe_asset, install_id: str | None = None, ) -> dict[str, Any]: if not selected: @@ -170,7 +196,10 @@ def run_update( continue destination = plugin_downloads_dir(state_root, instance, plugin_id) / plugin["latestAsset"] - downloaded = download_asset(plugin["latestAssetUrl"], destination) + if plugin.get("recipe"): + downloaded = build_recipe_asset(plugin, destination) + else: + downloaded = download_asset(plugin["latestAssetUrl"], destination) actual_sha = str(downloaded.get("sha256") or sha256_file(destination)) expected_sha = plugin.get("latestAssetSha256") if expected_sha and actual_sha != expected_sha: @@ -191,6 +220,7 @@ def run_update( plugin, actual_sha256=actual_sha, beat_saber_version=report["beatSaberVersion"], + instance=report["instance"], ) updated.append( { diff --git a/src/plugin_helper/updates.py b/src/plugin_helper/updates.py index e3a36e6..ed6696a 100644 --- a/src/plugin_helper/updates.py +++ b/src/plugin_helper/updates.py @@ -6,6 +6,7 @@ from typing import Any, Callable from .beatmods import BeatModsEntry, normalize_mods from .models import Lockfile, Registry +from .recipes import cinema_tools FetchReleases = Callable[[str], list[dict[str, Any]]] @@ -177,6 +178,58 @@ def _find_latest_matching_release( return None +def _check_cinema_tools( + entry: dict[str, Any], + *, + current_tag: str | None, + fetch_releases: FetchReleases, + include_prerelease: bool, +) -> None: + """Cinema tools is a recipe bundle; its update source is the upstream yt-dlp.exe release.""" + entry["recipe"] = cinema_tools.PLUGIN_ID + entry["repo"] = cinema_tools.YT_DLP_REPO + try: + releases = fetch_releases(cinema_tools.YT_DLP_REPO) + except Exception as exc: + entry["status"] = "error" + entry["messages"].append(str(exc)) + return + + for release in _sorted_releases(releases, include_prerelease): + asset = next( + (asset for asset in _release_assets(release) if _asset_name(asset) == cinema_tools.YT_DLP_ASSET), + None, + ) + if asset is not None: + break + else: + entry["status"] = "warning" + entry["messages"].append(f"no {cinema_tools.YT_DLP_REPO} release with {cinema_tools.YT_DLP_ASSET} found") + return + + yt_dlp_version = _release_tag(release) + yt_dlp_sha256 = str(asset.get("digest") or "").removeprefix("sha256:") or None + entry["latestTag"] = cinema_tools.bundle_tag(yt_dlp_version) + entry["latestAsset"] = cinema_tools.asset_name(yt_dlp_version) + entry["latestUrl"] = release.get("html_url") + entry["latestAssetUrl"] = asset.get("browser_download_url") + entry["latestYtDlpVersion"] = yt_dlp_version + entry["latestYtDlpSha256"] = yt_dlp_sha256 + + if cinema_tools.yt_dlp_version_from_tag(current_tag) == yt_dlp_version: + entry["status"] = "current" + return + if not yt_dlp_sha256: + entry["status"] = "warning" + entry["messages"].append(f"yt-dlp {yt_dlp_version} release did not publish a sha256 digest") + return + entry["status"] = "update" + entry["messages"].append(f"yt-dlp {yt_dlp_version} available; rebuilds Libs/yt-dlp.exe bundle") + + +_SUMMARY_KEYS = {"current": "current", "update": "updates", "warning": "warnings", "error": "errors"} + + def check_updates( *, registry: Registry, @@ -239,6 +292,17 @@ def check_updates( plugins.append(entry) continue + if locked.id == cinema_tools.PLUGIN_ID: + _check_cinema_tools( + entry, + current_tag=locked.tag, + fetch_releases=fetch_releases, + include_prerelease=include_prerelease, + ) + summary[_SUMMARY_KEYS[entry["status"]]] += 1 + plugins.append(entry) + continue + if _is_beatmods_locked(locked.tag, locked.reason): if fetch_beatmods is None: entry["status"] = "warning" diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 13eb133..eb5b6d0 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -2085,6 +2085,114 @@ instance = "1.40.8" self.assertEqual(updated_lock.plugins[0].download_url, "https://example.invalid/Example-v2.zip") self.assertIn("GitHub release digest matched", updated_lock.plugins[0].reason or "") + def _cinema_tools_lock(self) -> Lockfile: + return Lockfile( + beat_saber_version="1.44.1", + instance="1.44.1", + plugins=( + LockedPlugin( + id="cinema-tools", + repo=None, + tag="yt-dlp-2026.07.04-ffmpeg-8.1.2", + asset="CinemaTools-yt-dlp-2026.07.04-ffmpeg-8.1.2.zip", + sha256="old", + install_strategy="root-zip", + reason="Helper-managed Windows executable bundle.", + ), + ), + ) + + def _yt_dlp_releases(self, tag: str) -> list[dict[str, object]]: + return [ + { + "tag_name": tag, + "html_url": f"https://github.com/yt-dlp/yt-dlp/releases/tag/{tag}", + "published_at": "2026-10-01T00:00:00Z", + "assets": [ + {"name": "yt-dlp", "browser_download_url": "https://example.invalid/yt-dlp"}, + { + "name": "yt-dlp.exe", + "browser_download_url": f"https://example.invalid/{tag}/yt-dlp.exe", + "digest": "sha256:" + "a" * 64, + }, + ], + } + ] + + def test_update_check_reports_cinema_tools_yt_dlp_update(self) -> None: + repos: list[str] = [] + + def fetch(repo: str) -> list[dict[str, object]]: + repos.append(repo) + return self._yt_dlp_releases("2026.10.01") + + result = check_updates(registry=Registry(), lockfile=self._cinema_tools_lock(), fetch_releases=fetch) + + plugin = result["plugins"][0] + self.assertEqual(repos, ["yt-dlp/yt-dlp"]) + self.assertEqual(plugin["status"], "update") + self.assertEqual(plugin["latestTag"], "yt-dlp-2026.10.01-ffmpeg-8.1.2") + self.assertEqual(plugin["latestAsset"], "CinemaTools-yt-dlp-2026.10.01-ffmpeg-8.1.2.zip") + self.assertEqual(plugin["latestYtDlpSha256"], "a" * 64) + self.assertEqual(result["summary"]["updates"], 1) + + def test_update_check_reports_cinema_tools_current(self) -> None: + result = check_updates( + registry=Registry(), + lockfile=self._cinema_tools_lock(), + fetch_releases=lambda _repo: self._yt_dlp_releases("2026.07.04"), + ) + + self.assertEqual(result["plugins"][0]["status"], "current") + self.assertEqual(result["summary"]["current"], 1) + + def test_update_command_rebuilds_cinema_tools_bundle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + state = work / "state" + instance = work / "instance" + lock_path = work / "locks" / "1.44.1.lock.toml" + instance.mkdir() + built: list[tuple[str, str]] = [] + + def build(plugin: dict[str, object], destination: Path) -> dict[str, object]: + built.append((str(plugin["latestYtDlpVersion"]), str(plugin["latestYtDlpSha256"]))) + destination.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(destination, "w") as archive: + archive.writestr("Libs/yt-dlp.exe", b"new yt-dlp") + archive.writestr("Libs/ffmpeg.exe", b"ffmpeg") + return {"path": str(destination), "sha256": sha256_file(destination)} + + def download(_url: str, _destination: Path) -> dict[str, object]: + raise AssertionError("recipe bundles must not be downloaded directly") + + result = run_update( + instance="1.44.1", + instance_path=instance, + registry=Registry(), + lockfile=self._cinema_tools_lock(), + lock_path=lock_path, + state_root=state, + repo_root=work, + selected={"cinema-tools"}, + fetch_releases=lambda _repo: self._yt_dlp_releases("2026.10.01"), + download_asset=download, + build_recipe_asset=build, + ) + + self.assertEqual(built, [("2026.10.01", "a" * 64)]) + plan = json.loads(Path(result["planPath"]).read_text(encoding="utf-8")) + self.assertEqual( + sorted(change["target"] for change in plan["changes"]), + ["Libs/ffmpeg.exe", "Libs/yt-dlp.exe"], + ) + locked = load_lockfile(lock_path).plugins[0] + self.assertEqual(locked.tag, "yt-dlp-2026.10.01-ffmpeg-8.1.2") + self.assertEqual(locked.asset, "CinemaTools-yt-dlp-2026.10.01-ffmpeg-8.1.2.zip") + self.assertIsNone(locked.download_url) + self.assertIn("--yt-dlp-version 2026.10.01", locked.reason or "") + self.assertEqual(result["updated"][0]["toTag"], "yt-dlp-2026.10.01-ffmpeg-8.1.2") + def test_update_command_prepares_beatmods_update(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp)