Compare commits
1
Commits
1becf8366d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455c9fd2c6 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +196,9 @@ def run_update(
|
||||
continue
|
||||
|
||||
destination = plugin_downloads_dir(state_root, instance, plugin_id) / plugin["latestAsset"]
|
||||
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")
|
||||
@@ -191,6 +220,7 @@ def run_update(
|
||||
plugin,
|
||||
actual_sha256=actual_sha,
|
||||
beat_saber_version=report["beatSaberVersion"],
|
||||
instance=report["instance"],
|
||||
)
|
||||
updated.append(
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user