ab817e0d31
Re-add the top header as a 50px bar injected by ensureHeader() on every layout page (skipped on login), with the brand centered. The layout height now subtracts both header and footer. Rebrand the project CoRE.Vision → CoRE.Vizion+ everywhere: header + footer brand, all page <title>s, the login card, and the FastAPI OpenAPI title (applies on next backend restart). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
"""Точка входа 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")
|
|
|
|
|
|
@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) # потоки (вкл. <cam>_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() -> FileResponse:
|
|
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
|
|
|
|
|
@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")
|