Files
manage/backend/src/media_library_viewer_api/main.py
T
Developer b200025daa refactor(observability): drop file-SD writer for http_sd_configs
Slice 4 of observability-service-registry. Removes the shared-file
Prometheus bridge; external Prometheus now consumes node-exporter targets
via http_sd_configs against GET /api/monitoring/prometheus-targets.

- services/targets.py: removed write_prometheus_targets() (the file
  writer) and its json/Path/get_settings imports; updated module docstring.
  build_node_exporter_targets() is unchanged and still powers the HTTP
  endpoint.
- main.py: removed the startup write_prometheus_targets call.
- routers/settings.py: removed the _write_prometheus_targets helper and
  its three post machine create/update/delete call sites + the now-unused
  targets import.
- config.py: removed the prometheus_file_sd_dir field.
- docker-compose.yml / docker-compose.dev.yml: removed the
  PROMETHEUS_FILE_SD_DIR backend env var.
- tests: removed TestWritePrometheusTargets + the write_prometheus_targets
  import in test_targets.py; rewrote the two TestSettingsMachines tests to
  assert machines appear/disappear from /api/monitoring/prometheus-targets
  (the surviving HTTP path) instead of the removed file-writer side effect.

ruff clean; 239 backend tests pass.
2026-06-24 08:37:20 +00:00

170 lines
5.5 KiB
Python

"""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 backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
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 .version import get_backend_version, get_version_info
logger = logging.getLogger(__name__)
@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")
mail_queue = get_mail_queue()
backup_poller = get_backup_poller()
mail_queue.start()
backup_poller.start()
yield
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(users.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(services_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="0.0.0.0", port=8000)