"""TTS Voice Creator - Clone and Design — FastAPI entry point (thin wrapper).""" from __future__ import annotations import logging import os from logging.handlers import RotatingFileHandler from pathlib import Path from contextlib import asynccontextmanager from urllib.parse import parse_qs from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware from starlette.datastructures import MutableHeaders from core.constants import STATIC_DIR, _BufferHandler from core.config import _load_settings from core.voice_index import refresh_voice_index_background from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db logger = logging.getLogger("uvicorn.error") # ── App ─────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): # Uvicorn resets loggers during startup when run via CLI. # We must attach our handlers here to ensure they capture the active logs. for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"): l = logging.getLogger(name) if _buf_handler not in l.handlers: l.addHandler(_buf_handler) if "_file_handler" in globals() and _file_handler not in l.handlers: l.addHandler(_file_handler) try: refresh_voice_index_background(_load_settings()) except Exception as exc: logger.warning("Could not start voice index refresh: %s", exc) yield app = FastAPI(title="TTS Voice Creator - Clone and Design", lifespan=lifespan) # ── Response compression ────────────────────────────────────────────────────── # Gzip text assets (JS/CSS/HTML/JSON) above 1 KB — ~75% smaller transfer for the # large JS bundle and voice-list JSON. Skips already-compressed audio/images. app.add_middleware(GZipMiddleware, minimum_size=1024) # ── In-memory log buffer handler ────────────────────────────────────────────── _buf_handler = _BufferHandler() _buf_handler.setLevel(logging.DEBUG) logging.getLogger().addHandler(_buf_handler) # ── File log handler (rotates at 5 MB, keeps 3 files) ──────────────────────── _LOG_DIR = Path(os.environ.get("LOG_DIR", "/logs")) try: _LOG_DIR.mkdir(parents=True, exist_ok=True) _file_handler = RotatingFileHandler( _LOG_DIR / "app.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8", ) _file_handler.setLevel(logging.INFO) _file_handler.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) logging.getLogger().addHandler(_file_handler) except Exception as _log_err: logger.warning("Could not set up file logging at %s: %s", _LOG_DIR, _log_err) # ── Static-asset caching middleware ────────────────────────────────────────── # JS and CSS are served with ?v= by loader.js — safe to cache for 1 year. # Section HTML uses ?v= (always fresh) so no long-term cache there. # # Pure ASGI middleware, not @app.middleware("http") (= Starlette's # BaseHTTPMiddleware) — that implementation wraps every request in a task # group around call_next(), and a client disconnecting mid-StreamingResponse # (e.g. the live-attribution SSE stream hitting its idle timeout) races that # task group and raises "RuntimeError: No response returned." This class # only touches response headers via the raw `send` callable, so it never # holds the response open the way call_next() does. class StaticCacheHeadersMiddleware: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return path = scope["path"] has_version = "v" in parse_qs((scope.get("query_string") or b"").decode()) async def send_wrapper(message): if message["type"] == "http.response.start": headers = MutableHeaders(scope=message) if has_version and (path.startswith("/static/js/") or path.endswith(".css")): headers["Cache-Control"] = "public, max-age=31536000, immutable" elif path.startswith("/static/sections/"): headers["Cache-Control"] = "no-store" elif path.startswith("/static/vendor/"): headers["Cache-Control"] = "public, max-age=86400" elif path.startswith("/static/") and not has_version: # Bootstrap files (style.css, loader.js, nav.js, …) are loaded without a ?v= query. # Revalidate on every load so GUI edits appear after a normal reload — the server # returns 304 when the file is unchanged, so this stays cheap. headers["Cache-Control"] = "no-cache" await send(message) await self.app(scope, receive, send_wrapper) app.add_middleware(StaticCacheHeadersMiddleware) # ── Routers ─────────────────────────────────────────────────────────────────── app.include_router(admin.router) app.include_router(settings.router) app.include_router(library.router) app.include_router(stt.router) app.include_router(sources.router) app.include_router(docker.router) app.include_router(tts.router) app.include_router(conversation.router) app.include_router(reader.router) app.include_router(characters.router) app.include_router(rehearsals_db.router) # ── Static files ────────────────────────────────────────────────────────────── app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # ── Dev runner ──────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7890, log_level="info")