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) { - + diff --git a/static/js/settings.js b/static/js/settings.js index 2356958..1cdd030 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -88,7 +88,12 @@ function backendHelpHtml(b, compact = false) { if (b.ram) metricParts.push(` ${escHtml(b.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}
Identity: ${escHtml(b.identity || '')}
Style: ${escHtml(b.style || '')}
Best for: ${escHtml(b.best_for || '')}`; - return `${escHtml(b.label)}
${tags}
${metrics}
${detail}
`; + const health = b.health || {}; + const state = b.available ? 'ready' + : b.configured ? 'configured · unreachable' + : 'not configured'; + const error = !b.available && health.error ? `
${escHtml(health.error)}
` : ''; + return `${escHtml(b.label)} ${state}
${tags}
${metrics}
${detail}
${error}`; } function sttBackendHelpHtml(b) { @@ -101,8 +106,12 @@ function sttBackendHelpHtml(b) { if (m.ram) metricParts.push(` ${escHtml(m.ram)}`); const metrics = metricParts.length ? `
${metricParts.join('')}
` : ''; const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : ''; - const avail = b.available ? `ready` : `unavailable`; - return `${escHtml(b.label)} ${avail}${metrics}
${escHtml(b.url)}${escHtml(modelList)}
`; + const health = b.health || {}; + const state = b.available ? `ready` + : b.configured ? `configured · unreachable` + : `not configured`; + const error = !b.available && health.error ? ` ${escHtml(health.error)}` : ''; + return `${escHtml(b.label)} ${state}${metrics}
${escHtml(b.url)}${escHtml(modelList)}${error}
`; } function updateBackendHelp() { diff --git a/tests/test_security.py b/tests/test_security.py index 05c89cb..5e9dffc 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -78,6 +78,16 @@ class SecurityValidationTests(unittest.TestCase): self.assertEqual(_transcription_text_from_response(ResponseStub({"segments": [{"text": " one "}, {"text": "two"}]})), "one two") self.assertEqual(_transcription_text_from_response(ResponseStub(" plain text ")), "plain text") + def test_provider_configuration_is_distinct_from_liveness(self): + from routes.stt import _stt_backend_configured + from routes.tts import _backend_configured + self.assertFalse(_stt_backend_configured({}, "configured")) + self.assertTrue(_stt_backend_configured({"whisper_url": "http://whisper"}, "configured")) + self.assertFalse(_stt_backend_configured({}, "groq_whisper")) + self.assertTrue(_stt_backend_configured({"groq_api_key": "configured"}, "groq_whisper")) + self.assertFalse(_backend_configured({}, "customvoice", [])) + self.assertTrue(_backend_configured({"customvoice_url": "http://tts"}, "customvoice", [])) + def test_provider_retry_policy_is_bounded_and_closes_discarded_responses(self): from unittest.mock import patch from core.retry import request_with_retries, retry_delay