"""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 fastapi import FastAPI, Request from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.gzip import GZipMiddleware 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. @app.middleware("http") async def static_cache_headers(request: Request, call_next): response: Response = await call_next(request) path = request.url.path has_version = bool(request.query_params.get("v")) if has_version and (path.startswith("/static/js/") or path.endswith(".css")): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif path.startswith("/static/sections/"): response.headers["Cache-Control"] = "no-store" elif path.startswith("/static/vendor/"): response.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. response.headers["Cache-Control"] = "no-cache" return response # ── 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")