Security fixes: - Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal) - Verify yt-dlp output stays inside TEMP_DIR before registration - Remove Access-Control-Allow-Origin: * from /api/proxy-audio - TTL-based temp file registry (default 2h) to prevent disk fill Performance: - Cache settings + routing rules in memory (mtime-checked); eliminates per-request disk reads on every TTS call UI: - Add container name (optional) field to Docker stack TTS/STT engine cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming, NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on all engine cards, matching the existing Other Local TTS/STT cards Refactor — backend: - server.py: 5560 lines → 43-line entry point - core/ package: constants, registry, validation, docker_client, config, routing, audio, voice, presets, tts_helpers - routes/ package: admin, settings, library, stt, sources, docker, tts, conversation (FastAPI APIRouter modules) - Dockerfile + docker-compose.yml updated to include core/ and routes/ Refactor — frontend: - static/app.js: 8744 lines → 16 modules in static/js/ utils, voice-inspector, voice-sources, integrations, routing, settings, voice-clone, voice-library, tts-preview, benchmark, stt, init, engines, ai-backends, generation, conversation - static/loader.js updated to load modules sequentially Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
257 lines
9.5 KiB
Python
257 lines
9.5 KiB
Python
"""TTS routing rules: load/save/resolve routes, language detection, routing log."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from fastapi import Request
|
|
|
|
from core.constants import TTS_ROUTES_FILE, _routing_log_add
|
|
|
|
# ── Route token / backend validation ─────────────────────────────────────────
|
|
|
|
_ROUTE_LANGS = {"AUTO", "*", "EN", "DE", "FR", "ES", "IT", "PT", "NL", "PL"}
|
|
_ROUTE_BACKENDS = {"voice_clone", "streaming", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"}
|
|
|
|
|
|
def _clean_route_token(value: str, default: str = "*") -> str:
|
|
value = str(value or "").strip()
|
|
if not value:
|
|
return default
|
|
value = re.sub(r"[^A-Za-z0-9_\-\.\* ]+", "_", value)
|
|
return value[:80] or default
|
|
|
|
|
|
def _clean_route_sound(value: str) -> str:
|
|
value = str(value or "").strip()
|
|
if not value:
|
|
return ""
|
|
value = re.sub(r"[^A-Za-z0-9_\-\.\* /]+", "_", value)
|
|
return value[:240]
|
|
|
|
|
|
def _clean_route_backend(value: str) -> str:
|
|
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
|
|
aliases = {
|
|
"clone": "voice_clone",
|
|
"base": "voice_clone",
|
|
"tts": "voice_clone",
|
|
"standard": "voice_clone",
|
|
"voiceclone": "voice_clone",
|
|
"voice_clone_base": "voice_clone",
|
|
"stream": "streaming",
|
|
"tts_streaming": "streaming",
|
|
"voice_design": "voice_design",
|
|
"voicedesign": "voice_design",
|
|
"design": "voice_design",
|
|
"nvidia": "nvidia_magpie",
|
|
"magpie": "nvidia_magpie",
|
|
"nvidia_tts": "nvidia_magpie",
|
|
"nvidia_magpie_tts": "nvidia_magpie",
|
|
"nvidia_clone": "nvidia_zeroshot",
|
|
"nvidia_zeroshot_tts": "nvidia_zeroshot",
|
|
"magpie_zeroshot": "nvidia_zeroshot",
|
|
"zeroshot": "nvidia_zeroshot",
|
|
"zero_shot": "nvidia_zeroshot",
|
|
"nvidia_flow_tts": "nvidia_flow",
|
|
"magpie_flow": "nvidia_flow",
|
|
"flow": "nvidia_flow",
|
|
}
|
|
key = aliases.get(key, key)
|
|
return key if key in _ROUTE_BACKENDS else "voice_clone"
|
|
|
|
|
|
def _normalize_route(rule: dict, idx: int = 0) -> dict:
|
|
lang = str(rule.get("language", "*") or "*").strip().upper()
|
|
if lang not in _ROUTE_LANGS:
|
|
lang = "*"
|
|
output_voice = _clean_route_token(rule.get("output_voice", ""), "")
|
|
return {
|
|
"id": _clean_route_token(rule.get("id", f"route_{idx+1}"), f"route_{idx+1}"),
|
|
"enabled": bool(rule.get("enabled", True)),
|
|
"app": _clean_route_token(rule.get("app", "Open WebUI"), "Open WebUI"),
|
|
"input_voice": _clean_route_token(rule.get("input_voice", "default"), "default"),
|
|
"language": lang,
|
|
"backend": _clean_route_backend(rule.get("backend", "voice_clone")),
|
|
"output_voice": output_voice,
|
|
"before_sound": _clean_route_sound(rule.get("before_sound", "")),
|
|
"after_sound": _clean_route_sound(rule.get("after_sound", "")),
|
|
}
|
|
|
|
|
|
_routes_cache: list[dict] | None = None
|
|
_routes_cache_mtime: float = -1.0
|
|
|
|
|
|
def _load_tts_routes() -> list[dict]:
|
|
global _routes_cache, _routes_cache_mtime
|
|
mtime = TTS_ROUTES_FILE.stat().st_mtime if TTS_ROUTES_FILE.exists() else 0.0
|
|
if _routes_cache is not None and mtime == _routes_cache_mtime:
|
|
return list(_routes_cache)
|
|
if not TTS_ROUTES_FILE.exists():
|
|
_routes_cache = []
|
|
_routes_cache_mtime = 0.0
|
|
return []
|
|
try:
|
|
raw = json.loads(TTS_ROUTES_FILE.read_text())
|
|
routes = raw.get("routes", raw) if isinstance(raw, dict) else raw
|
|
if not isinstance(routes, list):
|
|
result: list[dict] = []
|
|
else:
|
|
result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)]
|
|
except Exception:
|
|
result = []
|
|
_routes_cache = result
|
|
_routes_cache_mtime = mtime
|
|
return list(result)
|
|
|
|
|
|
def _save_tts_routes(routes: list[dict]) -> None:
|
|
global _routes_cache, _routes_cache_mtime
|
|
TTS_ROUTES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
clean = [_normalize_route(r, i) for i, r in enumerate(routes)]
|
|
TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2))
|
|
_routes_cache = clean
|
|
_routes_cache_mtime = TTS_ROUTES_FILE.stat().st_mtime
|
|
|
|
|
|
# ── App name helpers ──────────────────────────────────────────────────────────
|
|
|
|
def _app_key(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
|
|
|
|
|
|
def _canonical_app_name(value: str) -> str:
|
|
key = _app_key(value)
|
|
if key in {"openwebui", "openwebuiapp"}:
|
|
return "Open WebUI"
|
|
if key == "sillytavern":
|
|
return "SillyTavern"
|
|
if key in {"homeassistant", "ha"}:
|
|
return "Home Assistant"
|
|
return str(value or "").strip() or "Open WebUI"
|
|
|
|
|
|
def _request_app_name(request: Request) -> str:
|
|
explicit = request.headers.get("x-tts-app") or request.headers.get("x-client-app")
|
|
if explicit:
|
|
return _canonical_app_name(explicit)
|
|
header = (
|
|
request.headers.get("x-openwebui-app")
|
|
or request.headers.get("x-openwebui-user-name")
|
|
or request.headers.get("referer")
|
|
or request.headers.get("origin")
|
|
or request.headers.get("user-agent")
|
|
or ""
|
|
)
|
|
h = _app_key(header)
|
|
if "openwebui" in h:
|
|
return "Open WebUI"
|
|
if "sillytavern" in h:
|
|
return "SillyTavern"
|
|
if "homeassistant" in h:
|
|
return "Home Assistant"
|
|
return "Open WebUI"
|
|
|
|
|
|
# ── Language detection ────────────────────────────────────────────────────────
|
|
|
|
def _detect_text_language(text: str) -> str:
|
|
low = f" {text.lower()} "
|
|
if re.search(r"[äöüß]", low) or re.search(r"\b(und|der|die|das|ich|nicht|mit|ist|ein|eine|auf|für)\b", low):
|
|
return "DE"
|
|
if re.search(r"[éèêàçùœ]", low) or re.search(r"\b(et|le|la|les|des|une|avec|pour|est|pas|que)\b", low):
|
|
return "FR"
|
|
if re.search(r"[áéíóúñ¿¡]", low) or re.search(r"\b(el|la|los|las|una|con|para|que|pero|está|hola)\b", low):
|
|
return "ES"
|
|
if re.search(r"\b(il|lo|gli|una|con|per|che|ciao|grazie|sono|della)\b", low):
|
|
return "IT"
|
|
if re.search(r"[ãõç]", low) or re.search(r"\b(com|para|uma|que|não|está|obrigado)\b", low):
|
|
return "PT"
|
|
if re.search(r"\b(het|een|niet|met|voor|zijn|maar|dank|goede)\b", low):
|
|
return "NL"
|
|
if re.search(r"[ąćęłńóśźż]", low) or re.search(r"\b(jest|nie|tak|dla|oraz|dzień|dziękuję)\b", low):
|
|
return "PL"
|
|
return "EN"
|
|
|
|
|
|
# ── Route matching ────────────────────────────────────────────────────────────
|
|
|
|
def _route_specificity(rule: dict, app: str, voice: str, lang: str) -> tuple[int, int, int, int] | None:
|
|
if not rule.get("enabled", True) or not rule.get("output_voice"):
|
|
return None
|
|
r_app = str(rule.get("app", "*"))
|
|
r_voice = str(rule.get("input_voice", "*"))
|
|
r_lang = str(rule.get("language", "*")).upper()
|
|
app_ok = r_app == "*" or _app_key(r_app) == _app_key(app)
|
|
voice_ok = r_voice == "*" or r_voice.lower() == voice.lower()
|
|
lang_ok = r_lang in {"*", "AUTO"} or r_lang == lang
|
|
if not (app_ok and voice_ok and lang_ok):
|
|
return None
|
|
return (
|
|
1 if r_app != "*" else 0,
|
|
1 if r_voice != "*" else 0,
|
|
1 if r_lang not in {"*", "AUTO"} else 0,
|
|
0,
|
|
)
|
|
|
|
|
|
def _resolve_tts_route(app: str, voice: str, text: str) -> tuple[str, dict | None]:
|
|
lang = _detect_text_language(text)
|
|
best: tuple[tuple[int, int, int, int], dict] | None = None
|
|
for idx, rule in enumerate(_load_tts_routes()):
|
|
spec = _route_specificity(rule, app, voice, lang)
|
|
if spec is None:
|
|
continue
|
|
spec = (spec[0], spec[1], spec[2], -idx)
|
|
if best is None or spec > best[0]:
|
|
best = (spec, rule)
|
|
if not best:
|
|
return voice, None
|
|
routed = dict(best[1])
|
|
routed["detected_language"] = lang
|
|
routed["requested_voice"] = voice
|
|
routed["app"] = app
|
|
return str(routed["output_voice"]), routed
|
|
|
|
|
|
def _route_backend(route: dict | None, voice: str = "") -> str:
|
|
from core.presets import _resolve_virtual_voice
|
|
if _resolve_virtual_voice(voice):
|
|
return "voice_design"
|
|
return _clean_route_backend((route or {}).get("backend", "voice_clone"))
|
|
|
|
|
|
# ── Routing log request helper ────────────────────────────────────────────────
|
|
|
|
def _routing_log_request(
|
|
request: Request,
|
|
*,
|
|
status: str,
|
|
app: str,
|
|
requested_voice: str,
|
|
routed_voice: str,
|
|
backend: str,
|
|
route: dict | None,
|
|
response_format: str,
|
|
text: str,
|
|
**extra,
|
|
) -> None:
|
|
route = route or {}
|
|
_routing_log_add(
|
|
kind="proxy",
|
|
status=status,
|
|
app=app,
|
|
requested_voice=requested_voice,
|
|
routed_voice=routed_voice,
|
|
backend=backend,
|
|
language=str(route.get("detected_language", "")) or (_detect_text_language(text) if text else ""),
|
|
matched=bool(route),
|
|
route_id=str(route.get("id", "")),
|
|
response_format=response_format,
|
|
text_preview=text[:160],
|
|
client=request.client.host if request.client else "",
|
|
user_agent=str(request.headers.get("user-agent", ""))[:160],
|
|
**extra,
|
|
)
|