diff options
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 4 | ||||
| -rw-r--r-- | app/backends/audiocpp/build.py | 24 | ||||
| -rw-r--r-- | app/backends/audiocpp/prebuilt.py | 167 | ||||
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 96 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 251 |
6 files changed, 455 insertions, 89 deletions
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index 2bb9cc7..d65c36b 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -72,7 +72,9 @@ from .prebuilt import ( fetch_latest_release, install_prebuilt, installed_release, + resolve_latest_tag, select_assets, + synthesize_assets, ) from .remote import fetch_server_models, fetch_server_voices from .wizard import ( @@ -108,7 +110,7 @@ __all__ = [ "apply_ggml_patches", "build_audiocpp", "uninstall", "update", # prebuilt "fetch_latest_release", "install_prebuilt", "installed_release", - "select_assets", + "select_assets", "resolve_latest_tag", "synthesize_assets", # remote "fetch_server_models", "fetch_server_voices", # wizard / status diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py index f840193..e3536cf 100644 --- a/app/backends/audiocpp/build.py +++ b/app/backends/audiocpp/build.py @@ -138,22 +138,26 @@ def _update_prebuilt(checkout: Path, backend: Optional[str], *, """The update route for a prebuilt (release-downloaded) install. Source builds update by rebuilding after a git pull; a prebuilt - install instead re-downloads when upstream published a newer release - (the marker's tag is compared against the latest release tag). The - recorded/detected backend selects the asset, mirroring what was - originally installed. A GitHub outage is not fatal — the installed - binary keeps working. Returns the install exit code (0 when already - current or when the check could not run). + install instead re-downloads when upstream published a newer release. + The current-version check resolves the newest tag from the release + page's redirect (no API quota); the API is only consulted by the + re-download itself, for the checksum digests. The recorded/detected + backend selects the asset, mirroring what was originally installed. + A GitHub outage is not fatal — the installed binary keeps working. + Returns the install exit code (0 when already current or when the + check could not run). """ marker = _prebuilt.installed_release(checkout, backend) - release = _prebuilt.fetch_latest_release() - if release is None: + tag = _prebuilt.resolve_latest_tag() + if tag is None: + release = _prebuilt.fetch_latest_release() + tag = str(release.get("tag_name") or "") if release else "" + if not tag: print("[WARNING] Could not check GitHub for a newer prebuilt " "audiocpp_server; keeping the installed one (" f"{marker.get('tag') if marker else 'unknown'}).") return 0 - tag = str(release.get("tag_name") or "") - if marker and tag and tag == marker.get("tag"): + if marker and tag == marker.get("tag"): print(f"[OK] Prebuilt audiocpp_server is current ({tag}).") return 0 print(f"[INFO] audio.cpp {tag} is available; downloading the prebuilt " diff --git a/app/backends/audiocpp/prebuilt.py b/app/backends/audiocpp/prebuilt.py index 28d60b5..13edbc0 100644 --- a/app/backends/audiocpp/prebuilt.py +++ b/app/backends/audiocpp/prebuilt.py @@ -24,8 +24,11 @@ a newer release instead of rebuilding from source. Everything downloads through urllib (no new dependencies); the GitHub API publishes a ``sha256:<hex>`` digest per asset, which is verified before -extraction. Files saved by urllib carry no macOS quarantine attribute, so -the ad-hoc-signed binaries run without Gatekeeper prompts. +extraction. When the API is rate-limited, the release tag is resolved from +the release page's redirect instead (no API quota) and the download +proceeds without verification (with a warning). Files saved by urllib +carry no macOS quarantine attribute, so the ad-hoc-signed binaries run +without Gatekeeper prompts. """ import hashlib @@ -49,6 +52,13 @@ from backends import common RELEASES_API_URL = \ "https://api.github.com/repos/0xShug0/audio.cpp/releases/latest" +# The release pages (NOT the API): following /releases/latest's redirect +# reveals the newest tag without spending any of the API's rate limit +# (60 requests/hour unauthenticated, shared per IP), and the asset +# download URLs are deterministic from the tag. +RELEASES_LATEST_URL = "https://github.com/0xShug0/audio.cpp/releases/latest" +RELEASES_DOWNLOAD_URL = "https://github.com/0xShug0/audio.cpp/releases/download" + # GitHub rejects API/HTTP requests without a User-Agent header. USER_AGENT = "tts-audiobook-generator (+https://github.com/0xShug0/audio.cpp)" @@ -68,6 +78,8 @@ _ASSET_RE = re.compile( _CUDART_RE = re.compile( r"^audio-v[^/]+-cudart-windows-x64-cuda(?P<variant>[a-z0-9.]+)\.zip$") +_TAG_RE = re.compile(r"/releases/tag/(v[^/?#]+)") + # Download progress reporting: emit a line at most every 4 MiB. _REPORT_STEP = 4 * 1024 * 1024 @@ -206,6 +218,52 @@ def fetch_latest_release(*, timeout: int = API_TIMEOUT) -> Optional[dict]: return None +def resolve_latest_tag(*, timeout: int = API_TIMEOUT) -> Optional[str]: + """The latest release tag, resolved WITHOUT the GitHub API. + + ``/releases/latest`` redirects to ``/releases/tag/vX.Y.Z``; reading + the final URL is an ordinary page hit that costs none of the API's + rate limit, so "already current" checks and rate-limited installs + never depend on the API. Returns None when the redirect cannot be + followed (offline, unexpected page shape). + """ + request = urllib.request.Request(RELEASES_LATEST_URL, + headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + url = response.geturl() or "" + except OSError: + return None + match = _TAG_RE.search(url) + return match.group(1) if match else None + + +def _variant_tokens(backend: Optional[str], *, + platform: Optional[str] = None, + machine: Optional[str] = None, + cuda_variant: Optional[str] = None + ) -> Optional[Tuple[str, str, str, Optional[str]]]: + """The (platform, arch, variant, cudart) tokens for this machine. + + Returns None when this platform/machine/backend combination has no + release asset naming (see ``prebuilt_supported``). The binary asset's + variant token carries a "cuda" prefix for CUDA builds + (``...-bin-windows-x64-cuda12.4.zip``) while the cudart archive uses + the bare version (``...-cudart-...-cuda12.4.zip``). + """ + token = _platform_token(platform) + arch = _arch_token(machine) + if token is None or arch is None or not prebuilt_supported( + backend, platform=platform): + return None + if token == "macos": + return token, arch, "metal", None + if backend == "cuda": + cuda = cuda_variant or _default_cuda_variant() + return token, arch, "cuda" + cuda, cuda + return token, arch, backend, None + + def select_assets(assets: List[dict], backend: Optional[str], *, platform: Optional[str] = None, machine: Optional[str] = None, @@ -218,23 +276,11 @@ def select_assets(assets: List[dict], backend: Optional[str], *, matches (unsupported platform, HIP, an unexpected arch). The CUDA variant defaults to the driver-appropriate one (``_default_cuda_variant``). """ - token = _platform_token(platform) - arch = _arch_token(machine) - if token is None or arch is None or not prebuilt_supported( - backend, platform=platform): + tokens = _variant_tokens(backend, platform=platform, machine=machine, + cuda_variant=cuda_variant) + if tokens is None: return None - variant: Optional[str] - cuda: Optional[str] = None - if token == "macos": - variant = "metal" - elif backend == "cuda": - # The binary asset names carry a "cuda" prefix on the variant - # token (``...-bin-windows-x64-cuda12.4.zip``); the cudart asset - # names use the bare version (``...-cudart-...-cuda12.4.zip``). - cuda = cuda_variant or _default_cuda_variant() - variant = "cuda" + cuda - else: - variant = backend + token, arch, variant, cuda = tokens main = _find_asset(assets, token, arch, variant) if main is None: return None @@ -246,6 +292,38 @@ def select_assets(assets: List[dict], backend: Optional[str], *, return main, extra +def synthesize_assets(backend: Optional[str], tag: str, *, + platform: Optional[str] = None, + machine: Optional[str] = None, + cuda_variant: Optional[str] = None + ) -> Optional[Tuple[dict, Optional[dict]]]: + """Asset entries built from the tag alone, without the GitHub API. + + The rate-limited fallback of ``install_prebuilt``: asset names and + ``releases/download/<tag>/`` URLs are deterministic, but the sha256 + digests are not — callers download these WITHOUT checksum + verification and must say so. Same (main, extra) contract as + ``select_assets``. + """ + tokens = _variant_tokens(backend, platform=platform, machine=machine, + cuda_variant=cuda_variant) + if tokens is None: + return None + token, arch, variant, cuda = tokens + + def asset(name: str) -> dict: + return {"name": name, "size": None, "digest": None, + "browser_download_url": + f"{RELEASES_DOWNLOAD_URL}/{tag}/{name}"} + + ext = ".tar.gz" if token == "macos" else ".zip" + main = asset(f"audio-{tag}-bin-{token}-{arch}-{variant}{ext}") + extra = None + if token == "windows" and backend == "cuda": + extra = asset(f"audio-{tag}-cudart-windows-{arch}-cuda{cuda}.zip") + return main, extra + + def _find_asset(assets: List[dict], platform_token: str, arch: str, variant: str) -> Optional[dict]: for asset in assets: @@ -426,13 +504,19 @@ def install_prebuilt(audiocpp_dir: Path, backend: Optional[str], *, cuda_variant: Optional[str] = None) -> int: """Download and install the latest prebuilt audiocpp_server for BACKEND. - Fetches the latest release, picks this machine's asset(s), downloads - them to a temp directory, verifies the published sha256 digests, then - extracts into ``build/<dir>/bin/`` (replacing any previous content — - including a source build there), syncs the checkout to the release - tag, and writes the ``prebuilt.json`` marker. Streaming output and - cancellation behave like the other install steps. Returns 0 on - success, 130 when cancelled, 1 on any failure. + Resolution degrades gracefully when GitHub's API is unavailable (its + unauthenticated limit is 60 requests/hour per IP): normally the API + supplies the release assets and their sha256 digests; when it is + rate-limited the newest tag is resolved from the release page's + redirect (no API quota), the asset names are synthesized, and the + download proceeds WITHOUT checksum verification (with a loud + warning). Only when neither path can resolve the release does the + install fail. The archive(s) are downloaded to a temp directory, + verified, then extracted into ``build/<dir>/bin/`` (replacing any + previous content — including a source build there), the checkout is + synced to the release tag, and the ``prebuilt.json`` marker written. + Streaming output and cancellation behave like the other install + steps. Returns 0 on success, 130 when cancelled, 1 on any failure. """ say = emit if emit is not None else print bin_dir = install_dir(audiocpp_dir, backend) @@ -442,16 +526,35 @@ def install_prebuilt(audiocpp_dir: Path, backend: Optional[str], *, return 1 say("[INFO] Checking the latest audio.cpp release...") release = fetch_latest_release() - if release is None: - return 1 - tag = str(release.get("tag_name") or "?") pair = select_assets(release.get("assets") or [], backend, - cuda_variant=cuda_variant) - if pair is None: + cuda_variant=cuda_variant) if release else None + if release is not None and pair is None: + # API reachable but nothing matches: a hard mismatch (unsupported + # backend, renamed assets) — do not paper over it with a + # synthesized name that would just 404. print(f"[ERROR] No prebuilt audiocpp_server asset for " - f"{backend!r} in release {tag}; build from source instead.") + f"{backend!r} in release {release.get('tag_name')}; build " + "from source instead.") return 1 - main, extra = pair + if pair is not None: + tag = str(release.get("tag_name") or "?") + main, extra = pair + else: + tag = resolve_latest_tag() + if tag is None: + print("[ERROR] Could not reach GitHub to resolve the latest " + "audio.cpp release (rate limit or offline); try again " + "later or build from source.") + return 1 + pair = synthesize_assets(backend, tag, cuda_variant=cuda_variant) + if pair is None: + print(f"[ERROR] No prebuilt audiocpp_server asset exists for " + f"{backend!r} in release {tag}; build from source " + "instead.") + return 1 + main, extra = pair + say("[WARNING] GitHub's release API is unreachable (rate limit?) " + f"— installing {tag} WITHOUT checksum verification.") existing = installed_release(audiocpp_dir, backend) if existing and existing.get("tag") == tag \ and existing.get("asset") == main.get("name"): diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index 5b237c4..f35aac1 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -303,6 +303,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "backend": backend, "build": build, "build_mode": s.get("build_mode"), + "prebuilt_forced": False, "lazy_load": True, "sync_port": None, "wav_dir": s["wav_dir"], @@ -621,39 +622,44 @@ def _execute_lanes(settings: dict, if build: mode = settings.get("build_mode") or "source" + forced = bool(settings.get("prebuilt_forced")) + + def source_build_step(emit, cancel) -> int: + """Build from source, warning-and-continue like the setup.""" + rc = _build.build_audiocpp(audiocpp_dir, settings["backend"], + emit=emit, cancel=cancel) + if rc == 124: + print("[WARNING] build went silent and was stopped; the " + "server.json was still written — build " + "audiocpp_server manually before starting it") + elif rc != 0: + print(f"[WARNING] build exited with code {rc}; the " + "server.json was still written — build " + "audiocpp_server manually before starting it") + else: + print("[OK] build complete") + return rc + if mode == "prebuilt": def build_step(emit, cancel): rc = _prebuilt.install_prebuilt( audiocpp_dir, settings["backend"], emit=emit, cancel=cancel) - if rc == 130: - return rc - if rc != 0: - print(f"[WARNING] prebuilt download failed (exit {rc}); " - "the server.json was still written — build " - "audiocpp_server from source ('Build audio.cpp " - "Server', or re-run with --prebuilt no) before " - "starting it") - else: + if rc == 0: print("[OK] prebuilt audiocpp_server installed") - return rc - build_title = (f"Download prebuilt audiocpp_server " - f"({settings['backend']})") + return 0 + if rc == 130 or forced: + # A cancelled download, or one the user explicitly + # forced with --prebuilt yes: fail fast. + return rc + # The usual failure is GitHub's API rate limit — recover + # in place instead of leaving the setup half-installed. + print(f"[WARNING] prebuilt download failed (exit {rc}); " + "falling back to a source build...") + return source_build_step(emit, cancel) + build_title = f"Install audiocpp_server ({settings['backend']})" else: - def build_step(emit, cancel): - rc = _build.build_audiocpp(audiocpp_dir, settings["backend"], - emit=emit, cancel=cancel) - if rc == 124: - print("[WARNING] build went silent and was stopped; the " - "server.json was still written — build " - "audiocpp_server manually before starting it") - elif rc != 0: - print(f"[WARNING] build exited with code {rc}; the " - "server.json was still written — build " - "audiocpp_server manually before starting it") - else: - print("[OK] build complete") - return rc + build_step = source_build_step build_title = f"Build audiocpp_server ({settings['backend']})" lanes.append(taskview.TaskLane( "Build", @@ -831,14 +837,25 @@ def build_screen(stdscr) -> int: if mode is _GO_BACK: return 1 + outcome: dict = {"prebuilt": False} + def build_step(emit, cancel): if mode == "prebuilt": - return _prebuilt.install_prebuilt(checkout, backend, - emit=emit, cancel=cancel) + rc = _prebuilt.install_prebuilt(checkout, backend, + emit=emit, cancel=cancel) + if rc == 0: + outcome["prebuilt"] = True + return 0 + if rc == 130: + return rc + # The usual failure is GitHub's API rate limit — recover in + # place instead of sending the user back to the menus. + print(f"[WARNING] prebuilt download failed (exit {rc}); " + "falling back to a source build...") return _build.build_audiocpp(checkout, backend, emit=emit, cancel=cancel) - action = "Download prebuilt audiocpp_server" if mode == "prebuilt" \ + action = f"Install audiocpp_server ({backend})" if mode == "prebuilt" \ else f"Build audiocpp_server ({backend})" lanes = [taskview.TaskLane("Build", [taskview.TaskStep(action, build_step)])] @@ -858,15 +875,15 @@ def build_screen(stdscr) -> int: "Download models", [taskview.TaskStep("Download missing models", download_step)])) - install_title = ("Download prebuilt audiocpp_server" - if mode == "prebuilt" else "Build audiocpp_server") + install_title = ("Install audiocpp_server" if mode == "prebuilt" + else "Build audiocpp_server") title = f"{install_title} & download models" if len(lanes) == 2 \ else install_title rc = taskview.run_lanes(stdscr, title, lanes) if rc != 0: return rc if not _configsync.update_server_backend(backend): - verb = "installed" if mode == "prebuilt" else "built" + verb = "installed" if outcome["prebuilt"] else "built" tui.flash(stdscr, f"audiocpp_server {verb} for {backend}. (Could not " "update server.json's backend field — reconfigure audio.cpp " "if it was already configured.)", "warn") @@ -995,8 +1012,12 @@ def _collect_from_flags(args: argparse.Namespace, build = False # How a pending install happens: the prebuilt release by default on # macOS/Windows (``--prebuilt no`` forces the source build), always - # the source build elsewhere. + # the source build elsewhere. A ``--prebuilt yes`` download that + # fails stays a failure (no source-build fallback) — the flag's + # whole point is "do not build". build_mode = _flag_build_mode(args, backend) if build else None + prebuilt_forced = bool(build) and getattr(args, "prebuilt", "auto") \ + == "yes" port = _configsync.config_port() lazy_load = True @@ -1039,6 +1060,7 @@ def _collect_from_flags(args: argparse.Namespace, "backend": backend, "build": build, "build_mode": build_mode, + "prebuilt_forced": prebuilt_forced, "lazy_load": lazy_load, "sync_port": None, "wav_dir": wav_dir, @@ -1088,9 +1110,11 @@ def build_parser() -> argparse.ArgumentParser: default="auto", help="How to install audiocpp_server when it is " "missing: auto downloads the prebuilt release " - "on macOS/Windows and builds from source " - "elsewhere; yes forces the prebuilt download; " - "no forces a source build") + "on macOS/Windows (falling back to a source " + "build if the download fails) and builds from " + "source elsewhere; yes forces the prebuilt " + "download and fails if it cannot; no forces a " + "source build") parser.add_argument("--whisper-model", type=str, default="base", help="Whisper model size for transcription " "(default: base)") diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 00f3512..276bf08 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -12,7 +12,7 @@ If you prefer to install the backend yourself (in your own environment, not the ### Download or build audiocpp_server -The wizard's default on macOS and Windows is to download a prebuilt `audiocpp_server` from [audio.cpp's releases](https://github.com/0xShug0/audio.cpp/releases) (checksum-verified, ~20–25 MB, no compiler or Xcode required — on Intel Macs the release runs on CPU only, and Windows CUDA builds come in 12.4/13.3 variants picked to match your NVIDIA driver). **Update Backends** keeps it current automatically. The manual build steps, if you prefer building from source, are below — build for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. audio.cpp ships one helper script per platform, and the hub runs the one matching your OS: +The wizard's default on macOS and Windows is to download a prebuilt `audiocpp_server` from [audio.cpp's releases](https://github.com/0xShug0/audio.cpp/releases) (checksum-verified, ~20–25 MB, no compiler or Xcode required — on Intel Macs the release runs on CPU only, and Windows CUDA builds come in 12.4/13.3 variants picked to match your NVIDIA driver). The newest version is resolved without GitHub's API, so the check rarely hits the API's rate limit; if the API *is* rate-limited when a download starts, the install proceeds without checksum verification (with a loud warning). If the download still fails, the TUI does not leave you half-installed: the same Build lane automatically falls back to building from source (a cancelled download, or an explicit `--prebuilt yes` — which exists to forbid source builds — do not fall back). **Update Backends** keeps a prebuilt install current automatically. The manual build steps, if you prefer building from source, are below — build for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. audio.cpp ships one helper script per platform, and the hub runs the one matching your OS: ```bash # Linux diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 4c00001..ada7954 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -3468,7 +3468,9 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): encoding="utf-8") def _run(self, checkout: Path, *, marker: Optional[dict], - release: Optional[dict]): + release: Optional[dict], tag: Optional[str] = None): + # TAG is what the quota-free redirect resolution reports; RELEASE + # is the API fallback (only consulted when the redirect fails). with patch("sys.platform", "darwin"), \ patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ @@ -3478,41 +3480,49 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): return_value={"backend": "cpu"}), \ patch.object(make_server.prebuilt, "installed_release", return_value=marker), \ + patch.object(make_server.prebuilt, "resolve_latest_tag", + return_value=tag) as mk_tag, \ patch.object(make_server.prebuilt, "fetch_latest_release", - return_value=release), \ + return_value=release) as mk_fetch, \ patch.object(make_server.prebuilt, "install_prebuilt", return_value=0) as mk_install, \ patch.object(common, "git_update", return_value=0) as mk_git: rc = make_server.build.update() - return rc, mk_install, mk_git + return rc, mk_install, mk_git, mk_tag, mk_fetch def test_newer_release_triggers_a_redownload(self): checkout = self._checkout() self._mark_prebuilt(checkout, "v0.6.0") - rc, mk_install, mk_git = self._run( + rc, mk_install, mk_git, _mk_tag, mk_fetch = self._run( checkout, marker={"tag": "v0.6.0", "asset": "x"}, - release=_release([], tag="v0.7.0")) + release=_release([], tag="v0.7.0"), tag="v0.7.0") self.assertEqual(rc, 0) mk_git.assert_not_called() mk_install.assert_called_once_with(checkout, "cpu", emit=None, cancel=None) + mk_fetch.assert_not_called() def test_current_release_is_a_noop(self): checkout = self._checkout() - rc, mk_install, mk_git = self._run( + rc, mk_install, mk_git, _mk_tag, mk_fetch = self._run( checkout, marker={"tag": "v0.7.0", "asset": "x"}, - release=_release([], tag="v0.7.0")) + release=None, tag="v0.7.0") self.assertEqual(rc, 0) mk_install.assert_not_called() mk_git.assert_not_called() + # The redirect already answered: the API (rate-limited easily) + # must not be touched for an "already current" check. + mk_fetch.assert_not_called() def test_unreachable_github_keeps_the_install(self): checkout = self._checkout() - rc, mk_install, mk_git = self._run( + rc, mk_install, mk_git, mk_tag, mk_fetch = self._run( checkout, marker={"tag": "v0.7.0", "asset": "x"}, - release=None) + release=None, tag=None) self.assertEqual(rc, 0) + mk_tag.assert_called_once() + mk_fetch.assert_called_once() # the API fallback tried too mk_install.assert_not_called() mk_git.assert_not_called() @@ -3606,3 +3616,226 @@ class PrebuiltFlagTests(unittest.TestCase): self.assertIsNotNone(settings) self.assertEqual(settings["build_mode"], mode) self.assertEqual(settings["build"], expected_build) + + +class PrebuiltTagResolutionTests(unittest.TestCase): + """resolve_latest_tag: the quota-free release-page redirect.""" + + def _redirect(self, url): + response = MagicMock() + response.__enter__.return_value.geturl.return_value = url + return response + + def test_parses_the_redirect_target(self): + with patch("urllib.request.urlopen", + return_value=self._redirect( + "https://github.com/0xShug0/audio.cpp/releases/tag/" + "v0.7.0")): + self.assertEqual(make_server.prebuilt.resolve_latest_tag(), + "v0.7.0") + + def test_offline_yields_none(self): + with patch("urllib.request.urlopen", side_effect=OSError("down")): + self.assertIsNone(make_server.prebuilt.resolve_latest_tag()) + + def test_unexpected_page_yields_none(self): + with patch("urllib.request.urlopen", + return_value=self._redirect( + "https://github.com/0xShug0/audio.cpp/releases")): + self.assertIsNone(make_server.prebuilt.resolve_latest_tag()) + + +class PrebuiltSyntheticAssetTests(unittest.TestCase): + """synthesize_assets: API-free names for the rate-limited fallback.""" + + def test_darwin_names_match_select_assets(self): + tag = "v1.2.3" + selected = make_server.prebuilt.select_assets( + [_asset(f"audio-{tag}-bin-macos-arm64-metal.tar.gz")], + "cpu", platform="darwin", machine="arm64") + synth = make_server.prebuilt.synthesize_assets( + "cpu", tag, platform="darwin", machine="arm64") + self.assertEqual(selected[0]["name"], synth[0]["name"]) + self.assertEqual( + synth[0]["browser_download_url"], + f"https://github.com/0xShug0/audio.cpp/releases/download/" + f"{tag}/audio-{tag}-bin-macos-arm64-metal.tar.gz") + self.assertIsNone(synth[1]) + + def test_windows_cuda_synthesizes_both_archives(self): + synth = make_server.prebuilt.synthesize_assets( + "cuda", "v1.2.3", platform="win32", machine="AMD64", + cuda_variant="13.3") + main, extra = synth + self.assertEqual(main["name"], + "audio-v1.2.3-bin-windows-x64-cuda13.3.zip") + self.assertEqual(extra["name"], + "audio-v1.2.3-cudart-windows-x64-cuda13.3.zip") + self.assertIsNone(main["digest"]) + + def test_unsupported_backend_is_none(self): + self.assertIsNone(make_server.prebuilt.synthesize_assets( + "hip", "v1.2.3", platform="win32", machine="AMD64")) + + +class PrebuiltRateLimitTests(unittest.TestCase): + """install_prebuilt degrades when the GitHub API is rate-limited.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = tmp + + def _checkout(self) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return _make_checkout(Path(tmp.name)) + + def test_rate_limited_api_still_installs_unverified(self): + checkout = self._checkout() + archive = _make_tar_gz( + Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz", + {"./audiocpp_server": "#!/bin/sh\n"}) + + def fake_download(url, dest, *, emit=None, cancel=None): + self.assertIn("/releases/download/v9.9.9/", url) + shutil.copyfile(archive, dest) + return 0 + + with patch("sys.platform", "darwin"), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=None), \ + patch.object(make_server.prebuilt, "resolve_latest_tag", + return_value="v9.9.9"), \ + patch.object(make_server.prebuilt, "_download", + side_effect=fake_download) as mk_dl, \ + patch.object(make_server.prebuilt.common, + "run_console_subprocess", return_value=0), \ + redirect_stdout(io.StringIO()) as out: + rc = make_server.prebuilt.install_prebuilt(checkout, "cpu") + self.assertEqual(rc, 0) + mk_dl.assert_called_once() + server = (checkout / "build" / "macos-metal-release" / "bin" + / "audiocpp_server") + self.assertTrue(server.exists()) + self.assertTrue(server.stat().st_mode & 0o111) + marker = make_server.prebuilt.installed_release(checkout, "cpu", + platform="darwin") + self.assertEqual(marker["tag"], "v9.9.9") + self.assertIn("WITHOUT checksum verification", out.getvalue()) + + def test_total_outage_fails_the_install(self): + checkout = self._checkout() + with patch("sys.platform", "darwin"), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=None), \ + patch.object(make_server.prebuilt, "resolve_latest_tag", + return_value=None): + rc = make_server.prebuilt.install_prebuilt(checkout, "cpu") + self.assertEqual(rc, 1) + self.assertIsNone(make_server.prebuilt.installed_release( + checkout, "cpu", platform="darwin")) + + def test_reachable_api_without_a_matching_asset_is_a_hard_error(self): + # A reachable API with no matching asset must not be papered over + # with a synthesized name that would just 404. + checkout = self._checkout() + with patch("sys.platform", "darwin"), \ + patch.object(make_server.prebuilt, "fetch_latest_release", + return_value=_release([], tag="v9.9.9")), \ + patch.object(make_server.prebuilt, "resolve_latest_tag", + return_value="v9.9.9") as mk_tag: + rc = make_server.prebuilt.install_prebuilt(checkout, "cpu") + self.assertEqual(rc, 1) + mk_tag.assert_not_called() + + +class PrebuiltFallbackTests(unittest.TestCase): + """A failed prebuilt download falls back to a source build in-lane.""" + + def _settings(self, checkout: Path, **overrides) -> dict: + settings = { + "audiocpp_dir": checkout, "backend": "cpu", "build": True, + "build_mode": "prebuilt", "prebuilt_forced": False, + "wav_dir": None, "include_clone": False, "plan": None, + "output_path": checkout / "server.json", "model_entries": [], + "install_guidance": [], "host": "127.0.0.1", "port": 8080, + "lazy_load": True, "download": False, "delete_unused": False, + "unused_entries": [], + } + settings.update(overrides) + return settings + + def _build_step(self, checkout: Path, settings: dict): + args = argparse.Namespace(input_dir=None, whisper_model="base") + lanes = make_server.wizard._execute_lanes(settings, args) + return lanes[0].steps[0] + + def _run_step(self, step, *, out): + with redirect_stdout(out): + return step.work(None, None) + + def _checkout(self) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + return _make_checkout(Path(tmp.name)) + + def test_failed_download_falls_back_to_source_build(self): + checkout = self._checkout() + step = self._build_step(checkout, self._settings(checkout)) + with patch.object(make_server.prebuilt, "install_prebuilt", + return_value=1) as mk_install, \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build, \ + redirect_stdout(io.StringIO()) as out: + rc = self._run_step(step, out=out) + self.assertEqual(rc, 0) + mk_install.assert_called_once() + mk_build.assert_called_once_with(checkout, "cpu", emit=None, + cancel=None) + self.assertIn("falling back to a source build", + out.getvalue()) + + def test_both_paths_failing_still_reports_an_error(self): + checkout = self._checkout() + step = self._build_step(checkout, self._settings(checkout)) + with patch.object(make_server.prebuilt, "install_prebuilt", + return_value=1), \ + patch.object(make_server.build, "build_audiocpp", + return_value=3), \ + redirect_stdout(io.StringIO()): + rc = self._run_step(step, out=io.StringIO()) + self.assertEqual(rc, 3) + + def test_cancelled_download_does_not_fall_back(self): + checkout = self._checkout() + step = self._build_step(checkout, self._settings(checkout)) + with patch.object(make_server.prebuilt, "install_prebuilt", + return_value=130) as _mk_install, \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = self._run_step(step, out=io.StringIO()) + self.assertEqual(rc, 130) + mk_build.assert_not_called() + + def test_forced_prebuilt_fails_fast(self): + checkout = self._checkout() + step = self._build_step( + checkout, self._settings(checkout, prebuilt_forced=True)) + with patch.object(make_server.prebuilt, "install_prebuilt", + return_value=1) as _mk_install, \ + patch.object(make_server.build, "build_audiocpp") as mk_build: + rc = self._run_step(step, out=io.StringIO()) + self.assertEqual(rc, 1) + mk_build.assert_not_called() + + def test_source_mode_never_touches_the_download(self): + checkout = self._checkout() + step = self._build_step( + checkout, self._settings(checkout, build_mode="source")) + with patch.object(make_server.prebuilt, "install_prebuilt") as mk_i, \ + patch.object(make_server.build, "build_audiocpp", + return_value=0) as mk_build: + rc = self._run_step(step, out=io.StringIO()) + self.assertEqual(rc, 0) + mk_i.assert_not_called() + mk_build.assert_called_once() |
