"""FastAPI application entrypoint.""" from __future__ import annotations import logging import time from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response as FastAPIResponse from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings from media_library_viewer_api.config import get_settings from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra from media_library_viewer_api.observability import ( get_request_id, metrics_payload, record_request, set_current_request_id, ) from media_library_viewer_api.routers import ( authentik_users as authentik_users_router, ) from media_library_viewer_api.routers import backups as backups_router from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks from media_library_viewer_api.routers import dashboards as dashboards_router from media_library_viewer_api.routers import jellyseerr as jellyseerr_router from media_library_viewer_api.routers import scheduler as scheduler_router # type: ignore[reportAttributeAccessIssue] from media_library_viewer_api.routers import services as services_router from media_library_viewer_api.routers import widgets as widgets_router from media_library_viewer_api.routers.settings import router as settings_router from .services.backup_poller import get_backup_poller from .services.scheduler import get_scheduler # type: ignore[reportMissingImports] from .version import get_backend_version, get_version_info logger = logging.getLogger(__name__) def _validate_prometheus_gateway_config() -> None: """Warn (not crash) about old-shape prometheus services needing migration (GM-113).""" try: store = get_settings_store() for service in store.list_services("prometheus"): config = service.get("config") or {} if "base_url" in config and "grafana_url" not in config: logger.warning( "Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. " "Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).", service.get("name"), service.get("id"), ) except Exception: # pragma: no cover - startup best-effort logger.exception("Failed to validate prometheus gateway config during startup") @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan — startup/shutdown.""" settings = get_settings() configure_logging(settings.log_level, settings.log_format) validate_auth_settings(settings) from media_library_viewer_api.services.secrets import validate_encryption_key validate_encryption_key() logger.info("Backend startup complete: %s", describe_settings(settings)) logger.info("Managed known_hosts will be populated lazily on first successful SSH connection") try: get_settings_store().ensure_defaults() except Exception: logger.exception("Failed to seed default settings during startup") try: from media_library_viewer_api.services.service_data import get_service_data_harness get_service_data_harness() except Exception: logger.exception("Failed to initialize service data harness during startup") _validate_prometheus_gateway_config() mail_queue = get_mail_queue() backup_poller = get_backup_poller() scheduler = get_scheduler() mail_queue.start() backup_poller.start() scheduler.start() yield scheduler.stop() backup_poller.stop() mail_queue.stop() logger.info("Backend shutdown complete") app = FastAPI( title="Manage API", version=get_backend_version(), description=( "Manage API for Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access." ), lifespan=lifespan, ) # CORS for development (Vite runs on :5173) app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", "http://localhost:3000", "http://127.0.0.1:5173", "http://127.0.0.1:3000", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def enforce_jwt_auth(request: Request, call_next): if request.url.path in {"/api/health", "/api/version", "/metrics"}: return await call_next(request) return await require_jwt_auth(request, call_next) @app.middleware("http") async def log_requests(request: Request, call_next): """Log every API request with timing, outcome, and request id.""" start = time.perf_counter() request_id = get_request_id(request) set_current_request_id(request_id) request.state.request_id = request_id client_host = request.client.host if request.client else "unknown" extra = sanitize_log_extra({"request_id": request_id, "client_host": client_host}) logger.info("request start %s %s", request.method, request.url.path, extra=extra) try: response = await call_next(request) except Exception: logger.exception( "request error %s %s", request.method, request.url.path, extra=sanitize_log_extra({"request_id": request_id, "client_host": client_host}), ) raise elapsed_ms = (time.perf_counter() - start) * 1000.0 response.headers["X-Request-Id"] = request_id record_request(request, response, elapsed_ms / 1000.0) logger.info( "request end %s %s status=%s elapsed_ms=%.1f", request.method, request.url.path, response.status_code, elapsed_ms, extra=sanitize_log_extra( { "request_id": request_id, "client_host": client_host, "status_code": response.status_code, "elapsed_ms": round(elapsed_ms, 1), } ), ) return response # Register routers app.include_router(dashboard.router) app.include_router(monitoring.router) app.include_router(media.router) app.include_router(files.router) app.include_router(jobs.router) app.include_router(tasks.router) app.include_router(settings_router) app.include_router(backups_router.router) app.include_router(widgets_router.router) app.include_router(scheduler_router.router) app.include_router(dashboards_router.router) app.include_router(jellyseerr_router.router) app.include_router(services_router.router) app.include_router(authentik_users_router.router) @app.get("/api/health") def health_check() -> dict[str, str]: """Simple health check endpoint.""" logger.debug("health check requested") return {"status": "ok"} @app.get("/api/version") def version_info() -> dict[str, str]: """Expose backend version/build metadata for the UI.""" logger.debug("version requested") return get_version_info() @app.get("/metrics") def metrics() -> Response: """Expose Prometheus metrics.""" data, content_type = metrics_payload() return FastAPIResponse(content=data, media_type=content_type) if __name__ == "__main__": uvicorn.run(app, host="127.0.0.1", port=8000)