Compare commits

..
1 Commits
Author SHA1 Message Date
pleb 455c9fd2c6 feat: Track yt-dlp releases in cinema-tools updates 2026-10-22 08:24:21 -07:00
5 changed files with 278 additions and 14 deletions
+5
View File
@@ -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
+68 -11
View File
@@ -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<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:
@@ -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 <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)
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
+33 -3
View File
@@ -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(
{
+64
View File
@@ -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"
+108
View File
@@ -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)