Files
CoRE.Vision/backend/app/main.py
T
git_admin c91f82bf28 mobile: add a phone dashboard on a second port (8443) — Live + Archive
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>
2026-06-01 23:36:50 +05:00

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