aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-29 13:57:40 -0400
committerhistoria <historiavg@proton.me>2026-08-29 13:57:40 -0400
commitdf4a81c6101d33fe745b6ac249c736e088760c85 (patch)
tree7103e8e2d33a1c2b9afecd47d309147bbeed3c34 /app/backends
parent8a128b3859b8f398e162d3168ff328ab3199d307 (diff)
downloadtts-audiobook-generator-df4a81c6101d33fe745b6ac249c736e088760c85.tar.gz
fix: crash on bad model_specs from audio.cpp, sanitized
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/audiocpp/build.py55
-rw-r--r--app/backends/audiocpp/models.py194
2 files changed, 202 insertions, 47 deletions
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index e3536cf..36c304c 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -59,7 +59,11 @@ def update(*, emit=None, cancel=None) -> int:
(a ``prebuilt.json`` marker next to the binary, see
``backends.audiocpp.prebuilt``) take a different route: they skip the
git update and rebuild entirely and instead re-download when upstream
- published a newer release (see ``_update_prebuilt``). Source-built
+ published a newer release (see ``_update_prebuilt``) — and when that
+ re-download fails (rate limit, offline), the same source-build route a
+ source-built checkout uses runs instead, so 'Update Backends' still
+ gets current by whatever means work; the installed binary is only
+ replaced on success and keeps working meanwhile. Source-built
checkouts keep the original flow — phases: stop server / git update /
rebuild — CANCEL is honored between phases only, so a started phase
always completes. The git update is a fetch plus hard reset to origin's
@@ -78,7 +82,8 @@ def update(*, emit=None, cancel=None) -> int:
interrupted (cancelled or failed) earlier rebuild, which leaves the
previous binary in place against already-updated sources. An
up-to-date checkout with a fresh binary costs one fetch. Returns the
- exit code (130 when cancelled before a remaining phase).
+ exit code (130 when cancelled before a remaining phase, or when a
+ prebuilt re-download was cancelled).
"""
# Only stop when a pid file exists: without one this tool never
# started the server, so the "not started by this tool" notice would
@@ -92,8 +97,19 @@ def update(*, emit=None, cancel=None) -> int:
print("[INFO] No audio.cpp checkout to update.")
return 0
backend = _rebuild_backend(checkout)
+ fell_back = False
if _prebuilt.installed_release(checkout, backend) is not None:
- return _update_prebuilt(checkout, backend, emit=emit, cancel=cancel)
+ rc = _update_prebuilt(checkout, backend, emit=emit, cancel=cancel)
+ if rc == 0 or rc == 130:
+ return rc
+ # The re-download failed (the usual cause is GitHub's API rate
+ # limit): the installed binary was left in place and keeps
+ # working — recover in place like the install paths do, by
+ # building from source instead.
+ print(f"[WARNING] prebuilt update failed (exit {rc}); the installed "
+ "audiocpp_server was left in place — falling back to a "
+ "source build...")
+ fell_back = True
head_before = common.git_head(checkout)
rc = common.git_update(checkout, emit=emit, cancel=cancel)
if rc != 0:
@@ -113,6 +129,8 @@ def update(*, emit=None, cancel=None) -> int:
moved=head_after not in (None, head_before)):
print(f"[OK] {checkout} is already at origin's HEAD with an "
"up-to-date audiocpp_server.")
+ if fell_back:
+ _drop_prebuilt_marker(checkout, backend)
return 0
if head_after in (None, head_before):
print(f"[INFO] audiocpp_server on disk is older than the "
@@ -130,9 +148,29 @@ def update(*, emit=None, cancel=None) -> int:
"retry the rebuild.")
else:
print("[OK] rebuild complete.")
+ if fell_back:
+ _drop_prebuilt_marker(checkout, backend)
return build_rc
+def _drop_prebuilt_marker(checkout: Path, backend: Optional[str]) -> None:
+ """Remove the prebuilt.json marker after a fallback source build.
+
+ The fallback replaced (or matched) the downloaded binary with a
+ source build, so the marker's "this build directory holds release
+ <tag>" claim is stale — keeping it would make the next update
+ re-download over the freshly built binary. Best-effort: a marker
+ that cannot be removed only means the next update re-downloads.
+ """
+ marker = _prebuilt.marker_path(checkout, backend)
+ if marker is None:
+ return
+ try:
+ marker.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
def _update_prebuilt(checkout: Path, backend: Optional[str], *,
emit=None, cancel=None) -> int:
"""The update route for a prebuilt (release-downloaded) install.
@@ -144,8 +182,10 @@ def _update_prebuilt(checkout: Path, backend: Optional[str], *,
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).
+ Returns 0 when already current or when the check could not run; a
+ failed re-download returns its exit code and ``update()`` falls back
+ to a source build (a cancelled download, 130, aborts without
+ falling back).
"""
marker = _prebuilt.installed_release(checkout, backend)
tag = _prebuilt.resolve_latest_tag()
@@ -166,11 +206,6 @@ def _update_prebuilt(checkout: Path, backend: Optional[str], *,
cancel=cancel)
if rc == 0:
print(f"[OK] audiocpp_server updated to {tag}.")
- else:
- print(f"[WARNING] prebuilt update failed (exit {rc}); the "
- "installed audiocpp_server was left in place. Retry "
- "'Update Backends' later, or build from source with "
- "'Build audio.cpp Server'.")
return rc
diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py
index 772ae8d..f2c4e5e 100644
--- a/app/backends/audiocpp/models.py
+++ b/app/backends/audiocpp/models.py
@@ -90,6 +90,14 @@ def _install_models(audiocpp_dir: Path,
runs with ``--progress --cancel-file`` so the view can show a real byte
progress bar and cancel gracefully. CANCEL aborts a running download.
+ When the checkout's model specs carry a broken ``strip_prefix`` (a dot
+ prefix over repo-root files — the manager rejects every file in such a
+ package) and the manager supports ``--specs-dir``, the installs run
+ against a sanitized temporary copy of the specs (see
+ ``_prepare_specs_dir``); the checkout itself is left untouched. Specs
+ that cannot be repaired confidently are left as-is: those installs
+ fail, are reported as warnings, and the remaining downloads continue.
+
Returns 0 when every command succeeded (or nothing needed running),
130 when cancelled, 1 when any download failed.
"""
@@ -122,45 +130,78 @@ def _install_models(audiocpp_dir: Path,
return 0
failed = False
- for _, install_id in pending:
- print(f"[INFO] Downloading {install_id}...")
- argv = [sys.executable, str(manager), "install", install_id]
- cancel_file: Optional[Path] = None
- on_cancel = None
- if supports_progress:
- fd, cancel_path = tempfile.mkstemp(
- prefix="audiocpp_cancel_", suffix=".cancel")
- os.close(fd)
- cancel_file = Path(cancel_path)
- cancel_file.unlink() # absent = not cancelled
- argv += ["--progress", "--cancel-file", str(cancel_file)]
- on_cancel = cancel_file.touch
+ specs_dir: Optional[Path] = None
+ if _manager_supports_flag(manager, "--specs-dir"):
try:
- rc = common.run_console_subprocess(
- argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel,
- on_cancel=on_cancel,
- stall_timeout=(DOWNLOAD_STALL_TIMEOUT
- if supports_progress else None))
+ specs_dir = _prepare_specs_dir(audiocpp_dir)
except OSError as exc:
- print(f"[WARNING] Could not run python {manager} install "
- f"{install_id}: {exc}")
- rc = 1
- finally:
- if cancel_file is not None:
- try:
- cancel_file.unlink()
- except OSError:
- pass
- if rc == 130 or (cancel is not None and cancel.is_set()):
- return 130
- if rc != 0:
- failed = True
- print(f"[WARNING] install {install_id} exited with code "
- f"{rc}; the model may need to be downloaded "
- "by hand")
+ print(f"[WARNING] Could not prepare sanitized model specs: {exc}")
+ specs_dir = None
+ if specs_dir is not None:
+ print(f"[INFO] The checkout's model specs carry a broken "
+ "strip_prefix; installing from a sanitized copy "
+ f"({specs_dir})")
+ specs_args = (["--specs-dir", str(specs_dir)]
+ if specs_dir is not None else [])
+ try:
+ for _, install_id in pending:
+ print(f"[INFO] Downloading {install_id}...")
+ argv = [sys.executable, str(manager)] + specs_args + [
+ "install", install_id]
+ cancel_file: Optional[Path] = None
+ on_cancel = None
+ if supports_progress:
+ fd, cancel_path = tempfile.mkstemp(
+ prefix="audiocpp_cancel_", suffix=".cancel")
+ os.close(fd)
+ cancel_file = Path(cancel_path)
+ cancel_file.unlink() # absent = not cancelled
+ argv += ["--progress", "--cancel-file", str(cancel_file)]
+ on_cancel = cancel_file.touch
+ try:
+ rc = common.run_console_subprocess(
+ argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel,
+ on_cancel=on_cancel,
+ stall_timeout=(DOWNLOAD_STALL_TIMEOUT
+ if supports_progress else None))
+ except OSError as exc:
+ print(f"[WARNING] Could not run python {manager} install "
+ f"{install_id}: {exc}")
+ rc = 1
+ finally:
+ if cancel_file is not None:
+ try:
+ cancel_file.unlink()
+ except OSError:
+ pass
+ if rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
+ if rc != 0:
+ failed = True
+ print(f"[WARNING] install {install_id} exited with code "
+ f"{rc}; the model may need to be downloaded "
+ "by hand")
+ finally:
+ if specs_dir is not None:
+ shutil.rmtree(specs_dir, ignore_errors=True)
return 1 if failed else 0
+def _manager_supports_flag(manager: Path, flag: str) -> bool:
+ """True when MANAGER's (model_manager_v2.py's) source mentions FLAG.
+
+ The checkout is downloaded, so an older copy may lack a relatively
+ recent flag; probing the script source once is cheaper than failing an
+ install with an unknown option. A false positive (the string appears
+ outside argparse) is caught when the subprocess reports the error.
+ """
+ try:
+ text = manager.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ return False
+ return flag in text
+
+
def _manager_supports_progress(manager: Path) -> bool:
"""True when MANAGER (model_manager_v2.py) supports --progress output.
@@ -168,11 +209,90 @@ def _manager_supports_progress(manager: Path) -> bool:
older audio.cpp checkout may not have them, so probe the script source
once instead of failing the download with an unknown flag.
"""
+ return (_manager_supports_flag(manager, "AUDIOCPP_PROGRESS")
+ and _manager_supports_flag(manager, "--cancel-file"))
+
+
+def _sanitize_model_spec(spec: dict) -> bool:
+ """Repair dot ``strip_prefix`` packages in SPEC, in place.
+
+ A package's ``strip_prefix`` is stripped from the front of every file
+ path to get the local layout, so it only works when every file is
+ listed under that prefix (``<prefix>/<file>``). A dot prefix ("." or
+ "./") is meant for files written ``./<file>``; when the package instead
+ lists repo-root files bare (``model.gguf``), the manager rejects the
+ whole package ("file path does not start with strip_prefix '.': ...")
+ and nothing can be downloaded. Root-level files need no prefix at all
+ (upstream specs like minimax_music3.json store ""), so dropping the dot
+ prefix is the safe repair. Prefixes naming a real directory are left
+ alone — the correct remote paths cannot be guessed. Returns True when
+ SPEC changed.
+ """
+ changed = False
+ for package in spec.get("packages") or []:
+ if not isinstance(package, dict):
+ continue
+ prefix = str(package.get("strip_prefix") or "").rstrip("/")
+ if prefix not in (".", ".."):
+ continue
+ files = package.get("files")
+ if not isinstance(files, list) or not files:
+ continue
+ if all(isinstance(item, str) and item.startswith(prefix + "/")
+ for item in files):
+ continue
+ package["strip_prefix"] = ""
+ changed = True
+ return changed
+
+
+def _prepare_specs_dir(audiocpp_dir: Path) -> Optional[Path]:
+ """Return a temp specs dir with dot ``strip_prefix`` entries repaired.
+
+ audio.cpp's model manager accepts ``--specs-dir``, so a checkout whose
+ specs carry a broken ``strip_prefix`` can be installed from a sanitized
+ copy without modifying the checkout. Every ``model_specs/*.json`` is
+ copied; the ones needing a repair are rewritten via
+ ``_sanitize_model_spec`` (specs that fail to parse are copied verbatim
+ so the manager reports them exactly as it would upstream). Returns None
+ when no spec needed a repair (or the specs directory is missing or
+ unreadable) — the caller then runs against the checkout's own specs.
+ The caller owns the returned directory and removes it when the installs
+ are done.
+ """
+ specs_dir = audiocpp_dir / "model_specs"
try:
- text = manager.read_text(encoding="utf-8", errors="ignore")
+ spec_paths = sorted(specs_dir.glob("*.json"))
except OSError:
- return False
- return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text
+ return None
+ if not spec_paths:
+ return None
+ payloads: List[Tuple[str, str]] = []
+ sanitized = False
+ for spec_path in spec_paths:
+ try:
+ text = spec_path.read_text(encoding="utf-8")
+ except OSError:
+ return None
+ try:
+ spec = json.loads(text)
+ except ValueError:
+ payloads.append((spec_path.name, text))
+ continue
+ if isinstance(spec, dict) and _sanitize_model_spec(spec):
+ sanitized = True
+ text = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
+ payloads.append((spec_path.name, text))
+ if not sanitized:
+ return None
+ staging = Path(tempfile.mkdtemp(prefix="audiocpp_specs_"))
+ try:
+ for name, payload in payloads:
+ (staging / name).write_text(payload, encoding="utf-8")
+ except OSError:
+ shutil.rmtree(staging, ignore_errors=True)
+ raise
+ return staging
def download_applicable(audiocpp_dir: Path, model_entries: List[dict]) -> bool: