aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 18:44:32 -0400
committerhistoria <historiavg@proton.me>2026-08-28 18:44:32 -0400
commit03ed68bafec56ef8bb690440d796c1a742042907 (patch)
tree9f80e1848ca8c141f49875ffdbe462527ab0a9c3
parent3c9b7e12029f6accdaabb6f60e01bce88156db98 (diff)
downloadtts-audiobook-generator-03ed68bafec56ef8bb690440d796c1a742042907.tar.gz
feat(audio.cpp): if github binary download fails, automatically start building from source
-rw-r--r--app/backends/audiocpp/__init__.py4
-rw-r--r--app/backends/audiocpp/build.py24
-rw-r--r--app/backends/audiocpp/prebuilt.py167
-rw-r--r--app/backends/audiocpp/wizard.py96
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_backends_audiocpp.py251
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()