c91f82bf28
A new mobile dashboard is served on a second host port (8443) mapped to the same backend, so the recorder/supervisor is not duplicated. main.py detects the mobile port via the Host header and serves mobile.html instead of the desktop index. The mobile SPA has two modes: Live (one camera with a selector) and Archive (camera + date + segment list). Video is delivered as server-side H.264 (live.mp4 / play.mp4 transcode) so a plain <video> plays on any phone. Bottom tab bar, dark theme, safe-area aware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
5.7 KiB
Python
153 lines
5.7 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")
|
|
# Мобильный 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) # потоки (вкл. <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(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")
|