This commit is contained in:
2026-05-30 00:08:59 +05:00
commit d3afd698b6
52 changed files with 8150 additions and 0 deletions
View File
+61
View File
@@ -0,0 +1,61 @@
"""Сборка команд ffmpeg/ffprobe. Запись — строго копированием (без перекодирования)."""
from __future__ import annotations
import asyncio
import json
import os
from ..config import Camera, Storage
def record_args(cam: Camera, storage: Storage, input_url: str | None = None) -> list[str]:
"""ffmpeg для записи основного потока сегментами mp4 копированием.
-c copy => CPU почти не используется. -strftime даёт имена сегментов по времени.
input_url — источник (по умолчанию рестрим go2rtc: камера держит одну сессию).
"""
# Плоско в каталоге камеры: дата в имени файла. Каталог создаётся один раз
# (recorder_task), новых подкаталогов не нужно — корректно переживает полночь.
# Подкаталог даты не используем: этот ffmpeg-билд не знает -strftime_mkdir.
out_template = os.path.join(storage.path, cam.id, "%Y-%m-%d_%H-%M-%S.mp4")
return [
"ffmpeg",
"-nostdin",
"-hide_banner",
"-loglevel", "warning",
"-rtsp_transport", "tcp",
"-timeout", "10000000", # 10s на установку соединения (микросекунды)
"-i", input_url or cam.record_url,
"-an", # звук не пишем (камеры обычно без него)
"-c:v", "copy",
"-f", "segment",
"-segment_time", str(storage.segment_seconds),
"-segment_atclocktime", "1", # резать по часам: кратно segment_time от 00:00
"-segment_clocktime_offset", "0",
"-segment_format", "mp4",
"-reset_timestamps", "1",
"-strftime", "1",
out_template,
]
async def probe_duration(path: str) -> tuple[float | None, int | None]:
"""ffprobe: длительность (сек) и размер файла (байт)."""
try:
size = os.path.getsize(path)
except OSError:
size = None
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await proc.communicate()
duration = None
if proc.returncode == 0 and out:
try:
data = json.loads(out)
duration = float(data["format"]["duration"])
except (json.JSONDecodeError, KeyError, ValueError):
duration = None
return duration, size
+109
View File
@@ -0,0 +1,109 @@
"""Индексатор архива: периодически сканирует /records, заносит новые сегменты в БД.
Логика: самый свежий сегмент каждой камеры ещё пишется ffmpeg-ом — его пропускаем.
Все остальные mp4, которых нет в БД, пробуем через ffprobe и добавляем.
Параллельно убираем из БД записи о пропавших файлах.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from datetime import datetime
from ..config import Config
from ..db import Database
from .ffmpeg import probe_duration
log = logging.getLogger("nvr.indexer")
SCAN_INTERVAL = 30 # сек
def _parse_started_at(path: str) -> int:
"""Время начала сегмента из имени <cam>/<YYYY-MM-DD_HH-MM-SS>.mp4."""
try:
stem = os.path.splitext(os.path.basename(path))[0]
dt = datetime.strptime(stem, "%Y-%m-%d_%H-%M-%S")
return int(dt.timestamp())
except ValueError:
return int(os.path.getmtime(path))
def _scan_segments(root: str) -> dict[str, list[str]]:
"""Возвращает {camera_id: [пути mp4, отсортированы]}."""
result: dict[str, list[str]] = {}
if not os.path.isdir(root):
return result
for cam_id in os.listdir(root):
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()
if files:
result[cam_id] = files
return result
class Indexer:
def __init__(self, config: Config, db: Database):
self.config = config
self.db = db
self._task: asyncio.Task | None = None
self._stopping = False
def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="indexer")
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:
try:
await self.scan_once()
except Exception: # noqa: BLE001
log.exception("indexer scan failed")
await asyncio.sleep(SCAN_INTERVAL)
async def scan_once(self) -> int:
root = self.config.storage.path
by_cam = _scan_segments(root)
known = await self.db.known_paths()
added = 0
for cam_id, files in by_cam.items():
# последний файл может писаться прямо сейчас — не индексируем
complete = files[:-1] if len(files) > 1 else []
for path in complete:
if path in known:
continue
duration, size = await probe_duration(path)
if size is None or size == 0:
continue
await self.db.add_recording(
cam_id, path, _parse_started_at(path), duration, size
)
added += 1
# уборка пропавших файлов
for path in known:
if not os.path.exists(path):
await self.db.delete_missing(path)
if added:
log.info("indexer: добавлено сегментов %d", added)
return added
+139
View File
@@ -0,0 +1,139 @@
"""Задача записи одной камеры: запуск ffmpeg + супервизия с backoff."""
from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import deque
from ..config import Camera, Storage
from ..models import CameraState, CameraStatus
from .ffmpeg import record_args
log = logging.getLogger("nvr.recorder")
BACKOFF_START = 1.0
BACKOFF_MAX = 60.0
MIN_HEALTHY_UPTIME = 15.0 # процесс прожил столько => считаем старт удачным, сбрасываем backoff
class RecorderTask:
"""Держит один процесс ffmpeg на камеру, перезапускает при падении."""
def __init__(self, cam: Camera, storage: Storage, on_status=None, input_url: str | None = None):
self.cam = cam
self.storage = storage
self.input_url = input_url # источник записи (рестрим go2rtc)
self.on_status = on_status # callback(CameraStatus) для WS-уведомлений
self.status = CameraStatus(
camera_id=cam.id, name=cam.name, enabled=cam.enabled,
state=CameraState.OFFLINE, since=int(time.time()),
)
self._proc: asyncio.subprocess.Process | None = None
self._task: asyncio.Task | None = None
self._stopping = False
self._log_tail: deque[str] = deque(maxlen=40)
# ── управление ──────────────────────────────────────────────
def start(self) -> None:
if self._task and not self._task.done():
return
self._stopping = False
self._task = asyncio.create_task(self._run(), name=f"rec-{self.cam.id}")
async def stop(self) -> None:
self._stopping = True
await self._kill_proc()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._set_state(CameraState.OFFLINE)
async def _kill_proc(self) -> None:
proc = self._proc
if proc and proc.returncode is None:
try:
proc.terminate()
await asyncio.wait_for(proc.wait(), timeout=5)
except (asyncio.TimeoutError, ProcessLookupError):
try:
proc.kill()
except ProcessLookupError:
pass
self._proc = None
# ── основной цикл ───────────────────────────────────────────
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:
self._set_state(CameraState.STARTING)
args = record_args(self.cam, self.storage, self.input_url)
log.info("[%s] start ffmpeg: %s", self.cam.id, _redact(args, self.cam))
try:
self._proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
self._set_state(CameraState.RETRYING, "ffmpeg not found")
return
self.status.pid = self._proc.pid
self._set_state(CameraState.ONLINE)
started = time.monotonic()
await self._drain_stderr(self._proc)
rc = await self._proc.wait()
uptime = time.monotonic() - started
if self._stopping:
break
# backoff сбрасываем только если поток реально шёл (а не упал сразу, как .33 с 453)
if uptime >= MIN_HEALTHY_UPTIME:
backoff = BACKOFF_START
else:
backoff = min(backoff * 2, BACKOFF_MAX)
err = self._log_tail[-1] if self._log_tail else f"exit code {rc}"
self.status.restarts += 1
self._set_state(CameraState.RETRYING, err)
log.warning("[%s] ffmpeg exited rc=%s (uptime %.1fs), retry in %.0fs",
self.cam.id, rc, uptime, backoff)
await asyncio.sleep(backoff)
async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None:
assert proc.stderr is not None
async for raw in proc.stderr:
line = raw.decode("utf-8", "replace").rstrip()
if line:
self._log_tail.append(line)
log.debug("[%s] %s", self.cam.id, line)
# ── статус ──────────────────────────────────────────────────
def _set_state(self, state: CameraState, error: str | None = None) -> None:
self.status.state = state
self.status.last_error = error
self.status.since = int(time.time())
if state != CameraState.ONLINE:
self.status.pid = None
if self.on_status:
try:
self.on_status(self.status)
except Exception: # noqa: BLE001 — статус не должен ронять рекордер
log.exception("on_status callback failed")
@property
def log_tail(self) -> list[str]:
return list(self._log_tail)
def _redact(args: list[str], cam: Camera) -> str:
return " ".join(a.replace(cam.password, "***") for a in args)
+80
View File
@@ -0,0 +1,80 @@
"""Супервизор: владеет набором RecorderTask по всем включённым камерам."""
from __future__ import annotations
import logging
from ..config import Camera, Config
from ..models import CameraStatus
from ..services import go2rtc
from .recorder_task import RecorderTask
log = logging.getLogger("nvr.supervisor")
class Supervisor:
def __init__(self, config: Config, on_status=None):
self.config = config
self.on_status = on_status
self.tasks: dict[str, RecorderTask] = {}
def _input_url(self, cam: Camera) -> str:
# запись основного потока идёт через рестрим go2rtc (одна сессия к камере)
return go2rtc.restream_main_url(self.config, cam.id)
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))
self.tasks[cam.id] = task
if cam.enabled:
task.start()
log.info("recorder started: %s", cam.id)
else:
log.info("recorder skipped (disabled): %s", cam.id)
async def stop_all(self) -> None:
for task in self.tasks.values():
await task.stop()
def statuses(self) -> list[CameraStatus]:
return [t.status for t in self.tasks.values()]
def status(self, cam_id: str) -> CameraStatus | None:
task = self.tasks.get(cam_id)
return task.status if task else None
def log_tail(self, cam_id: str) -> list[str]:
task = self.tasks.get(cam_id)
return task.log_tail if task else []
async def restart(self, cam_id: str) -> bool:
task = self.tasks.get(cam_id)
if not task:
return False
await task.stop()
task.start()
return True
def add_camera(self, cam: Camera) -> None:
"""Создаёт задачу записи для новой камеры и запускает (если включена)."""
task = RecorderTask(cam, self.config.storage, on_status=self.on_status,
input_url=self._input_url(cam))
self.tasks[cam.id] = task
if cam.enabled:
task.start()
log.info("recorder added: %s", cam.id)
async def remove_camera(self, cam_id: str) -> None:
task = self.tasks.pop(cam_id, None)
if task:
await task.stop()
log.info("recorder removed: %s", cam_id)
async def replace_camera(self, cam: Camera) -> None:
await self.remove_camera(cam.id)
self.add_camera(cam)
async def restart_all(self) -> None:
"""Пересоздаёт все задачи (например, после смены настроек хранилища)."""
for cam in list(self.config.cameras):
await self.replace_camera(cam)