diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cd1b6f..6a20cb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
+- **Provider status clarity.** TTS and STT backend discovery now reports configuration separately from liveness,
+ with bounded endpoint/status/error details shown in backend help text so unreachable services are actionable.
- **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in
`audiobook-cast.js`, including dialogue-only merging, split-sentence repair, and nearby-duplicate removal,
phantom-speaker filtering, and speaker-alias consolidation, keeping deterministic segment cleanup separate from
diff --git a/TODO.md b/TODO.md
index 253d207..6a83239 100644
--- a/TODO.md
+++ b/TODO.md
@@ -99,8 +99,8 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [x] **M — Add structured logs with request/job IDs.** Report provider, endpoint,
duration and failure reason without logging prompts, keys or book contents.
-- [ ] **M — Separate provider health from configuration status.** Show actionable
- connectivity and authentication errors.
+- [x] **M — Separate provider health from configuration status.** TTS/STT discovery now returns independent
+ `configured`, `available`, and bounded `health` details; backend help text shows actionable connectivity errors.
- [x] **M — Add visible job history and retry controls.** Expose recent failures and
safe retry state to users.
- [x] **L — Add memory diagnostics.** Show browser heap where supported, rendered rows,
@@ -231,7 +231,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [ ] Add structured logs with request and job IDs.
- [ ] Report provider, endpoint, duration, and error reason clearly.
-- [ ] Separate health status from “configured”.
+- [x] Separate health status from “configured”; discovery APIs now preserve both states and expose sanitized probe errors.
- [x] Add visible job history and safe retry state; the authenticated `/api/jobs` metadata endpoint now feeds a
rendered panel with status, elapsed time, errors, cancellation, and an explicit reopen-source retry boundary.
- [ ] Avoid logging prompts, API keys, or book contents by default.
diff --git a/VERSION b/VERSION
index 9f5aeb8..d1c9614 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.24.42
+1.24.43
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4c966c9..1053e94 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -2,6 +2,7 @@
## Current shape
- **Backend**: FastAPI, modular — `server.py` (app + middleware) → `routes/*.py` (admin, tts, library, sources, stt, conversation, docker, settings) → `core/*.py` (audio, voice, config, validation, tts_helpers, …).
+- **Provider status**: TTS/STT discovery responses expose `configured` independently from `available`/`health`, with only bounded probe endpoint, HTTP status, and sanitized error details returned to the UI.
- **Frontend**: ~15k lines of **plain global-scope JS** in `static/js/*.js`, loaded in order by `static/loader.js` (no bundler/build step). Sections are HTML fragments in `static/sections/*.html` fetched at runtime. Modules communicate via globals (`window._voices`, `$`, `escHtml`, `toast`, `saveMeta`, `loadVoiceLibrary`, …) and some runtime monkey-patching (e.g. `renderVoiceList` is reassigned in `voice-library.js`).
## Already optimised
diff --git a/routes/stt.py b/routes/stt.py
index 6240dc9..050aadd 100644
--- a/routes/stt.py
+++ b/routes/stt.py
@@ -166,6 +166,22 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
return settings.get("whisper_url") or _WHISPER_DEFAULT
+def _stt_backend_configured(settings: dict, backend: str) -> bool:
+ backend = _clean_stt_backend(backend)
+ if backend == "custom:" or backend.startswith("custom:"):
+ return bool(_stt_backend_url(settings, backend).strip())
+ if backend == "groq_whisper":
+ return bool(settings.get("groq_api_key", "").strip())
+ keys = {
+ "nvidia_parakeet": ("nvidia_asr_url",),
+ "nvidia_router": ("nvidia_router_url",),
+ "faster_whisper": ("faster_whisper_url",),
+ "whisper_cpp": ("whisper_cpp_url",),
+ "configured": ("whisper_url",),
+ }.get(backend, ())
+ return any(str(settings.get(key) or '').strip() for key in keys)
+
+
def _stt_backend_model(backend: str) -> str:
backend = _clean_stt_backend(backend)
if backend in {"nvidia_parakeet", "nvidia_router"}:
@@ -227,7 +243,7 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
return header + data
-def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
+def _stt_backend_health(url: str) -> dict:
base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under
@@ -249,14 +265,15 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
elif isinstance(item, str):
models.append(item)
if not ok:
- return False, models
+ return {"ok": False, "models": models, "error": health.get("error") or models_result.get("error")}
# Do not send a real audio transcription as a health probe. WhisperX's
# batch server uses one worker and a probe consumes the GPU for several
# seconds; while it runs, even /health blocks and the UI reports the
# otherwise healthy service as unreachable. The service health endpoint is
# the authoritative liveness check; the real transcription path is tested
# when the user actually transcribes audio.
- return ok, models
+ return {"ok": True, "models": models, "status": health.get("status") or models_result.get("status"),
+ "endpoint": health.get("endpoint") or models_result.get("endpoint")}
def _transcription_text_from_response(resp: requests.Response) -> str:
@@ -487,13 +504,16 @@ async def stt_backends():
ok = bool(api_key)
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
else:
- ok, models = _stt_backend_health(url)
+ health_detail = _stt_backend_health(url)
+ ok, models = health_detail.get("ok", False), health_detail.get("models", [])
items.append({
"id": backend,
"label": _stt_backend_label(backend, url),
"url": url,
"port": _backend_port_label(url),
"available": ok,
+ "configured": _stt_backend_configured(settings, backend),
+ "health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
"model": _stt_backend_model(backend),
"models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
@@ -509,7 +529,8 @@ async def stt_backends():
if (card_id, url) in seen_urls:
continue
seen_urls.add((card_id, url))
- ok, models = _stt_backend_health(url)
+ health_detail = _stt_backend_health(url)
+ ok, models = health_detail.get("ok", False), health_detail.get("models", [])
port = _backend_port_label(url)
label = card.get("label") or card.get("name") or "Custom STT"
if port:
@@ -520,6 +541,8 @@ async def stt_backends():
"url": url,
"port": port,
"available": ok,
+ "configured": _stt_backend_configured(settings, card_id),
+ "health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
"model": "whisper-1",
"models": models,
"metrics": {},
diff --git a/routes/tts.py b/routes/tts.py
index a9c672d..fd78891 100644
--- a/routes/tts.py
+++ b/routes/tts.py
@@ -321,8 +321,26 @@ def _backend_capabilities(backend: str) -> dict:
return caps.get(_clean_preview_backend(backend), {})
+def _backend_health_detail(url: str) -> dict:
+ return probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)
+
+
def _backend_health(url: str) -> bool:
- return bool(probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)['ok'])
+ return bool(_backend_health_detail(url).get('ok'))
+
+
+def _backend_configured(settings: dict, backend: str, voices: list) -> bool:
+ """Report explicit configuration independently from provider liveness."""
+ keys = {
+ "voice_clone": ("tts_url",), "streaming": ("tts_stream_url", "tts_url"),
+ "customvoice": ("customvoice_url",), "voice_design": ("voice_design_url",),
+ "nvidia_magpie": ("nvidia_tts_url", "nvidia_router_url"),
+ "nvidia_zeroshot": ("nvidia_zeroshot_url", "nvidia_clone_url", "nvidia_router_url"),
+ "nvidia_flow": ("nvidia_flow_url", "nvidia_clone_url", "nvidia_router_url"),
+ "kokoro": ("kokoro_url",), "vibevoice": ("vibevoice_url",),
+ "xtts": ("xtts_url",), "fishspeech": ("fishspeech_url",),
+ }.get(_clean_preview_backend(backend), ())
+ return bool(voices) if _clean_preview_backend(backend) in {"voice_clone", "streaming", "fishspeech"} and not any(str(settings.get(k) or '').strip() for k in keys) else any(str(settings.get(k) or '').strip() for k in keys)
def _backend_available(backend: str, voices: list, health: bool) -> bool:
@@ -611,7 +629,8 @@ async def tts_backends():
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
voices = _fetch_backend_voices(settings, backend)
- health = _backend_health(url)
+ health_detail = _backend_health_detail(url)
+ health = bool(health_detail.get('ok'))
available = _backend_available(backend, voices, health)
items.append({
"id": backend,
@@ -619,6 +638,13 @@ async def tts_backends():
"url": url,
"port": _backend_port_label(url),
"available": available,
+ "configured": _backend_configured(settings, backend, voices),
+ "health": {
+ "ok": health,
+ "status": health_detail.get("status"),
+ "endpoint": health_detail.get("endpoint"),
+ "error": health_detail.get("error"),
+ },
"voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend),
})
diff --git a/static/index.html b/static/index.html
index 363c673..684bdd1 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,10 +27,10 @@
-
-
-
-
+
+
+
+
@@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
-
+