Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
7.9 KiB
Python
144 lines
7.9 KiB
Python
"""Local Docker container management and URL probe endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from core.docker_client import _docker_get_json, _docker_post
|
|
from core.validation import _normalize_service_url
|
|
|
|
router = APIRouter()
|
|
|
|
_LOCAL_CONTAINER_DEFS: list[dict] = [
|
|
{"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup — restart after adding or editing voices."},
|
|
{"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Describe a voice in words — no WAV needed."},
|
|
{"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Style control over configured premium speakers such as Ryan, Vivian, and Serena."},
|
|
{"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/qwen3-tts-streaming-dgx-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."},
|
|
{"name": "parakeet-asr", "label": "NVIDIA Parakeet ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet GPU-accelerated speech recognition on port 8090."},
|
|
{"name": "magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. GPU-accelerated, high-quality multilingual synthesis."},
|
|
{"name": "parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet RNNT NIM multilingual ASR on port 8092."},
|
|
]
|
|
|
|
|
|
def _container_status(name: str) -> dict:
|
|
try:
|
|
code, data = _docker_get_json(f"/containers/{quote(name, safe='')}/json")
|
|
if code == 404:
|
|
return {"status": "not_found"}
|
|
if code == 200 and isinstance(data, dict):
|
|
state = data.get("State", {})
|
|
return {
|
|
"status": state.get("Status", "unknown"),
|
|
"running": state.get("Running", False),
|
|
"paused": state.get("Paused", False),
|
|
"started_at": state.get("StartedAt", ""),
|
|
"image": data.get("Config", {}).get("Image", ""),
|
|
}
|
|
return {"status": "error", "detail": f"HTTP {code}"}
|
|
except Exception as e:
|
|
return {"status": "error", "detail": str(e)}
|
|
|
|
|
|
@router.get("/api/local-containers")
|
|
async def get_local_containers():
|
|
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
|
|
results = []
|
|
for defn in _LOCAL_CONTAINER_DEFS:
|
|
entry = {k: v for k, v in defn.items()}
|
|
if sock_ok:
|
|
entry.update(_container_status(defn["name"]))
|
|
else:
|
|
entry["status"] = "no_socket"
|
|
results.append(entry)
|
|
return {"containers": results, "socket_available": sock_ok}
|
|
|
|
|
|
@router.post("/api/local-containers/{name}/start")
|
|
async def start_local_container(name: str):
|
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
|
raise HTTPException(404, f"Unknown container: {name}")
|
|
try:
|
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/start")
|
|
except Exception as e:
|
|
raise HTTPException(502, f"Docker start failed: {e}")
|
|
if code not in (204, 304):
|
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
|
return {"ok": True, "name": name, **_container_status(name)}
|
|
|
|
|
|
@router.post("/api/local-containers/{name}/stop")
|
|
async def stop_local_container(name: str):
|
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
|
raise HTTPException(404, f"Unknown container: {name}")
|
|
try:
|
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/stop?t=10")
|
|
except Exception as e:
|
|
raise HTTPException(502, f"Docker stop failed: {e}")
|
|
if code not in (204, 304):
|
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
|
return {"ok": True, "name": name, **_container_status(name)}
|
|
|
|
|
|
@router.post("/api/local-containers/{name}/restart")
|
|
async def restart_local_container(name: str):
|
|
if not any(c["name"] == name for c in _LOCAL_CONTAINER_DEFS):
|
|
raise HTTPException(404, f"Unknown container: {name}")
|
|
try:
|
|
code, _ = _docker_post(f"/containers/{quote(name, safe='')}/restart?t=10")
|
|
except Exception as e:
|
|
raise HTTPException(502, f"Docker restart failed: {e}")
|
|
if code not in (204, 304):
|
|
raise HTTPException(502, f"Docker API returned HTTP {code}")
|
|
return {"ok": True, "name": name, **_container_status(name)}
|
|
|
|
|
|
@router.get("/api/probe-url")
|
|
async def probe_url(url: str, type: str = ""):
|
|
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
|
|
base = _normalize_service_url(url).rstrip("/")
|
|
for _suffix in ("/v1", "/api/v1"):
|
|
if base.endswith(_suffix):
|
|
base = base[: -len(_suffix)]
|
|
break
|
|
hdrs = {"User-Agent": "TTS-Voice-Creator/probe"}
|
|
|
|
if type == "llm":
|
|
checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)]
|
|
elif type == "stt":
|
|
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
|
|
elif type == "tts":
|
|
checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
|
|
else:
|
|
checks = [("", None)]
|
|
|
|
last_err = "No response"
|
|
for path, json_key in checks:
|
|
try:
|
|
r = requests.get(base + path, timeout=5, headers=hdrs, allow_redirects=True)
|
|
if r.status_code >= 500:
|
|
last_err = f"HTTP {r.status_code} on {path or '/'}"
|
|
continue
|
|
if r.status_code >= 400 and path:
|
|
continue
|
|
if json_key:
|
|
try:
|
|
data = r.json()
|
|
if json_key not in data:
|
|
last_err = f"Unexpected response from {path} (missing '{json_key}')"
|
|
continue
|
|
except Exception:
|
|
last_err = f"{path} returned non-JSON (HTTP {r.status_code})"
|
|
continue
|
|
return {"ok": True, "status": r.status_code, "endpoint": path or "/"}
|
|
except requests.exceptions.ConnectionError:
|
|
return {"ok": False, "error": "Connection refused"}
|
|
except requests.exceptions.Timeout:
|
|
return {"ok": False, "error": "Timeout (5 s)"}
|
|
except Exception as e:
|
|
last_err = str(e)
|
|
return {"ok": False, "error": last_err}
|