Files
CoRE.Vision/backend/app/main.py
T
git_admin 00cfca1a57 go2rtc: auto-reconcile streams so live/recording self-heal
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>
2026-05-30 12:53:02 +05:00

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")