Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a8eddf1d8 | |||
| 74236fdbb0 | |||
| 0216d2c1f1 | |||
| c2b698e1f4 | |||
| ffddaaa50f | |||
| dcc4cc046f | |||
| ab817e0d31 | |||
| 02efe2ab4b | |||
| 5b8f11dbb6 | |||
| c6cdcf55b2 | |||
| b29b808c6d | |||
| a519b78eb2 | |||
| e2e03054ac | |||
| b07bf2d874 | |||
| ec41267444 | |||
| 1e6974acb8 | |||
| 20dfed0bae | |||
| abc726b5a7 | |||
| 421f873160 | |||
| 8e4844111b | |||
| a64749ccc8 | |||
| 24155a8492 | |||
| 7060f26e86 | |||
| 81a5b58eb9 | |||
| 90e216e30b | |||
| 650a531f21 | |||
| 44f5aa920f | |||
| c876a46e61 | |||
| 5828873910 | |||
| fcddc0c470 | |||
| d1ef54af27 | |||
| 00cfca1a57 |
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -12,7 +13,7 @@ from ..config import Camera, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services import go2rtc
|
||||
from ..transcode import (encode_args, norm_profile, norm_bitrate, norm_preset,
|
||||
norm_keyint, norm_codec)
|
||||
norm_keyint, norm_codec, norm_rc, norm_qp)
|
||||
|
||||
router = APIRouter(prefix="/api/cameras", tags=["cameras"])
|
||||
|
||||
@@ -32,11 +33,13 @@ def _camera_from_body(body: dict, cam_id: str) -> Camera:
|
||||
id=cam_id,
|
||||
name=(body.get("name") or cam_id).strip(),
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
record=bool(body.get("record", True)),
|
||||
ip=ip,
|
||||
user=user,
|
||||
password=password,
|
||||
main_path=(body.get("main_path") or DEFAULT_MAIN).strip(),
|
||||
sub_path=sub,
|
||||
storage_id=(body.get("storage_id") or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
@@ -49,11 +52,13 @@ def _camera_payload(cam_id: str) -> dict:
|
||||
"id": cam.id,
|
||||
"name": cam.name,
|
||||
"enabled": cam.enabled,
|
||||
"record": cam.record,
|
||||
"ip": cam.ip,
|
||||
"user": cam.user,
|
||||
"password": cam.password,
|
||||
"main_path": cam.main_path,
|
||||
"sub_path": cam.sub_path,
|
||||
"storage_id": cam.storage_id,
|
||||
"has_sub": has_sub,
|
||||
"status": status.to_dict() if status else None,
|
||||
# live-источники go2rtc: raw (как есть) и h264 (перекодирование по опции)
|
||||
@@ -86,6 +91,8 @@ async def camera_live_transcoded(
|
||||
preset: str = Query(None),
|
||||
keyint: str = Query(None),
|
||||
codec: str = Query(None),
|
||||
rc: str = Query(None),
|
||||
qp: str = Query(None),
|
||||
stream: str = Query("sub"),
|
||||
):
|
||||
"""Live с перекодированием в H.264 (fragmented mp4) под профиль/битрейт клиента.
|
||||
@@ -103,7 +110,8 @@ async def camera_live_transcoded(
|
||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||
"-rtsp_transport", "tcp", "-i", src,
|
||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
||||
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
|
||||
low_latency=True, preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
|
||||
rc=norm_rc(rc), qp=norm_qp(qp)),
|
||||
"-an",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f", "mp4", "pipe:1",
|
||||
@@ -251,6 +259,7 @@ async def create_camera(request: Request) -> dict:
|
||||
runtime.config.cameras.append(cam)
|
||||
save_config(runtime.config)
|
||||
runtime.supervisor.add_camera(cam)
|
||||
if cam.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
@@ -266,7 +275,10 @@ async def update_camera(cam_id: str, request: Request) -> dict:
|
||||
runtime.config.cameras[idx] = cam
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.replace_camera(cam)
|
||||
if cam.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, cam)
|
||||
else:
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
return _camera_payload(cam_id)
|
||||
|
||||
|
||||
@@ -295,3 +307,38 @@ async def clear_cameras(request: Request) -> dict:
|
||||
await runtime.supervisor.remove_camera(cam_id)
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
return {"ok": True, "removed": len(ids)}
|
||||
|
||||
|
||||
@router.post("/{cam_id}/toggle")
|
||||
async def toggle_camera(cam_id: str, request: Request) -> dict:
|
||||
"""Тумблеры камеры: enabled (вкл/выкл камеру) и/или record (REC — запись на диск).
|
||||
|
||||
enabled=False — камера выключена полностью: убираем go2rtc-потоки → нет live и записи
|
||||
(камера остаётся зарегистрированной). record управляет только записью. go2rtc трогаем
|
||||
лишь при смене enabled, поэтому переключение REC не мигает live."""
|
||||
require_role(request, "admin")
|
||||
cam = runtime.config.camera(cam_id)
|
||||
if not cam:
|
||||
raise HTTPException(404, "camera not found")
|
||||
body = await request.json()
|
||||
kw = {}
|
||||
if "enabled" in body:
|
||||
kw["enabled"] = bool(body["enabled"])
|
||||
if "record" in body:
|
||||
kw["record"] = bool(body["record"])
|
||||
if "storage_id" in body: # смена хранилища записи камеры
|
||||
kw["storage_id"] = (body["storage_id"] or "").strip()
|
||||
if not kw:
|
||||
raise HTTPException(400, "ожидается enabled, record и/или storage_id")
|
||||
enabled_changed = "enabled" in kw and kw["enabled"] != cam.enabled
|
||||
new = replace(cam, **kw)
|
||||
idx = next(i for i, c in enumerate(runtime.config.cameras) if c.id == cam_id)
|
||||
runtime.config.cameras[idx] = new
|
||||
save_config(runtime.config)
|
||||
if enabled_changed: # потоки go2rtc только при смене вкл/выкл камеры
|
||||
if new.enabled:
|
||||
await go2rtc.add_camera_streams(runtime.config, new)
|
||||
else:
|
||||
await go2rtc.remove_camera_streams(runtime.config, cam_id)
|
||||
await runtime.supervisor.replace_camera(new) # рекордер: репойнт на новое хранилище / enabled AND record
|
||||
return {"ok": True, "enabled": new.enabled, "record": new.record, "storage_id": new.storage_id}
|
||||
|
||||
@@ -9,7 +9,8 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from ..runtime import runtime
|
||||
from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile,
|
||||
norm_bitrate, norm_preset, norm_keyint, norm_codec)
|
||||
norm_bitrate, norm_preset, norm_keyint, norm_codec,
|
||||
norm_rc, norm_qp)
|
||||
|
||||
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
|
||||
|
||||
@@ -53,6 +54,8 @@ async def recording_transcoded(
|
||||
preset: str = Query(None),
|
||||
keyint: str = Query(None),
|
||||
codec: str = Query(None),
|
||||
rc: str = Query(None),
|
||||
qp: str = Query(None),
|
||||
start: float = Query(0.0),
|
||||
):
|
||||
"""Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC.
|
||||
@@ -71,7 +74,8 @@ async def recording_transcoded(
|
||||
*(["-ss", f"{start:.3f}"] if start and start > 0 else []),
|
||||
"-i", rec.path,
|
||||
*encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate),
|
||||
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint)),
|
||||
preset=norm_preset(preset), keyint_sec=norm_keyint(keyint),
|
||||
rc=norm_rc(rc), qp=norm_qp(qp)),
|
||||
"-an",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f", "mp4", "pipe:1",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""REST: управление хранилищами записи — список, CRUD, выбор активного, политика.
|
||||
|
||||
Несколько хранилищ (local-папка или NAS/SMB) + одно активное (куда пишем новые записи).
|
||||
Квота — на каждом хранилище. NAS монтируется на ХОСТЕ (backend не монтирует сам), здесь
|
||||
лишь хранятся параметры и генерируется готовая команда mount/fstab."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from ..auth import require_role
|
||||
from ..config import StorageItem, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services.system import is_mounted, storage_disk
|
||||
|
||||
router = APIRouter(prefix="/api/storages", tags=["storages"])
|
||||
|
||||
ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
STORAGES_ROOT = "/storages" # общий bind-mount корень для доп. хранилищ
|
||||
HOST_STORAGES_ROOT = "/opt/nvr/storages" # он же на хосте (для команды монтирования NAS)
|
||||
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────
|
||||
async def _item_payload(item: StorageItem, active: str) -> dict:
|
||||
nas = item.type == "nas"
|
||||
return {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"path": item.path,
|
||||
"quota_gb": item.quota_gb,
|
||||
"server": item.server,
|
||||
"share": item.share,
|
||||
"username": item.username,
|
||||
"password": item.password,
|
||||
"subpath": item.subpath,
|
||||
"enabled": item.enabled,
|
||||
"active": item.id == active,
|
||||
"mounted": is_mounted(item.path) if nas else True,
|
||||
"used_bytes": await runtime.db.total_size_under(item.path),
|
||||
"disk": storage_disk(item.path),
|
||||
}
|
||||
|
||||
|
||||
def _nas_mount_help(item: StorageItem) -> dict:
|
||||
"""Готовая команда монтирования SMB на хосте + строка fstab (монтаж — на хосте)."""
|
||||
host_path = f"{HOST_STORAGES_ROOT}/{item.id}"
|
||||
src = f"//{item.server}/{item.share}"
|
||||
if item.subpath:
|
||||
src += "/" + item.subpath.strip("/")
|
||||
cred = f"username={item.username},password={item.password}"
|
||||
opts = f"{cred},uid=0,gid=0,vers=3.0,iocharset=utf8"
|
||||
return {
|
||||
"host_path": host_path,
|
||||
"mount_cmd": f"sudo mkdir -p {host_path} && sudo mount -t cifs {src} {host_path} -o {opts}",
|
||||
"fstab": f"{src} {host_path} cifs {opts},_netdev,nofail 0 0",
|
||||
"note": "После монтирования на хосте выполните `docker compose up -d` (или включите "
|
||||
"rshared-проброс), чтобы том стал виден в контейнере.",
|
||||
}
|
||||
|
||||
|
||||
# ── список ──────────────────────────────────────────────────────
|
||||
@router.get("")
|
||||
async def list_storages() -> dict:
|
||||
cfg = runtime.config
|
||||
active = cfg.storage.active
|
||||
items = [await _item_payload(s, active) for s in cfg.storage.items]
|
||||
return {
|
||||
"storages": items,
|
||||
"active": active,
|
||||
"retention_days": cfg.storage.retention_days,
|
||||
"segment_seconds": cfg.storage.segment_seconds,
|
||||
}
|
||||
|
||||
|
||||
# ── политика (глобально): срок хранения и длина сегмента ─────────
|
||||
@router.post("/policy")
|
||||
async def update_policy(request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
st = runtime.config.storage
|
||||
kw: dict = {}
|
||||
seg_changed = False
|
||||
if "segment_seconds" in body:
|
||||
try:
|
||||
seg = int(body["segment_seconds"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "segment_seconds должен быть числом")
|
||||
if seg not in ALLOWED_SEGMENTS:
|
||||
raise HTTPException(400, "допустимо 60 / 120 / 300 / 600 / 900 секунд")
|
||||
kw["segment_seconds"] = seg
|
||||
seg_changed = seg != st.segment_seconds
|
||||
if "retention_days" in body:
|
||||
try:
|
||||
rd = int(body["retention_days"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "retention_days должен быть числом")
|
||||
if rd < 0 or rd > 3650:
|
||||
raise HTTPException(400, "retention_days в диапазоне 0..3650")
|
||||
kw["retention_days"] = rd
|
||||
if not kw:
|
||||
raise HTTPException(400, "ожидается segment_seconds и/или retention_days")
|
||||
runtime.config.storage = replace(st, **kw)
|
||||
save_config(runtime.config)
|
||||
if seg_changed:
|
||||
await runtime.supervisor.restart_all()
|
||||
return {
|
||||
"ok": True,
|
||||
"segment_seconds": runtime.config.storage.segment_seconds,
|
||||
"retention_days": runtime.config.storage.retention_days,
|
||||
}
|
||||
|
||||
|
||||
# ── CRUD ────────────────────────────────────────────────────────
|
||||
@router.post("")
|
||||
async def create_storage(request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
sid = (body.get("id") or "").strip()
|
||||
if not ID_RE.match(sid):
|
||||
raise HTTPException(400, "id: разрешены латиница, цифры, _ и - (до 32 символов)")
|
||||
if runtime.config.storage_by_id(sid):
|
||||
raise HTTPException(409, "хранилище с таким id уже существует")
|
||||
stype = body.get("type", "local")
|
||||
if stype not in ("local", "nas"):
|
||||
raise HTTPException(400, "type: local | nas")
|
||||
name = (body.get("name") or sid).strip()
|
||||
quota = max(0, int(body.get("quota_gb") or 0))
|
||||
helper = None
|
||||
|
||||
if stype == "local":
|
||||
# путь = подпапка общего корня /storages/<id>; допускаем явный path (напр. /records)
|
||||
path = (body.get("path") or "").strip() or f"{STORAGES_ROOT}/{sid}"
|
||||
item = StorageItem(id=sid, name=name, type="local", path=path, quota_gb=quota)
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HTTPException(400, f"не удалось создать папку {path}: {exc}")
|
||||
else:
|
||||
server = (body.get("server") or "").strip()
|
||||
share = (body.get("share") or "").strip()
|
||||
if not server or not share:
|
||||
raise HTTPException(400, "для NAS обязательны server и share")
|
||||
path = f"{STORAGES_ROOT}/{sid}"
|
||||
item = StorageItem(
|
||||
id=sid, name=name, type="nas", path=path, quota_gb=quota,
|
||||
server=server, share=share,
|
||||
username=(body.get("username") or "").strip(),
|
||||
password=body.get("password") or "",
|
||||
subpath=(body.get("subpath") or "").strip(),
|
||||
)
|
||||
# создаём точку монтирования на хосте (через bind-mount /storages) — писать туда
|
||||
# рекордер начнёт только когда SMB реально смонтируют (guard по os.path.ismount)
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
helper = _nas_mount_help(item)
|
||||
|
||||
new_items = runtime.config.storage.items + (item,)
|
||||
runtime.config.storage = replace(runtime.config.storage, items=new_items)
|
||||
save_config(runtime.config)
|
||||
out = await _item_payload(item, runtime.config.storage.active)
|
||||
if helper:
|
||||
out["mount_help"] = helper
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/{sid}")
|
||||
async def update_storage(sid: str, request: Request) -> dict:
|
||||
require_role(request, "admin")
|
||||
item = runtime.config.storage_by_id(sid)
|
||||
if not item:
|
||||
raise HTTPException(404, "storage not found")
|
||||
body = await request.json()
|
||||
kw: dict = {}
|
||||
if "name" in body:
|
||||
kw["name"] = (body.get("name") or item.name).strip()
|
||||
if "quota_gb" in body:
|
||||
kw["quota_gb"] = max(0, int(body.get("quota_gb") or 0))
|
||||
if "enabled" in body:
|
||||
kw["enabled"] = bool(body["enabled"])
|
||||
for f in ("server", "share", "username", "subpath"): # метаданные NAS
|
||||
if f in body:
|
||||
kw[f] = (body.get(f) or "").strip()
|
||||
if "password" in body:
|
||||
kw["password"] = body.get("password") or ""
|
||||
if not kw:
|
||||
raise HTTPException(400, "нет полей для изменения")
|
||||
# id/type/path неизменны (привязаны к записям на диске → не ломаем архив)
|
||||
new = replace(item, **kw)
|
||||
items = list(runtime.config.storage.items)
|
||||
idx = next(i for i, s in enumerate(items) if s.id == sid)
|
||||
items[idx] = new
|
||||
runtime.config.storage = replace(runtime.config.storage, items=tuple(items))
|
||||
save_config(runtime.config)
|
||||
# quota применится при следующем проходе retention; path не менялся → рекордеры не трогаем
|
||||
out = await _item_payload(new, runtime.config.storage.active)
|
||||
if new.type == "nas":
|
||||
out["mount_help"] = _nas_mount_help(new)
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{sid}")
|
||||
async def delete_storage(sid: str, request: Request) -> dict:
|
||||
"""Убирает хранилище из списка. Файлы и записи в БД НЕ трогаются (архив сохраняется)."""
|
||||
require_role(request, "admin")
|
||||
if not runtime.config.storage_by_id(sid):
|
||||
raise HTTPException(404, "storage not found")
|
||||
if sid == runtime.config.storage.active:
|
||||
raise HTTPException(409, "нельзя удалить активное хранилище — сначала выберите другое")
|
||||
if len(runtime.config.storage.items) <= 1:
|
||||
raise HTTPException(409, "нельзя удалить последнее хранилище")
|
||||
items = tuple(s for s in runtime.config.storage.items if s.id != sid)
|
||||
runtime.config.storage = replace(runtime.config.storage, items=items)
|
||||
save_config(runtime.config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{sid}/activate")
|
||||
async def activate_storage(sid: str, request: Request) -> dict:
|
||||
"""Делает хранилище активным (новые записи идут туда). Перезапускает рекордеры."""
|
||||
require_role(request, "admin")
|
||||
item = runtime.config.storage_by_id(sid)
|
||||
if not item:
|
||||
raise HTTPException(404, "storage not found")
|
||||
runtime.config.storage = replace(runtime.config.storage, active=sid)
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.restart_all() # репойнт рекордеров на новый путь
|
||||
mounted = is_mounted(item.path) if item.type == "nas" else True
|
||||
return {"ok": True, "active": sid, "mounted": mounted}
|
||||
@@ -3,16 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..auth import require_role
|
||||
from ..config import Storage, save_config
|
||||
from ..runtime import runtime
|
||||
from ..services.system import system_stats
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
ALLOWED_SEGMENTS = (60, 120, 300, 600, 900) # 1 / 2 / 5 / 10 / 15 минут
|
||||
VERSION_FILE = os.environ.get("NVR_VERSION_FILE", "/app/VERSION")
|
||||
|
||||
|
||||
@@ -36,25 +33,3 @@ async def get_system() -> dict:
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/storage")
|
||||
async def update_storage(request: Request) -> dict:
|
||||
"""Смена размера сегмента записи (1/5/10/15 мин). Перезапускает рекордеры."""
|
||||
require_role(request, "admin")
|
||||
body = await request.json()
|
||||
try:
|
||||
seg = int(body.get("segment_seconds"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "segment_seconds должен быть числом")
|
||||
if seg not in ALLOWED_SEGMENTS:
|
||||
raise HTTPException(400, "допустимо 60 / 300 / 600 / 900 секунд")
|
||||
|
||||
st = runtime.config.storage
|
||||
runtime.config.storage = Storage(
|
||||
path=st.path, quota_gb=st.quota_gb,
|
||||
retention_days=st.retention_days, segment_seconds=seg,
|
||||
)
|
||||
save_config(runtime.config)
|
||||
await runtime.supervisor.restart_all()
|
||||
return {"segment_seconds": seg}
|
||||
|
||||
+131
-11
@@ -18,6 +18,8 @@ class Camera:
|
||||
password: str
|
||||
main_path: str
|
||||
sub_path: str | None = None
|
||||
record: bool = True # REC: писать на диск. enabled — камера вообще включена (live+запись)
|
||||
storage_id: str = "" # в какое хранилище писать ("" = активное/по умолчанию)
|
||||
|
||||
def rtsp_url(self, path: str) -> str:
|
||||
return f"rtsp://{self.user}:{self.password}@{self.ip}:554{path}"
|
||||
@@ -28,12 +30,45 @@ class Camera:
|
||||
return self.rtsp_url(self.main_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageItem:
|
||||
"""Одно хранилище записи: локальная папка или сетевой диск (SMB/NAS).
|
||||
|
||||
`path` — путь ВНУТРИ контейнера, куда пишутся сегменты. `quota_gb` — лимит
|
||||
размера ЭТОГО хранилища (0 = без лимита). Для NAS поля server/share/username/
|
||||
password/subpath — метаданные (монтаж делается на хосте, backend ими не
|
||||
монтирует, только показывает статус и генерирует команду mount).
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
type: str # "local" | "nas"
|
||||
path: str
|
||||
quota_gb: int = 0
|
||||
server: str = ""
|
||||
share: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
subpath: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storage:
|
||||
path: str = "/records"
|
||||
quota_gb: int = 50
|
||||
"""Глобальная политика записи + список хранилищ и id активного.
|
||||
|
||||
Квота — на каждом StorageItem (не здесь). retention_days/segment_seconds —
|
||||
глобальные: срок хранения по возрасту и длина сегмента для всех хранилищ.
|
||||
"""
|
||||
active: str = "local"
|
||||
retention_days: int = 7
|
||||
segment_seconds: int = 600
|
||||
items: tuple[StorageItem, ...] = ()
|
||||
|
||||
|
||||
# Дефолтное хранилище (когда конфиг пуст / битый) — совпадает с историческим /records.
|
||||
_DEFAULT_STORAGE = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local", path="/records", quota_gb=30,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -54,6 +89,21 @@ class Config:
|
||||
def enabled_cameras(self) -> list[Camera]:
|
||||
return [c for c in self.cameras if c.enabled]
|
||||
|
||||
# ── хранилища ───────────────────────────────────────────────
|
||||
def storage_by_id(self, sid: str) -> StorageItem | None:
|
||||
return next((s for s in self.storage.items if s.id == sid), None)
|
||||
|
||||
def active_storage(self) -> StorageItem:
|
||||
"""Хранилище, куда пишутся новые записи (fallback — первый/дефолтный)."""
|
||||
return (
|
||||
self.storage_by_id(self.storage.active)
|
||||
or (self.storage.items[0] if self.storage.items else _DEFAULT_STORAGE)
|
||||
)
|
||||
|
||||
def storage_paths(self) -> list[str]:
|
||||
"""Пути всех хранилищ — индексатор сканирует их все (старые записи не теряются)."""
|
||||
return [s.path for s in self.storage.items] or [_DEFAULT_STORAGE.path]
|
||||
|
||||
|
||||
def load_config(path: str | None = None) -> Config:
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
@@ -68,22 +118,75 @@ def load_config(path: str | None = None) -> Config:
|
||||
except (OSError, yaml.YAMLError):
|
||||
raw = {}
|
||||
|
||||
storage = Storage(**(raw.get("storage") or {}))
|
||||
storage = _storage_from_raw(raw.get("storage") or {})
|
||||
live = Live(**(raw.get("live") or {}))
|
||||
cameras = [
|
||||
Camera(
|
||||
cameras = [_camera_from_raw(c) for c in (raw.get("cameras") or [])]
|
||||
return Config(storage=storage, live=live, cameras=cameras)
|
||||
|
||||
|
||||
def _camera_from_raw(c: dict) -> Camera:
|
||||
# Миграция со старой схемы: раньше `enabled` означал «писать на диск» (live был всегда),
|
||||
# отдельного «камера выключена» не существовало. Старый файл (без ключа record) трактуем
|
||||
# так: запись = старый enabled, а сама камера включена. Новый файл (с record) — как есть.
|
||||
if "record" in c:
|
||||
enabled, record = bool(c.get("enabled", True)), bool(c["record"])
|
||||
else:
|
||||
enabled, record = True, bool(c.get("enabled", True))
|
||||
return Camera(
|
||||
id=c["id"],
|
||||
name=c.get("name", c["id"]),
|
||||
enabled=c.get("enabled", True),
|
||||
enabled=enabled,
|
||||
record=record,
|
||||
ip=c["ip"],
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
main_path=c["main_path"],
|
||||
sub_path=c.get("sub_path"),
|
||||
storage_id=c.get("storage_id", "") or "",
|
||||
)
|
||||
|
||||
|
||||
def _storage_item_from_raw(s: dict) -> StorageItem:
|
||||
return StorageItem(
|
||||
id=s["id"],
|
||||
name=s.get("name", s["id"]),
|
||||
type=s.get("type", "local"),
|
||||
path=s["path"],
|
||||
quota_gb=int(s.get("quota_gb", 0) or 0),
|
||||
server=s.get("server", "") or "",
|
||||
share=s.get("share", "") or "",
|
||||
username=s.get("username", "") or "",
|
||||
password=s.get("password", "") or "",
|
||||
subpath=s.get("subpath", "") or "",
|
||||
enabled=bool(s.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
def _storage_from_raw(raw: dict) -> Storage:
|
||||
# Новая схема: есть список items.
|
||||
if raw.get("items"):
|
||||
items = tuple(_storage_item_from_raw(s) for s in raw["items"])
|
||||
active = raw.get("active") or (items[0].id if items else "local")
|
||||
return Storage(
|
||||
active=active,
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=items,
|
||||
)
|
||||
# Миграция со старой одиночной схемы {path, quota_gb, retention_days, segment_seconds}:
|
||||
# один локальный item, старый глобальный quota → квота этого хранилища.
|
||||
legacy_path = raw.get("path") or "/records"
|
||||
legacy_quota = int(raw.get("quota_gb", 30) or 30)
|
||||
local = StorageItem(
|
||||
id="local", name="Локальное хранилище", type="local",
|
||||
path=legacy_path, quota_gb=legacy_quota,
|
||||
)
|
||||
return Storage(
|
||||
active="local",
|
||||
retention_days=int(raw.get("retention_days", 7)),
|
||||
segment_seconds=int(raw.get("segment_seconds", 600)),
|
||||
items=(local,),
|
||||
)
|
||||
for c in (raw.get("cameras") or [])
|
||||
]
|
||||
return Config(storage=storage, live=live, cameras=cameras)
|
||||
|
||||
|
||||
def camera_to_yaml(c: Camera) -> dict:
|
||||
@@ -91,23 +194,40 @@ def camera_to_yaml(c: Camera) -> dict:
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"enabled": c.enabled,
|
||||
"record": c.record,
|
||||
"ip": c.ip,
|
||||
"user": c.user,
|
||||
"password": c.password,
|
||||
"main_path": c.main_path,
|
||||
"sub_path": c.sub_path,
|
||||
"storage_id": c.storage_id,
|
||||
}
|
||||
|
||||
|
||||
def storage_item_to_yaml(s: StorageItem) -> dict:
|
||||
d = {
|
||||
"id": s.id, "name": s.name, "type": s.type,
|
||||
"path": s.path, "quota_gb": s.quota_gb,
|
||||
}
|
||||
if s.type == "nas":
|
||||
d.update({
|
||||
"server": s.server, "share": s.share, "username": s.username,
|
||||
"password": s.password, "subpath": s.subpath,
|
||||
})
|
||||
if not s.enabled:
|
||||
d["enabled"] = False
|
||||
return d
|
||||
|
||||
|
||||
def save_config(config: Config, path: str | None = None) -> None:
|
||||
"""Сохраняет конфиг обратно в cameras.yaml (комментарии теряются)."""
|
||||
path = path or os.environ.get("NVR_CONFIG", "cameras.yaml")
|
||||
data = {
|
||||
"storage": {
|
||||
"path": config.storage.path,
|
||||
"quota_gb": config.storage.quota_gb,
|
||||
"active": config.storage.active,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
"items": [storage_item_to_yaml(s) for s in config.storage.items],
|
||||
},
|
||||
"live": {"go2rtc_url": config.live.go2rtc_url},
|
||||
"cameras": [camera_to_yaml(c) for c in config.cameras],
|
||||
|
||||
@@ -8,6 +8,14 @@ import aiosqlite
|
||||
|
||||
from .models import Recording
|
||||
|
||||
|
||||
def _like_prefix(prefix: str) -> str:
|
||||
"""LIKE-шаблон для путей внутри хранилища: '<path>/%' с экранированием %, _, \\."""
|
||||
p = prefix.rstrip("/") + "/"
|
||||
p = p.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return p + "%"
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -105,6 +113,24 @@ class Database:
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
# ── per-storage (по префиксу пути) — для квоты на каждое хранилище ──
|
||||
async def total_size_under(self, prefix: str) -> int:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM recordings WHERE path LIKE ? ESCAPE '\\'",
|
||||
(_like_prefix(prefix),),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
async def oldest_recordings_under(self, prefix: str, limit: int = 50) -> list[Recording]:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT * FROM recordings WHERE path LIKE ? ESCAPE '\\' "
|
||||
"ORDER BY started_at ASC LIMIT ?",
|
||||
(_like_prefix(prefix), limit),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Recording(**dict(r)) for r in rows]
|
||||
|
||||
async def recordings_before(self, ts: int) -> list[Recording]:
|
||||
cur = await self.conn.execute(
|
||||
"SELECT * FROM recordings WHERE started_at < ? ORDER BY started_at ASC", (ts,)
|
||||
|
||||
+12
-5
@@ -17,7 +17,7 @@ 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 import cameras, recordings, storages, system, ws
|
||||
from .api.ws import ConnectionManager
|
||||
from .services import go2rtc
|
||||
|
||||
@@ -46,25 +46,31 @@ async def lifespan(app: FastAPI):
|
||||
runtime.indexer = Indexer(runtime.config, runtime.db)
|
||||
runtime.retention = Retention(runtime.config, runtime.db)
|
||||
|
||||
os.makedirs(runtime.config.storage.path, exist_ok=True)
|
||||
# каталоги локальных хранилищ; 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()
|
||||
log.info("NVR запущен: камер=%d, хранилище=%s",
|
||||
len(runtime.config.cameras), runtime.config.storage.path)
|
||||
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.Vision", version="1.0", lifespan=lifespan)
|
||||
app = FastAPI(title="CoRE.Vizion+", version="1.0", lifespan=lifespan)
|
||||
|
||||
# публичные пути (без авторизации)
|
||||
PUBLIC_PATHS = {"/login", "/api/login", "/api/health"}
|
||||
@@ -96,6 +102,7 @@ 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)
|
||||
|
||||
|
||||
@@ -5,19 +5,21 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from ..config import Camera, Storage
|
||||
from ..config import Camera
|
||||
|
||||
|
||||
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
|
||||
def record_args(cam: Camera, out_root: str, segment_seconds: int,
|
||||
input_url: str | None = None) -> list[str]:
|
||||
"""ffmpeg для записи основного потока сегментами mp4 копированием.
|
||||
|
||||
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
|
||||
out_root — корень активного хранилища (внутри пишем подкаталог камеры).
|
||||
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
|
||||
"""
|
||||
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
|
||||
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
|
||||
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
|
||||
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
# Подпапка даты: <root>/<cam>/<YYYY-MM-DD>/<YYYY-MM-DD_HH-MM-SS>.mp4.
|
||||
# Этот ffmpeg-билд НЕ знает -strftime_mkdir, поэтому каталоги даты (сегодня+завтра)
|
||||
# заранее создаёт recorder_task при старте и индексатор каждые 30с (переживает полночь).
|
||||
out_template = os.path.join(out_root, cam.id, "%Y-%m-%d", "%Y-%m-%d_%H-%M-%S.mp4")
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
@@ -29,7 +31,7 @@ def record_args(cam: Camera, storage: Storage, input_url: str | None = None) ->
|
||||
"-an", # звук не пишем (камеры обычно без него)
|
||||
"-c:v", "copy",
|
||||
"-f", "segment",
|
||||
"-segment_time", str(storage.segment_seconds),
|
||||
"-segment_time", str(segment_seconds),
|
||||
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
|
||||
"-segment_clocktime_offset", "0",
|
||||
"-segment_format", "mp4",
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config import Config
|
||||
from ..db import Database
|
||||
@@ -32,7 +32,11 @@ def _parse_started_at(path: str) -> int:
|
||||
|
||||
|
||||
def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
|
||||
"""Возвращает {camera_id: [пути mp4, отсортированы]}.
|
||||
|
||||
Файлы лежат в подпапке даты <cam>/<YYYY-MM-DD>/*.mp4; старые «плоские»
|
||||
<cam>/*.mp4 тоже подхватываем (обратная совместимость). Сортировка по полному
|
||||
пути == хронологическая (ISO-даты и в каталоге, и в имени)."""
|
||||
result: dict[str, list[str]] = {}
|
||||
if not os.path.isdir(root):
|
||||
return result
|
||||
@@ -40,12 +44,19 @@ def _scan_segments(root: str) -> dict[str, list[str]]:
|
||||
cam_dir = os.path.join(root, cam_id)
|
||||
if not os.path.isdir(cam_dir):
|
||||
continue
|
||||
files = [
|
||||
os.path.join(cam_dir, fn)
|
||||
for fn in os.listdir(cam_dir)
|
||||
if fn.endswith(".mp4")
|
||||
]
|
||||
files.sort()
|
||||
files: list[str] = []
|
||||
for entry in os.listdir(cam_dir):
|
||||
p = os.path.join(cam_dir, entry)
|
||||
if entry.endswith(".mp4") and os.path.isfile(p):
|
||||
files.append(p) # старый плоский формат
|
||||
elif os.path.isdir(p): # подпапка даты
|
||||
for fn in os.listdir(p):
|
||||
if fn.endswith(".mp4"):
|
||||
files.append(os.path.join(p, fn))
|
||||
# сортируем по ИМЕНИ файла (YYYY-MM-DD_HH-MM-SS) — хронологически независимо от того,
|
||||
# лежит файл плоско или в подпапке даты (полный путь сортировал бы '/' < '_' неверно,
|
||||
# из-за чего пишущийся сейчас файл в подпапке не оказывался бы последним)
|
||||
files.sort(key=os.path.basename)
|
||||
if files:
|
||||
result[cam_id] = files
|
||||
return result
|
||||
@@ -74,20 +85,42 @@ class Indexer:
|
||||
async def _loop(self) -> None:
|
||||
while not self._stopping:
|
||||
try:
|
||||
self._ensure_date_dirs()
|
||||
await self.scan_once()
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("indexer scan failed")
|
||||
await asyncio.sleep(SCAN_INTERVAL)
|
||||
|
||||
def _ensure_date_dirs(self) -> None:
|
||||
"""Создаёт каталоги даты (сегодня+завтра) для пишущих камер заранее: ffmpeg
|
||||
не умеет -strftime_mkdir, без готового каталога запись встала бы в полночь."""
|
||||
today = datetime.now()
|
||||
days = [today.strftime("%Y-%m-%d"), (today + timedelta(days=1)).strftime("%Y-%m-%d")]
|
||||
for cam in self.config.cameras:
|
||||
if not (cam.enabled and cam.record):
|
||||
continue
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
if store.type == "nas" and not os.path.ismount(store.path):
|
||||
continue
|
||||
for d in days:
|
||||
try:
|
||||
os.makedirs(os.path.join(store.path, cam.id, d), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def scan_once(self) -> int:
|
||||
root = self.config.storage.path
|
||||
by_cam = _scan_segments(root)
|
||||
active_path = self.config.active_storage().path
|
||||
known = await self.db.known_paths()
|
||||
added = 0
|
||||
|
||||
# сканируем ВСЕ хранилища: после смены активного старые записи не теряются
|
||||
for root in self.config.storage_paths():
|
||||
by_cam = _scan_segments(root)
|
||||
is_active = (root == active_path)
|
||||
for cam_id, files in by_cam.items():
|
||||
# последний файл может писаться прямо сейчас — не индексируем
|
||||
complete = files[:-1] if len(files) > 1 else []
|
||||
# в активном хранилище последний файл ещё пишется — пропускаем;
|
||||
# в неактивных все сегменты завершены, индексируем целиком.
|
||||
complete = files[:-1] if is_active else files
|
||||
for path in complete:
|
||||
if path in known:
|
||||
continue
|
||||
|
||||
@@ -6,8 +6,9 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..config import Camera, Storage
|
||||
from ..config import Camera, StorageItem
|
||||
from ..models import CameraState, CameraStatus
|
||||
from .ffmpeg import record_args
|
||||
|
||||
@@ -21,9 +22,11 @@ MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => сч
|
||||
class RecorderTask:
|
||||
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
|
||||
|
||||
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
|
||||
def __init__(self, cam: Camera, store: StorageItem, segment_seconds: int,
|
||||
on_status=None, input_url: str | None = None):
|
||||
self.cam = cam
|
||||
self.storage = storage
|
||||
self.store = store # активное хранилище (куда пишем)
|
||||
self.segment_seconds = segment_seconds
|
||||
self.input_url = input_url # источник записи (рестрим go2rtc)
|
||||
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
|
||||
self.status = CameraStatus(
|
||||
@@ -69,11 +72,32 @@ class RecorderTask:
|
||||
# ── основной цикл ───────────────────────────────────────────
|
||||
async def _run(self) -> None:
|
||||
backoff = BACKOFF_START
|
||||
os.makedirs(os.path.join(self.storage.path, self.cam.id), exist_ok=True)
|
||||
|
||||
while not self._stopping:
|
||||
# NAS: пишем только если том реально смонтирован на хосте. Иначе запись
|
||||
# ушла бы в overlay контейнера (потеря при рестарте) — ждём монтаж, каталог
|
||||
# тоже не создаём.
|
||||
if self.store.type == "nas" and not os.path.ismount(self.store.path):
|
||||
self._set_state(CameraState.RETRYING, "NAS не смонтирован")
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
try:
|
||||
# подпапки даты <cam>/<YYYY-MM-DD> — сегодня и завтра (ffmpeg их не создаёт);
|
||||
# индексатор продолжает поддерживать их каждые 30с (переживает полночь)
|
||||
base = os.path.join(self.store.path, self.cam.id)
|
||||
now = datetime.now()
|
||||
for d in (now, now + timedelta(days=1)):
|
||||
os.makedirs(os.path.join(base, d.strftime("%Y-%m-%d")), exist_ok=True)
|
||||
except OSError as exc:
|
||||
self._set_state(CameraState.RETRYING, f"mkdir: {exc}")
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
self._set_state(CameraState.STARTING)
|
||||
args = record_args(self.cam, self.storage, self.input_url)
|
||||
args = record_args(self.cam, self.store.path, self.segment_seconds, self.input_url)
|
||||
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
|
||||
@@ -21,16 +21,22 @@ class Supervisor:
|
||||
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
|
||||
return go2rtc.restream_main_url(self.config, cam.id)
|
||||
|
||||
def _new_task(self, cam: Camera) -> RecorderTask:
|
||||
# хранилище камеры: её storage_id, иначе активное (по умолчанию). Несуществующий
|
||||
# id (удалённое хранилище) → тоже откат на активное.
|
||||
store = self.config.storage_by_id(cam.storage_id) or self.config.active_storage()
|
||||
return RecorderTask(cam, store, self.config.storage.segment_seconds,
|
||||
on_status=self.on_status, input_url=self._input_url(cam))
|
||||
|
||||
def start_all(self) -> None:
|
||||
for cam in self.config.cameras:
|
||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||
input_url=self._input_url(cam))
|
||||
task = self._new_task(cam)
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
if cam.enabled and cam.record:
|
||||
task.start()
|
||||
log.info("recorder started: %s", cam.id)
|
||||
else:
|
||||
log.info("recorder skipped (disabled): %s", cam.id)
|
||||
log.info("recorder skipped (enabled=%s record=%s): %s", cam.enabled, cam.record, cam.id)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for task in self.tasks.values():
|
||||
@@ -57,10 +63,9 @@ class Supervisor:
|
||||
|
||||
def add_camera(self, cam: Camera) -> None:
|
||||
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
|
||||
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
|
||||
input_url=self._input_url(cam))
|
||||
task = self._new_task(cam)
|
||||
self.tasks[cam.id] = task
|
||||
if cam.enabled:
|
||||
if cam.enabled and cam.record:
|
||||
task.start()
|
||||
log.info("recorder added: %s", cam.id)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
||||
from .recorder.indexer import Indexer
|
||||
from .recorder.supervisor import Supervisor
|
||||
from .services.retention import Retention
|
||||
from .services.go2rtc import Reconciler
|
||||
from .api.ws import ConnectionManager
|
||||
|
||||
|
||||
@@ -18,6 +19,7 @@ class Runtime:
|
||||
supervisor: "Supervisor"
|
||||
indexer: "Indexer"
|
||||
retention: "Retention"
|
||||
reconciler: "Reconciler"
|
||||
ws: "ConnectionManager"
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ go2rtc-потоки на камеру:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
@@ -27,6 +28,7 @@ from ..config import Camera, Config
|
||||
log = logging.getLogger("nvr.go2rtc")
|
||||
|
||||
GO2RTC_YAML = os.environ.get("GO2RTC_CONFIG", "/app/go2rtc.yaml")
|
||||
RECONCILE_INTERVAL = 30 # сек — период сверки рантайма go2rtc с конфигом
|
||||
|
||||
|
||||
def _rtsp_host(config: Config) -> str:
|
||||
@@ -58,6 +60,8 @@ def _streams_for(cam: Camera) -> dict[str, str]:
|
||||
def build_streams(config: Config) -> dict:
|
||||
streams: dict = {}
|
||||
for c in config.cameras:
|
||||
if not c.enabled: # выключенная камера — без потоков (ни live, ни записи)
|
||||
continue
|
||||
for name, src in _streams_for(c).items():
|
||||
streams[name] = [src]
|
||||
return streams
|
||||
@@ -118,5 +122,80 @@ async def sync_all(config: Config) -> None:
|
||||
write_go2rtc_yaml(config)
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
for cam in config.cameras:
|
||||
if not cam.enabled:
|
||||
continue
|
||||
for name, src in _streams_for(cam).items():
|
||||
await _call("PUT", _put(base, name, src))
|
||||
|
||||
|
||||
def _get_stream_names(base: str) -> set[str]:
|
||||
"""Имена потоков, реально загруженных в рантайм go2rtc (/api/streams)."""
|
||||
req = urllib.request.Request(f"{base}/api/streams", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8") or "{}")
|
||||
return set(data.keys())
|
||||
|
||||
|
||||
async def reconcile(config: Config) -> int:
|
||||
"""Дотягивает в go2rtc недостающие потоки (самовосстановление).
|
||||
|
||||
go2rtc читает go2rtc.yaml только при старте, а рантайм-таблицу держит через API.
|
||||
Если её опустошить (DELETE при чистке камер, перезапуск go2rtc со старым/пустым
|
||||
файлом, молча провалившийся стартовый sync_all) — рестрим начинает отдавать 404,
|
||||
и live, и запись «слепнут». Здесь сверяем желаемый набор с /api/streams и PUT-им
|
||||
ТОЛЬКО отсутствующие — существующие не трогаем, чтобы не рвать активных
|
||||
producer'ов/consumer'ов. Возвращает число восстановленных потоков."""
|
||||
if not config.cameras:
|
||||
return 0
|
||||
base = config.live.go2rtc_url.rstrip("/")
|
||||
try:
|
||||
existing = await asyncio.to_thread(_get_stream_names, base)
|
||||
except Exception as exc: # noqa: BLE001 — go2rtc может быть недоступен/перезапускаться
|
||||
log.debug("go2rtc reconcile: /api/streams недоступен: %s", exc)
|
||||
return 0
|
||||
added = 0
|
||||
for cam in config.cameras:
|
||||
if not cam.enabled: # выключенные камеры в go2rtc не держим
|
||||
continue
|
||||
for name, src in _streams_for(cam).items():
|
||||
if name not in existing:
|
||||
await _call("PUT", _put(base, name, src))
|
||||
added += 1
|
||||
if added:
|
||||
log.info("go2rtc reconcile: восстановлено потоков %d", added)
|
||||
write_go2rtc_yaml(config) # и файл приводим в соответствие — для будущего рестарта
|
||||
return added
|
||||
|
||||
|
||||
class Reconciler:
|
||||
"""Фоновая сверка потоков go2rtc с конфигом каждые RECONCILE_INTERVAL секунд.
|
||||
|
||||
Держит ссылку на тот же объект Config, что и остальной рантайм (его .cameras
|
||||
мутируется in-place при add/clear), поэтому всегда видит актуальный список."""
|
||||
|
||||
def __init__(self, config: Config, interval: int = RECONCILE_INTERVAL):
|
||||
self.config = config
|
||||
self.interval = interval
|
||||
self._task: asyncio.Task | None = None
|
||||
self._stopping = False
|
||||
|
||||
def start(self) -> None:
|
||||
self._stopping = False
|
||||
self._task = asyncio.create_task(self._loop(), name="go2rtc-reconcile")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stopping = True
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while not self._stopping:
|
||||
await asyncio.sleep(self.interval)
|
||||
try:
|
||||
await reconcile(self.config)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("go2rtc reconcile failed")
|
||||
|
||||
@@ -61,19 +61,21 @@ class Retention:
|
||||
return len(old)
|
||||
|
||||
async def _enforce_quota(self) -> int:
|
||||
quota = self.config.storage.quota_gb * 1024 ** 3
|
||||
if not quota:
|
||||
return 0
|
||||
# квота — на КАЖДОЕ хранилище отдельно (0 = без лимита). Для каждого удаляем
|
||||
# самые старые записи ВНУТРИ него, пока его размер не уложится в его квоту.
|
||||
removed = 0
|
||||
# удаляем самые старые, пока суммарный размер не уложится в квоту
|
||||
while await self.db.total_size() > quota:
|
||||
batch = await self.db.oldest_recordings(limit=20)
|
||||
for item in self.config.storage.items:
|
||||
if not item.quota_gb:
|
||||
continue
|
||||
quota = item.quota_gb * 1024 ** 3
|
||||
while await self.db.total_size_under(item.path) > quota:
|
||||
batch = await self.db.oldest_recordings_under(item.path, limit=20)
|
||||
if not batch:
|
||||
break
|
||||
for rec in batch:
|
||||
await self._remove(rec.id, rec.path)
|
||||
removed += 1
|
||||
if await self.db.total_size() <= quota:
|
||||
if await self.db.total_size_under(item.path) <= quota:
|
||||
break
|
||||
return removed
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Системная статистика: диск хранилища, CPU, RAM, аптайм."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
@@ -11,19 +12,31 @@ from ..config import Config
|
||||
_START = time.time()
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
path = config.storage.path
|
||||
def storage_disk(path: str) -> dict:
|
||||
"""Использование диска, на котором лежит path (для одного хранилища)."""
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
disk = {
|
||||
return {
|
||||
"total_gb": round(usage.total / 1024 ** 3, 1),
|
||||
"used_gb": round(usage.used / 1024 ** 3, 1),
|
||||
"free_gb": round(usage.free / 1024 ** 3, 1),
|
||||
"percent": round(usage.used / usage.total * 100, 1) if usage.total else 0,
|
||||
}
|
||||
except OSError:
|
||||
disk = {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||
return {"total_gb": 0, "used_gb": 0, "free_gb": 0, "percent": 0}
|
||||
|
||||
|
||||
def is_mounted(path: str) -> bool:
|
||||
"""Точка монтирования? (для NAS — признак, что том реально подключён)."""
|
||||
try:
|
||||
return os.path.ismount(path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def system_stats(config: Config) -> dict:
|
||||
active = config.active_storage()
|
||||
path = active.path
|
||||
vm = psutil.virtual_memory()
|
||||
return {
|
||||
"uptime_s": int(time.time() - _START),
|
||||
@@ -32,8 +45,8 @@ def system_stats(config: Config) -> dict:
|
||||
"ram_used_gb": round(vm.used / 1024 ** 3, 1),
|
||||
"ram_total_gb": round(vm.total / 1024 ** 3, 1),
|
||||
"storage_path": path,
|
||||
"disk": disk,
|
||||
"quota_gb": config.storage.quota_gb,
|
||||
"disk": storage_disk(path),
|
||||
"quota_gb": active.quota_gb,
|
||||
"retention_days": config.storage.retention_days,
|
||||
"segment_seconds": config.storage.segment_seconds,
|
||||
}
|
||||
|
||||
+63
-22
@@ -1,17 +1,25 @@
|
||||
"""Общие параметры перекодирования H.264 (профиль, битрейт, пресет, GOP)."""
|
||||
"""Общие параметры перекодирования H.264/VP9: профиль, битрейт, пресет, GOP, режим RC."""
|
||||
from __future__ import annotations
|
||||
|
||||
H264_PROFILES = ("baseline", "main", "high")
|
||||
BITRATES = ("500k", "1000k", "2000k", "4000k", "8000k", "10000k", "16000k", "20000k", "24000k")
|
||||
PRESETS = ("ultrafast", "superfast")
|
||||
PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium")
|
||||
KEYINTS = (1, 2, 5) # интервал ключевых кадров, секунды
|
||||
CODECS = ("h264", "vp9")
|
||||
RC_MODES = ("vbr", "cbr", "qp") # контроль битрейта: переменный / постоянный / постоянный квантизатор
|
||||
|
||||
DEFAULT_PROFILE = "main"
|
||||
DEFAULT_BITRATE = "2000k"
|
||||
DEFAULT_PRESET = "superfast"
|
||||
DEFAULT_KEYINT = 2
|
||||
DEFAULT_CODEC = "h264"
|
||||
DEFAULT_RC = "vbr"
|
||||
DEFAULT_QP = 23
|
||||
QP_MIN, QP_MAX = 0, 51
|
||||
|
||||
# пресет x264 → cpu-used VP9 (больше число = быстрее кодирование, ниже качество)
|
||||
_VP9_CPU = {"ultrafast": "8", "superfast": "8", "veryfast": "7",
|
||||
"faster": "6", "fast": "5", "medium": "4"}
|
||||
|
||||
|
||||
def norm_profile(p: str | None) -> str:
|
||||
@@ -30,6 +38,10 @@ def norm_preset(p: str | None) -> str:
|
||||
return p if p in PRESETS else DEFAULT_PRESET
|
||||
|
||||
|
||||
def norm_rc(m: str | None) -> str:
|
||||
return m if m in RC_MODES else DEFAULT_RC
|
||||
|
||||
|
||||
def norm_keyint(k) -> int:
|
||||
try:
|
||||
k = int(k)
|
||||
@@ -38,51 +50,80 @@ def norm_keyint(k) -> int:
|
||||
return k if k in KEYINTS else DEFAULT_KEYINT
|
||||
|
||||
|
||||
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
"""Аргументы libx264: профиль, битрейт, пресет и интервал ключевых кадров.
|
||||
def norm_qp(q) -> int:
|
||||
try:
|
||||
q = int(q)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_QP
|
||||
return max(QP_MIN, min(QP_MAX, q))
|
||||
|
||||
Ключевые кадры расставляются по времени (через -force_key_frames), поэтому
|
||||
интервал в секундах соблюдается независимо от fps камеры."""
|
||||
bufsize = f"{int(bitrate[:-1]) * 2}k"
|
||||
|
||||
def _kbps(bitrate: str) -> int:
|
||||
return int(bitrate[:-1])
|
||||
|
||||
|
||||
def x264_args(profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы libx264: профиль, пресет, GOP и режим контроля битрейта.
|
||||
|
||||
rc: vbr — целевой битрейт с потолком (-b:v/-maxrate); cbr — постоянный
|
||||
битрейт (nal-hrd=cbr, min=max=b:v); qp — постоянный квантизатор (-qp, без
|
||||
битрейта). Ключевые кадры расставляются по времени (-force_key_frames)."""
|
||||
preset = norm_preset(preset)
|
||||
sec = norm_keyint(keyint_sec)
|
||||
rc = norm_rc(rc)
|
||||
args = [
|
||||
"-c:v", "libx264",
|
||||
"-preset", preset,
|
||||
"-profile:v", profile,
|
||||
"-b:v", bitrate,
|
||||
"-maxrate", bitrate,
|
||||
"-bufsize", bufsize,
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
if rc == "qp":
|
||||
args += ["-qp", str(norm_qp(qp))]
|
||||
elif rc == "cbr":
|
||||
kb = _kbps(bitrate)
|
||||
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate,
|
||||
"-bufsize", f"{kb}k", "-x264-params", "nal-hrd=cbr"]
|
||||
else: # vbr
|
||||
kb = _kbps(bitrate)
|
||||
args += ["-b:v", bitrate, "-maxrate", bitrate, "-bufsize", f"{kb * 2}k"]
|
||||
if low_latency:
|
||||
args += ["-tune", "zerolatency"]
|
||||
return args
|
||||
|
||||
|
||||
def vp9_args(bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
"""Аргументы кодировщика libvpx-VP9 (профиль H.264 неприменим)."""
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы libvpx-VP9 (профиль H.264 неприменим).
|
||||
|
||||
rc: vbr/cbr — по битрейту; qp — постоянное качество (-crf N -b:v 0, у VP9
|
||||
нет прямого -qp). cpu-used маппится из пресета. realtime для live."""
|
||||
sec = norm_keyint(keyint_sec)
|
||||
# быстрый пресет → выше cpu-used (быстрее, ниже качество); realtime для live
|
||||
cpu = "8" if norm_preset(preset) == "ultrafast" else "6"
|
||||
return [
|
||||
rc = norm_rc(rc)
|
||||
args = [
|
||||
"-c:v", "libvpx-vp9",
|
||||
"-b:v", bitrate,
|
||||
"-maxrate", bitrate,
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", cpu,
|
||||
"-cpu-used", _VP9_CPU.get(norm_preset(preset), "6"),
|
||||
"-row-mt", "1",
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{sec})",
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
if rc == "qp":
|
||||
args += ["-crf", str(norm_qp(qp)), "-b:v", "0"]
|
||||
elif rc == "cbr":
|
||||
args += ["-b:v", bitrate, "-minrate", bitrate, "-maxrate", bitrate]
|
||||
else: # vbr
|
||||
args += ["-b:v", bitrate, "-maxrate", bitrate]
|
||||
return args
|
||||
|
||||
|
||||
def encode_args(codec: str, profile: str, bitrate: str, low_latency: bool = False,
|
||||
preset: str | None = None, keyint_sec=None) -> list[str]:
|
||||
preset: str | None = None, keyint_sec=None,
|
||||
rc: str = DEFAULT_RC, qp=DEFAULT_QP) -> list[str]:
|
||||
"""Аргументы видеокодировщика под выбранный кодек (h264 | vp9)."""
|
||||
if norm_codec(codec) == "vp9":
|
||||
return vp9_args(bitrate, low_latency, preset, keyint_sec)
|
||||
return x264_args(profile, bitrate, low_latency, preset, keyint_sec)
|
||||
return vp9_args(bitrate, low_latency, preset, keyint_sec, rc, qp)
|
||||
return x264_args(profile, bitrate, low_latency, preset, keyint_sec, rc, qp)
|
||||
|
||||
+6
-3
@@ -20,10 +20,13 @@ services:
|
||||
- ./backend/app:/app/app # код бэкенда (правка без пересборки образа)
|
||||
- ./frontend:/app/frontend # фронтенд (правка без пересборки)
|
||||
- ./data:/data # SQLite-индекс архива
|
||||
# Запись. Локальная папка ./records ИЛИ смонтированный на хост NAS (SMB/NFS).
|
||||
# Для NAS: смонтируйте на хосте (например /mnt/nas/cctv) и замените левую часть:
|
||||
# - /mnt/nas/cctv:/records
|
||||
# Запись. ./records — дефолтное локальное хранилище (id=local).
|
||||
- ./records:/records
|
||||
# Общий корень доп. хранилищ (вкладка «Хранилища»): локальные подпапки
|
||||
# /storages/<id> и точки монтирования NAS. NAS монтируется на ХОСТЕ в
|
||||
# ./storages/<id>; после `mount` на хосте выполните `docker compose up -d`,
|
||||
# чтобы том стал виден здесь (или добавьте :rshared для live-подхвата).
|
||||
- ./storages:/storages
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
NVR_CONFIG: /app/cameras.yaml
|
||||
|
||||
+16
-22
@@ -3,45 +3,39 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Архив</title>
|
||||
<title>CoRE.Vizion+ — Архив</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="archive">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item active" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main class="archive-main">
|
||||
<div class="toolbar">
|
||||
<label class="muted">Камера:</label>
|
||||
<select id="cam-select"></select>
|
||||
<label class="muted">Дата:</label>
|
||||
<input type="date" id="date">
|
||||
<aside class="arch-side">
|
||||
<div class="arch-cams" id="arch-cams"></div>
|
||||
<div class="arch-date">
|
||||
<input type="date" id="date" class="arch-dateinput">
|
||||
<div class="arch-cal" id="calendar"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="arch-content" id="arch-content">
|
||||
<div class="player-wrap" id="player">
|
||||
<div class="hint">Кликните по таймлайну ниже, чтобы смотреть запись</div>
|
||||
</div>
|
||||
<div class="timeline" id="timeline"></div>
|
||||
<footer class="grid-bar" id="arch-bar">
|
||||
<div class="gb-left"></div>
|
||||
<div class="gb-center"></div>
|
||||
<div class="gb-right">
|
||||
<button class="gb-btn" id="arch-fs" title="Полный экран" aria-label="Полный экран">⛶</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+1
-8
@@ -3,17 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Камеры</title>
|
||||
<title>CoRE.Vizion+ — Камеры</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="cams">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
|
||||
+25
-20
@@ -3,38 +3,43 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Live view</title>
|
||||
<title>CoRE.Vizion+ — Live view</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="live">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item active" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav" id="live-subnav">
|
||||
<a class="subitem subplay" id="play-toggle" href="#" title="Начать/остановить просмотр">⏹ Стоп</a>
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
<a class="subitem subfs" id="fs-grid" href="#" title="Развернуть сетку на весь экран">⛶ Полный экран</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<main id="live-main">
|
||||
<div class="grid g4" id="grid"></div>
|
||||
<footer class="grid-bar" id="grid-bar">
|
||||
<div class="gb-left"></div>
|
||||
<div class="gb-center">
|
||||
<button class="gb-btn" id="gb-prev" title="Предыдущая страница" aria-label="Предыдущая страница">⏮</button>
|
||||
<button class="gb-btn gb-play" id="gb-play" title="Просмотр / стоп" aria-label="Просмотр / стоп">⏸</button>
|
||||
<button class="gb-btn" id="gb-next" title="Следующая страница" aria-label="Следующая страница">⏭</button>
|
||||
</div>
|
||||
<div class="gb-right">
|
||||
<div class="gb-split">
|
||||
<button class="gb-btn" id="gb-split" title="Раскладка сетки" aria-label="Раскладка сетки">▦</button>
|
||||
<div class="gb-split-menu" id="gb-split-menu">
|
||||
<button data-grid="2">4</button>
|
||||
<button data-grid="3">9</button>
|
||||
<button data-grid="4">16</button>
|
||||
<button data-grid="5">25</button>
|
||||
<button data-grid="6">36</button>
|
||||
<button data-grid="7">49</button>
|
||||
<button data-grid="8">64</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="gb-btn" id="gb-fs" title="Полный экран" aria-label="Полный экран">⛶</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-21
@@ -3,32 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Раскладка</title>
|
||||
<title>CoRE.Vizion+ — Раскладка</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="layout">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="navline">
|
||||
<a class="nav-item" href="/" data-nav="live" style="flex:1"><span class="ico">▦</span>Live view</a>
|
||||
<a class="nav-gear active" href="/layout" title="Настройка раскладки">⚙</a>
|
||||
</div>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Вход</title>
|
||||
<title>CoRE.Vizion+ — Вход</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<form class="login-card" id="login-form">
|
||||
<div class="brand login-brand">CoRE<span>.Vision</span></div>
|
||||
<div class="brand login-brand">CoRE<span>.Vizion+</span></div>
|
||||
<input type="text" id="username" placeholder="Логин" autocomplete="username" autofocus>
|
||||
<input type="password" id="password" placeholder="Пароль" autocomplete="current-password">
|
||||
<button type="submit">Войти</button>
|
||||
|
||||
+107
-46
@@ -3,29 +3,13 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CoRE.Vision — Настройки</title>
|
||||
<title>CoRE.Vizion+ — Настройки</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body data-page="settings">
|
||||
<header>
|
||||
<div class="brand">CoRE<span>.Vision</span></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="stats" id="sys-stats"></div>
|
||||
<div class="clock" id="clock"></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<a class="nav-item" href="/" data-nav="live"><span class="ico">▦</span>Live view</a>
|
||||
<div class="subnav">
|
||||
<a class="subitem" data-grid="2" href="/?grid=2">4</a>
|
||||
<a class="subitem" data-grid="3" href="/?grid=3">9</a>
|
||||
<a class="subitem" data-grid="4" href="/?grid=4">16</a>
|
||||
<a class="subitem" data-grid="5" href="/?grid=5">25</a>
|
||||
<a class="subitem" data-grid="6" href="/?grid=6">36</a>
|
||||
<a class="subitem" data-grid="7" href="/?grid=7">49</a>
|
||||
<a class="subitem" data-grid="8" href="/?grid=8">64</a>
|
||||
</div>
|
||||
<a class="nav-item" href="/archive" data-nav="archive"><span class="ico">🗄</span>Архив</a>
|
||||
<div class="aside-spacer"></div>
|
||||
<a class="nav-item active" href="/settings" data-nav="settings"><span class="ico">⚙</span>Настройки</a>
|
||||
@@ -34,7 +18,9 @@
|
||||
<main class="settings-main">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="cams" data-min-role="admin">Камеры</button>
|
||||
<button class="tab" data-tab="storage" data-min-role="admin">Хранилища</button>
|
||||
<button class="tab" data-tab="layout">Раскладка</button>
|
||||
<button class="tab" data-tab="player" data-min-role="operator">Плеер</button>
|
||||
<button class="tab" data-tab="users" data-min-role="superadmin">Пользователи</button>
|
||||
<button class="tab" data-tab="general" data-min-role="operator">Система</button>
|
||||
<button class="tab" data-tab="optim" data-min-role="superadmin">Доп настройки</button>
|
||||
@@ -48,36 +34,122 @@
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="bulk-edit">Изменить</button>
|
||||
<button id="bulk-cancel" hidden>Отмена</button>
|
||||
<button id="add-cam">+ Добавить камеру</button>
|
||||
<button id="clear-cams" class="danger" title="Удалить все камеры (архив не трогается)">Очистить все</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="cams">
|
||||
<table class="cams cams-fixed">
|
||||
<thead>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th>Статус</th><th></th></tr>
|
||||
<tr><th style="width:48px">ID</th><th>Камера</th><th>IP</th><th>Потоки</th><th style="width:56px">Вкл</th><th style="width:64px">Запись</th><th style="width:150px">Хранилище</th><th data-bulk-label="Логин / пароль">Статус</th><th style="width:62px"></th></tr>
|
||||
</thead>
|
||||
<tbody id="cam-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 2: Система -->
|
||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card" data-min-role="admin">
|
||||
<h2>Запись</h2>
|
||||
<label class="fl" style="font-size:13px;color:var(--text)">Размер сегмента</label>
|
||||
<select id="segment-size" style="margin-top:6px;display:block">
|
||||
<!-- ВКЛАДКА: Хранилища (управление записью) -->
|
||||
<div class="tab-panel" data-panel="storage" data-min-role="admin" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Хранилища записи</h2>
|
||||
<button id="stor-add">+ Добавить хранилище</button>
|
||||
</div>
|
||||
<div class="muted" style="margin-bottom:12px">
|
||||
Активное хранилище — куда пишутся новые записи. Квота задаётся на каждое хранилище
|
||||
(0 = без лимита) и правится прямо в таблице. После смены активного старые записи
|
||||
остаются доступны в Архиве. Удаление хранилища убирает его из списка — файлы записей
|
||||
НЕ удаляются.
|
||||
</div>
|
||||
<table class="cams">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:74px">Активно</th>
|
||||
<th style="width:90px">ID</th>
|
||||
<th>Название</th>
|
||||
<th style="width:56px">Тип</th>
|
||||
<th>Путь</th>
|
||||
<th style="width:130px">Квота, ГБ</th>
|
||||
<th style="width:150px">Занято / диск</th>
|
||||
<th style="width:130px">Статус</th>
|
||||
<th style="width:48px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stor-rows"></tbody>
|
||||
</table>
|
||||
<div class="muted" id="stor-msg" style="margin-top:10px;font-size:12px"></div>
|
||||
</div>
|
||||
|
||||
<!-- форма добавления (скрыта по умолчанию) -->
|
||||
<div class="card" id="stor-form-card" hidden>
|
||||
<h2>Новое хранилище</h2>
|
||||
<div class="cam-edit-grid">
|
||||
<label>ID<input type="text" id="sf-id" placeholder="arch2"></label>
|
||||
<label>Название<input type="text" id="sf-name" placeholder="Архив 2"></label>
|
||||
<label>Тип
|
||||
<select id="sf-type">
|
||||
<option value="local">Локальное (папка)</option>
|
||||
<option value="nas">NAS (SMB)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Квота, ГБ (0 = без лимита)<input type="number" id="sf-quota" min="0" value="0"></label>
|
||||
<label class="sf-local">Папка<input type="text" id="sf-path" placeholder="/storages/arch2 (по умолчанию)"></label>
|
||||
<label class="sf-nas" hidden>SMB сервер (IP)<input type="text" id="sf-server" placeholder="192.168.1.10"></label>
|
||||
<label class="sf-nas" hidden>Шара (share)<input type="text" id="sf-share" placeholder="cctv"></label>
|
||||
<label class="sf-nas" hidden>Логин<input type="text" id="sf-user"></label>
|
||||
<label class="sf-nas" hidden>Пароль<input type="password" id="sf-pass"></label>
|
||||
<label class="sf-nas" hidden>Подпапка (необяз.)<input type="text" id="sf-subpath" placeholder="cam-archive"></label>
|
||||
</div>
|
||||
<div class="cam-edit-foot">
|
||||
<span class="err" id="sf-err"></span>
|
||||
<button id="sf-save" class="primary">Создать</button>
|
||||
<button id="sf-cancel">Отмена</button>
|
||||
</div>
|
||||
<div class="muted" id="sf-mount" style="margin-top:10px;font-size:12px;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Политика записи <span class="muted" style="font-weight:400;font-size:12px">— общая для всех хранилищ</span></h2>
|
||||
<div class="cam-edit-grid">
|
||||
<label>Длина сегмента
|
||||
<select id="pol-segment">
|
||||
<option value="60">1 минута</option>
|
||||
<option value="120">2 минуты</option>
|
||||
<option value="300">5 минут</option>
|
||||
<option value="600">10 минут</option>
|
||||
<option value="900">15 минут</option>
|
||||
</select>
|
||||
<div style="margin-top:12px"><button id="segment-save">Сохранить</button></div>
|
||||
<div class="muted" id="segment-saved" style="margin-top:8px;font-size:12px"></div>
|
||||
</label>
|
||||
<label>Срок хранения, дней (0 = не удалять по возрасту)<input type="number" id="pol-retention" min="0" max="3650"></label>
|
||||
</div>
|
||||
<div style="margin-top:12px"><button id="pol-save">Сохранить</button></div>
|
||||
<div class="muted" id="pol-saved" style="margin-top:8px;font-size:12px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Активное хранилище <span class="muted" style="font-weight:400;font-size:12px">— сводка</span></h2>
|
||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА 2: Система -->
|
||||
<div class="tab-panel" data-panel="general" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h2>Система</h2>
|
||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ВКЛАДКА: Плеер и перекодирование (этот браузер) -->
|
||||
<div class="tab-panel" data-panel="player" data-min-role="operator" hidden>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h2>Live view <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||||
<input type="checkbox" id="pref-autostart">
|
||||
<span>Показывать камеры сразу при входе в Live view
|
||||
<span class="muted">— иначе просмотр стартует после нажатия Play (по умолчанию)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
||||
@@ -127,22 +199,6 @@
|
||||
<div style="margin-top:16px"><button id="prefs-save">Сохранить</button></div>
|
||||
<div class="muted" id="prefs-saved" style="margin-top:8px;font-size:12px">Сохраняется в этом браузере</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Хранилище</h2>
|
||||
<div class="kv" id="storage-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Система</h2>
|
||||
<div class="kv" id="system-kv"><div class="muted">загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Учётная запись</h2>
|
||||
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
|
||||
<button id="logout">Выйти из системы</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +260,11 @@
|
||||
|
||||
<!-- ВКЛАДКА 4: Пользователи (только суперадмин) -->
|
||||
<div class="tab-panel" data-panel="users" data-min-role="superadmin" hidden>
|
||||
<div class="card">
|
||||
<h2>Учётная запись</h2>
|
||||
<div class="muted" id="me-info" style="margin-bottom:10px;font-size:13px"></div>
|
||||
<button id="logout">Выйти из системы</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Пользователи и роли</h2>
|
||||
|
||||
+666
-140
File diff suppressed because it is too large
Load Diff
+164
-32
@@ -13,8 +13,9 @@
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--header-h: 48px;
|
||||
--sidebar-w: 240px;
|
||||
--header-h: 50px;
|
||||
--footer-h: 22px;
|
||||
--sidebar-w: 72px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -26,12 +27,12 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Шапка ── */
|
||||
header {
|
||||
height: var(--header-h); display: flex; align-items: center;
|
||||
/* ── Шапка (50px, бренд по центру) ── */
|
||||
header.app-header {
|
||||
height: var(--header-h); flex: none; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
gap: 18px;
|
||||
}
|
||||
header.app-header .brand { font-size: 18px; }
|
||||
.brand { font-weight: 700; letter-spacing: .5px; color: #fff; }
|
||||
.brand span { color: #c0392b; }
|
||||
nav { display: flex; gap: 4px; }
|
||||
@@ -46,11 +47,22 @@ nav a.active { background: var(--accent); color: #fff; }
|
||||
.stats b { color: var(--text); font-weight: 600; }
|
||||
.clock { font-variant-numeric: tabular-nums; color: var(--text); }
|
||||
|
||||
/* ── Раскладка ── */
|
||||
.layout { display: flex; height: calc(100% - var(--header-h)); }
|
||||
/* ── Раскладка (между шапкой и футером) ── */
|
||||
.layout { display: flex; height: calc(100% - var(--header-h) - var(--footer-h)); }
|
||||
|
||||
/* ── Футер (бренд + CPU/RAM/диск + часы + версия) ── */
|
||||
footer.app-footer {
|
||||
height: var(--footer-h); display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
font-size: 11px; color: var(--text-dim);
|
||||
}
|
||||
footer.app-footer .brand { font-size: 12px; }
|
||||
footer.app-footer .stats { display: flex; gap: 14px; font-size: 11px; }
|
||||
footer.app-footer .clock { font-size: 11px; color: var(--text); }
|
||||
footer.app-footer #footer-version { font-variant-numeric: tabular-nums; }
|
||||
aside {
|
||||
width: var(--sidebar-w); background: var(--panel); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 0; overflow: hidden;
|
||||
}
|
||||
.aside-title {
|
||||
padding: 12px 14px 8px; font-size: 11px; text-transform: uppercase;
|
||||
@@ -58,14 +70,14 @@ aside {
|
||||
}
|
||||
/* ── Навигация в сайдбаре ── */
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 14px;
|
||||
border-left: 3px solid transparent; background: none; border-top: 0; border-right: 0;
|
||||
border-bottom: 0; cursor: pointer; width: 100%; text-align: left; font-family: inherit;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||
width: 64px; height: 64px; padding: 0; border-radius: 12px;
|
||||
color: var(--text-dim); text-decoration: none; font-weight: 500; font-size: 9px; line-height: 1.15;
|
||||
border: 0; background: none; cursor: pointer; text-align: center; font-family: inherit;
|
||||
}
|
||||
.nav-item:hover { background: var(--panel-2); color: var(--text); }
|
||||
.nav-item.active { background: var(--panel-2); color: #fff; border-left-color: #c0392b; }
|
||||
.nav-item .ico { font-size: 17px; line-height: 1; width: 20px; text-align: center; }
|
||||
.nav-item.active { background: var(--panel-2); color: #fff; box-shadow: inset 0 0 0 1px #c0392b; }
|
||||
.nav-item .ico { font-size: 26px; line-height: 1; width: auto; text-align: center; }
|
||||
.aside-spacer { flex: 1; }
|
||||
|
||||
/* Иконка настройки раскладки рядом с Live view */
|
||||
@@ -147,7 +159,7 @@ input[type="checkbox"]:checked {
|
||||
}
|
||||
input[type="checkbox"]:checked::before { transform: translateX(18px); background: var(--ok); }
|
||||
input[type="checkbox"]:focus-visible { outline: 2px solid var(--ok); outline-offset: 2px; }
|
||||
input[type="date"], select {
|
||||
input[type="date"], input[type="number"], select {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
}
|
||||
@@ -158,6 +170,42 @@ input[type="date"], select {
|
||||
background: #000; overflow: hidden;
|
||||
grid-auto-rows: 1fr; grid-auto-columns: 1fr; /* все дорожки строго равны */
|
||||
}
|
||||
/* «Соло»: одна камера на весь экран (двойной клик по ячейке в полноэкранном режиме) */
|
||||
.grid.solo { grid-template-columns: 1fr !important; grid-template-rows: 1fr !important; }
|
||||
.grid.solo > .tile:not(.solo) { display: none; }
|
||||
|
||||
/* ── Панель управления под сеткой (футер фрейма live): листание + play/stop + фулскрин ── */
|
||||
footer.grid-bar {
|
||||
height: 32px; flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 0 8px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
}
|
||||
.grid-bar .gb-left { flex: 1; display: flex; justify-content: flex-start; }
|
||||
.grid-bar .gb-center { flex: 0 0 auto; display: flex; justify-content: center; gap: 8px; }
|
||||
.grid-bar .gb-right { flex: 1; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.grid-bar .gb-btn {
|
||||
height: 24px; min-width: 30px; padding: 0 8px; border: 1px solid var(--border);
|
||||
background: var(--panel-2); color: var(--text); border-radius: 6px; cursor: pointer;
|
||||
font-size: 14px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.grid-bar .gb-btn:hover:not(:disabled) { border-color: #c0392b; color: #fff; }
|
||||
.grid-bar .gb-btn:disabled { opacity: .3; cursor: default; }
|
||||
.grid-bar .gb-play { min-width: 42px; font-size: 15px; }
|
||||
/* кнопка «Раскладка» (window split) + всплывающее меню (раскрывается вверх) */
|
||||
.grid-bar .gb-split { position: relative; display: inline-flex; }
|
||||
.grid-bar .gb-split-menu {
|
||||
display: none; position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 50;
|
||||
flex-direction: column; gap: 2px; padding: 4px; min-width: 60px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.grid-bar .gb-split-menu.open { display: flex; }
|
||||
.grid-bar .gb-split-menu button {
|
||||
height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: 5px;
|
||||
background: none; color: var(--text); cursor: pointer; font-size: 13px;
|
||||
font-variant-numeric: tabular-nums; text-align: center;
|
||||
}
|
||||
.grid-bar .gb-split-menu button:hover { background: var(--panel-2); }
|
||||
.grid-bar .gb-split-menu button.active { background: var(--accent); border-color: #c0392b; color: #fff; }
|
||||
.tile {
|
||||
position: relative; background: #05070a; border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||
@@ -192,6 +240,9 @@ input[type="date"], select {
|
||||
.tile:fullscreen { border: none; background: #000; }
|
||||
.tile:fullscreen video, .tile:fullscreen video-stream { object-fit: contain; }
|
||||
#grid:fullscreen { width: 100vw; height: 100vh; background: var(--bg); padding: 0; }
|
||||
/* в полноэкранном режиме панель-футер скрыта (вход/выход — клавиша F, выход также Esc) */
|
||||
#live-main:fullscreen .grid-bar,
|
||||
#arch-content:fullscreen .grid-bar { display: none; }
|
||||
.hevc-hint {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
|
||||
background: rgba(63, 3, 3, .88); color: #fff; font-size: 11px;
|
||||
@@ -199,7 +250,51 @@ input[type="date"], select {
|
||||
}
|
||||
|
||||
/* ── Архив ── */
|
||||
.archive-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.archive-main { flex: 1; display: flex; overflow: hidden; } /* сайдбар + контент */
|
||||
/* левый сайдбар архива: 220px, поделён по высоте пополам */
|
||||
.arch-side {
|
||||
width: 220px; flex: none; display: flex; flex-direction: column;
|
||||
background: var(--panel); border-right: 1px solid var(--border); overflow: hidden;
|
||||
}
|
||||
/* верхний фрейм — список камер: заполняет всё над карточкой даты, без полосы прокрутки */
|
||||
.arch-cams { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; }
|
||||
.arch-cams::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
.arch-cam {
|
||||
padding: 6px 12px; font-size: 13px; color: var(--text); cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.arch-cam:hover { background: var(--panel-2); }
|
||||
.arch-cam.active { background: #c0392b; color: #fff; font-weight: 600; }
|
||||
/* нижний фрейм даты — карточка (дата сверху + календарь), прижата вниз */
|
||||
.arch-date {
|
||||
flex: none; margin: 10px; padding: 10px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.arch-dateinput {
|
||||
width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 8px; font-size: 13px;
|
||||
}
|
||||
.arch-cal { user-select: none; }
|
||||
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.cal-nav {
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
width: 24px; height: 24px; border-radius: 6px; cursor: pointer; padding: 0; line-height: 1; font-size: 14px;
|
||||
}
|
||||
.cal-nav:hover { border-color: #c0392b; color: #fff; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
|
||||
.cal-wd { font-size: 10px; color: var(--text-dim); text-align: center; padding: 2px 0; }
|
||||
.cal-day {
|
||||
text-align: center; font-size: 12px; padding: 4px 0; border-radius: 5px; cursor: pointer;
|
||||
color: var(--text); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cal-day.empty { visibility: hidden; cursor: default; }
|
||||
.cal-day:hover:not(.empty) { background: rgba(255, 255, 255, .07); }
|
||||
.cal-day.today { box-shadow: inset 0 0 0 1px var(--text-dim); }
|
||||
.cal-day.sel { background: #c0392b; color: #fff; font-weight: 600; }
|
||||
/* контент архива (плеер + таймлайн) */
|
||||
.arch-content { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||
.player-wrap { flex: 1; background: #000; display: flex; align-items: center; justify-content: center; min-height: 0; }
|
||||
.player-wrap video { max-width: 100%; max-height: 100%; }
|
||||
.player-wrap .hint { color: var(--text-dim); }
|
||||
@@ -328,7 +423,7 @@ input[type="date"], select {
|
||||
.settings-main .wrap { max-width: 920px; margin: 0 auto; }
|
||||
|
||||
/* Вкладки (как в браузере) */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent; border-radius: 0;
|
||||
color: var(--text-dim); padding: 10px 18px; cursor: pointer; font-size: 14px; font-weight: 500;
|
||||
@@ -336,20 +431,18 @@ input[type="date"], select {
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: #fff; border-bottom-color: #c0392b; }
|
||||
.tab-panel[hidden] { display: none; }
|
||||
.tr-row { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; padding-left: 28px; }
|
||||
/* ряд настроек профиля: слева — ровная сетка параметров, справа — «Готовый профиль» */
|
||||
.tr-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; padding-left: 28px; align-items: flex-start; }
|
||||
.tr-row[hidden] { display: none; }
|
||||
/* 3 группы параметров перекодирования: Кодек / Битрейт / Готовый профиль */
|
||||
.tr-group { border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px 12px; background: var(--panel-2); }
|
||||
.tr-gtitle { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 8px; }
|
||||
.tr-gbody { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: flex-end; }
|
||||
.tr-row label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-dim); }
|
||||
.tr-row select { display: block; }
|
||||
/* общие пресеты перекодирования (SOFT / MEDIUM / HARD) */
|
||||
.tr-presets { flex-basis: 100%; display: flex; align-items: center; gap: 8px; padding-left: 28px; }
|
||||
.tr-preset {
|
||||
padding: 4px 12px; font-size: 12px; font-weight: 600; border-radius: 6px;
|
||||
background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); cursor: pointer;
|
||||
}
|
||||
.tr-preset[data-preset="SOFT"] { color: #e03131; }
|
||||
.tr-preset[data-preset="MEDIUM"] { color: #e9c46a; }
|
||||
.tr-preset[data-preset="HARD"] { color: #ffffff; }
|
||||
.tr-preset.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.tr-gbody select, .tr-gbody input[type="number"] { display: block; }
|
||||
.tr-gbody input[type="number"] { width: 80px; }
|
||||
.tr-group-preset select { min-width: 120px; }
|
||||
.tr-row select:disabled, .tr-row input:disabled { opacity: .4; cursor: not-allowed; }
|
||||
/* блок профиля: режим (свой плеер / перекодировать) + ряд параметров */
|
||||
.tr-block { padding: 12px 0; border-top: 1px solid var(--border); }
|
||||
.tr-block:first-of-type { border-top: none; padding-top: 0; }
|
||||
@@ -415,8 +508,26 @@ table.cams th, table.cams td {
|
||||
table.cams th { color: var(--text-dim); font-weight: 500; font-size: 11px; padding: 4px 10px; }
|
||||
table.cams td.actions { white-space: nowrap; text-align: right; }
|
||||
table.cams tr.slot-empty td { opacity: .5; }
|
||||
/* строка-контейнер лога не должна занимать высоту, пока лог скрыт */
|
||||
table.cams tr.logrow > td { padding: 0; border: none; }
|
||||
/* камеры: жёстко фиксированная высота строки 22px независимо от контролов.
|
||||
line-height:1 + контролы по центру и ниже 22px → строки не распирает. */
|
||||
table.cams.cams-fixed td, table.cams.cams-fixed th {
|
||||
height: 22px; padding-top: 0; padding-bottom: 0; line-height: 1;
|
||||
}
|
||||
table.cams.cams-fixed td > * { vertical-align: middle; }
|
||||
/* тумблеры «Вкл»/«Запись» в строке — компактные (30×16) */
|
||||
table.cams.cams-fixed td input[type="checkbox"] {
|
||||
width: 30px; height: 16px; border-radius: 8px;
|
||||
}
|
||||
table.cams.cams-fixed td input[type="checkbox"]::before { width: 12px; height: 12px; top: 1px; left: 1px; }
|
||||
table.cams.cams-fixed td input[type="checkbox"]:checked::before { transform: translateX(14px); }
|
||||
/* селекты и инпуты в строке — на 2px ниже строки, по центру */
|
||||
table.cams.cams-fixed td select.stor-sel,
|
||||
table.cams.cams-fixed td select.cell-sel,
|
||||
table.cams.cams-fixed td input.cell-in { height: 20px; }
|
||||
/* кнопки действий ⟳/✕ (режим редактирования) — компактные */
|
||||
table.cams.cams-fixed td.actions button {
|
||||
height: 20px; padding: 0 6px; font-size: 13px; line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Инлайн-редактор строки (вместо всплывающего окна) ── */
|
||||
.cam-edit { background: var(--panel-2); padding: 12px 12px 14px; }
|
||||
@@ -439,10 +550,31 @@ button.primary { background: var(--accent); border-color: #c0392b; color: #fff;
|
||||
button.danger { border-color: #c0392b; color: #e74c3c; }
|
||||
button.danger:hover { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||
table.cams td.actions button { padding: 2px 8px; font-size: 12px; }
|
||||
/* инлайн-селекты в строке (хранилище, канал) — компактные, не раздувают высоту строки */
|
||||
table.cams td select.stor-sel, table.cams td select.cell-sel {
|
||||
height: 22px; padding: 1px 4px; font-size: 12px; line-height: 1;
|
||||
border-radius: 5px; width: 100%; max-width: 150px; box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* массовое редактирование: инпуты прямо в ячейках, компактные (высота строки не растёт) */
|
||||
table.cams td input.cell-in {
|
||||
height: 22px; padding: 1px 6px; font-size: 12px; line-height: 1;
|
||||
border-radius: 5px; width: 100%; box-sizing: border-box; vertical-align: middle;
|
||||
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
}
|
||||
table.cams td input.cell-in:focus { outline: none; border-color: #c0392b; }
|
||||
table.cams td.cell-dual { white-space: nowrap; }
|
||||
table.cams td.cell-dual input.cell-in { display: inline-block; width: calc(50% - 3px); }
|
||||
table.cams td.cell-dual input.cell-in + input.cell-in { margin-left: 4px; }
|
||||
table.cams tr.bulk-row td { background: rgba(192, 57, 43, .05); }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px;
|
||||
border-radius: 11px; font-size: 12px; background: var(--panel-2);
|
||||
}
|
||||
.badge-active { background: rgba(46, 204, 113, .18); color: var(--ok); font-weight: 600; }
|
||||
.q-cell { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.q-cell .q-input { width: 78px; }
|
||||
.q-cell button { padding: 2px 8px; font-size: 12px; }
|
||||
.logbox {
|
||||
margin: 4px 10px 12px; padding: 10px; background: #05070a; border: 1px solid var(--border);
|
||||
border-radius: 6px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
|
||||
+12
-7
@@ -286,7 +286,11 @@ var FrameRenderer = class _FrameRenderer {
|
||||
format: "I420",
|
||||
codedWidth: w,
|
||||
codedHeight: h,
|
||||
timestamp
|
||||
timestamp,
|
||||
// Hikvision H.265 — BT.709, TV/limited range. Без явного colorSpace браузер для
|
||||
// I420 по умолчанию берёт BT.601 (зелёно-красный сдвиг) и неверный диапазон
|
||||
// (белёсость). Задаём явно, чтобы YUV→RGB шёл по правильной матрице и диапазону.
|
||||
colorSpace: { primaries: "bt709", transfer: "bt709", matrix: "bt709", fullRange: false }
|
||||
});
|
||||
await this._writer.write(videoFrame);
|
||||
videoFrame.close();
|
||||
@@ -394,12 +398,13 @@ var FRAGMENT_SRC = `
|
||||
uniform sampler2D u_texCb;
|
||||
uniform sampler2D u_texCr;
|
||||
void main() {
|
||||
float y = texture2D(u_texY, v_tex).r;
|
||||
float cb = texture2D(u_texCb, v_tex).r - 0.5;
|
||||
float cr = texture2D(u_texCr, v_tex).r - 0.5;
|
||||
float r = y + 1.5748 * cr;
|
||||
float g = y - 0.1873 * cb - 0.4681 * cr;
|
||||
float b = y + 1.8556 * cb;
|
||||
// BT.709 limited range (TV): Y∈[16,235]/255 → (Y-16)*255/219; Cb/Cr∈[16,240]/255 центр 128/255.
|
||||
float y = (texture2D(u_texY, v_tex).r - 0.0627451) * 1.1643836;
|
||||
float cb = texture2D(u_texCb, v_tex).r - 0.5019608;
|
||||
float cr = texture2D(u_texCr, v_tex).r - 0.5019608;
|
||||
float r = y + 1.7927411 * cr;
|
||||
float g = y - 0.2132486 * cb - 0.5329093 * cr;
|
||||
float b = y + 2.1124018 * cb;
|
||||
gl_FragColor = vec4(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user