"""Точка входа NVR: FastAPI + супервизор записи + индексатор + retention.""" from __future__ import annotations import asyncio import logging import os from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from .config import load_config from .db import Database from .recorder.indexer import Indexer from .recorder.supervisor import Supervisor from .services.retention import Retention from .runtime import runtime from . import auth, prefs from .api import cameras, recordings, storages, system, ws from .api.ws import ConnectionManager from .services import go2rtc logging.basicConfig( level=os.environ.get("NVR_LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) log = logging.getLogger("nvr") FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend") # Мобильный dashboard: тот же бэкенд слушает один порт, но в docker проброшен второй # хост-порт (8443→8090). Запрос на него приходит с Host ".. :8443" → отдаём mobile.html. MOBILE_PORT = os.environ.get("NVR_MOBILE_PORT", "8443") def _is_mobile(request: Request) -> bool: return request.headers.get("host", "").endswith(":" + MOBILE_PORT) @asynccontextmanager async def lifespan(app: FastAPI): runtime.config = load_config() runtime.db = Database() await runtime.db.connect() runtime.ws = ConnectionManager() runtime.ws.bind_loop(asyncio.get_running_loop()) def on_status(status) -> None: runtime.ws.broadcast_threadsafe({"type": "camera", "camera": status.to_dict()}) runtime.supervisor = Supervisor(runtime.config, on_status=on_status) runtime.indexer = Indexer(runtime.config, runtime.db) runtime.retention = Retention(runtime.config, runtime.db) # каталоги локальных хранилищ; NAS не создаём — ждём монтаж на хосте (guard в recorder_task) for st in runtime.config.storage.items: if st.type == "local": os.makedirs(st.path, exist_ok=True) await go2rtc.sync_all(runtime.config) # потоки (вкл. _main) в go2rtc для рестрима runtime.reconciler = go2rtc.Reconciler(runtime.config) # самовосстановление потоков go2rtc runtime.supervisor.start_all() runtime.indexer.start() runtime.retention.start() runtime.reconciler.start() log.info("NVR запущен: камер=%d, активное хранилище=%s", len(runtime.config.cameras), runtime.config.active_storage().path) try: yield finally: log.info("остановка NVR…") await runtime.reconciler.stop() await runtime.indexer.stop() await runtime.retention.stop() await runtime.supervisor.stop_all() await runtime.db.close() app = FastAPI(title="CoRE.Vizion+", version="1.0", lifespan=lifespan) # публичные пути (без авторизации) PUBLIC_PATHS = {"/login", "/api/login", "/api/health"} @app.middleware("http") async def cross_origin_isolation(request: Request, call_next): # COOP/COEP включают crossOriginIsolated → доступен SharedArrayBuffer, # который нужен многопоточному WASM-декодеру H.265 (свой плеер jessibuca). response = await call_next(request) response.headers["Cross-Origin-Opener-Policy"] = "same-origin" response.headers["Cross-Origin-Embedder-Policy"] = "credentialless" return response @app.middleware("http") async def require_auth(request: Request, call_next): path = request.url.path if path in PUBLIC_PATHS or path.startswith("/static/"): return await call_next(request) if auth.auth_manager.validate(request.cookies.get(auth.COOKIE_NAME)): return await call_next(request) if path.startswith("/api/"): return JSONResponse({"detail": "unauthorized"}, status_code=401) return RedirectResponse("/login") app.include_router(auth.router) app.include_router(prefs.router) app.include_router(cameras.router) app.include_router(recordings.router) app.include_router(storages.router) app.include_router(system.router) app.include_router(ws.router) # ── фронтенд ──────────────────────────────────────────────────── @app.get("/") async def index(request: Request) -> FileResponse: # на мобильном порту — мобильный dashboard (Live одной камеры + Архив) page = "mobile.html" if _is_mobile(request) else "index.html" return FileResponse(os.path.join(FRONTEND_DIR, page)) @app.get("/archive") async def archive() -> FileResponse: return FileResponse(os.path.join(FRONTEND_DIR, "archive.html")) @app.get("/settings") async def settings() -> FileResponse: return FileResponse(os.path.join(FRONTEND_DIR, "settings.html")) @app.get("/cams") async def cams() -> FileResponse: return FileResponse(os.path.join(FRONTEND_DIR, "cams.html")) @app.get("/layout") async def layout() -> RedirectResponse: # раскладка переехала во вкладку «Настройки → Раскладка» return RedirectResponse("/settings") @app.get("/login") async def login_page() -> FileResponse: return FileResponse(os.path.join(FRONTEND_DIR, "login.html")) if os.path.isdir(os.path.join(FRONTEND_DIR, "static")): app.mount("/static", StaticFiles(directory=os.path.join(FRONTEND_DIR, "static")), name="static")