00cfca1a57
Live and recording both read from the go2rtc restream. If go2rtc's runtime stream table gets emptied (camera clear via API, go2rtc restart with a stale/empty config, or a silently-failed startup sync_all), every restream returns 404 and both live and recording go dark until a manual `docker restart nvr-go2rtc`. Add a background Reconciler that every 30s diffs the desired streams against go2rtc /api/streams and PUTs only the missing ones (existing streams are left untouched so active producers/consumers are not disrupted). VERSION -> 0.0.115. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.8 KiB
Python
140 lines
4.8 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, 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)
|
|
|
|
os.makedirs(runtime.config.storage.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.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.Vision", 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(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")
|