init
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user