a64749ccc8
Replace the single fixed /records path with a list of storages and an
active selection (where new recordings go). Quota is now per-storage.
- config: StorageItem(id,name,type,path,quota_gb,nas-meta) + Storage keeps
global retention_days/segment_seconds; active + items. Migration from the
old single {path,quota_gb} schema → one local item.
- recorder: write to the active storage; restart_all() repoints on switch;
NAS-not-mounted guard (never write into the container overlay).
- indexer: scan ALL storage roots so recordings survive an active switch.
- retention: enforce quota PER storage via db.total_size_under /
oldest_recordings_under (path-prefix LIKE).
- api/storages: GET/POST/PUT/DELETE + activate + policy. NAS uses host-mount
(backend stores SMB params, generates the mount/fstab command, reports
ismount status). Retire old POST /api/storage.
- frontend: new "Хранилища" settings tab — table with inline per-row quota,
add form (local folder / NAS SMB), global policy (segment + retention).
- compose: add shared ./storages:/storages root for extra local folders and
host-mounted NAS shares.
VERSION 0.0.127.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
64 lines
2.9 KiB
Python
64 lines
2.9 KiB
Python
"""Сборка команд ffmpeg/ffprobe. Запись — строго копированием (без перекодирования)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
|
|
from ..config import Camera
|
|
|
|
|
|
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(out_root, 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(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
|