video_monitor/app/recording/manager.py

285 lines
10 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""24/7 录像管理 — FFmpeg 分段录制 + retention 清理"""
import logging
import os
import subprocess
import threading
import time
from datetime import datetime, timedelta
logger = logging.getLogger("recording.manager")
_MANAGER = None
_MANAGER_LOCK = threading.Lock()
class RecordingManager(object):
def __init__(self):
self._lock = threading.RLock()
self._processes = {} # stream_id -> {"proc": Popen, "path": str}
self._retention_thread = None
self._running = False
def start(self):
if self._running:
return
self._running = True
self._retention_thread = threading.Thread(
target=self._retention_loop, name="recording-retention", daemon=True)
self._retention_thread.start()
2026-09-04 18:16:14 +08:00
self._sync_thread = threading.Thread(
target=self._sync_loop, name="recording-sync", daemon=True)
self._sync_thread.start()
2026-08-30 22:22:11 +08:00
logger.info("RecordingManager 已启动")
def stop(self):
if not self._running and not self._processes:
return
self._running = False
with self._lock:
stream_ids = list(self._processes)
for stream_id in stream_ids:
self.stop_stream(stream_id)
if self._retention_thread and self._retention_thread.is_alive():
self._retention_thread.join(timeout=5)
logger.info("RecordingManager 已停止")
2026-09-04 18:16:14 +08:00
def _sync_loop(self):
"""周期性把 DB 中的 record_enable 标记同步为实际 FFmpeg 录制进程。
分进程部署下 Web 请求只负责持久化 record_enable真正的录像进程
由持有录制服务的领导者进程通过本循环异步启动/停止
"""
time.sleep(3)
while self._running:
try:
self._run_sync()
except Exception as e:
logger.warning("录像同步异常: %s", e)
for _ in range(5):
if not self._running:
break
time.sleep(1)
def _run_sync(self):
from app.models import StreamModel
streams = list(StreamModel.objects.filter(forward_state=1))
wanted = {s.id for s in streams if int(s.record_enable or 0) == 1}
current = set(self.list_recording())
for s in streams:
if s.id in wanted and s.id not in current:
ok, info = self.start_stream(s)
if not ok:
logger.warning("录像启动失败 stream=%s: %s", s.id, info)
elif s.id not in wanted and s.id in current:
self.stop_stream(s.id)
2026-08-30 22:22:11 +08:00
def _segment_seconds(self):
try:
from app.utils.GlobalUtils import g_config
return int(getattr(g_config, "recordingSegmentSeconds", 600))
except Exception:
return 600
def _record_dir(self, stream):
from app.utils.GlobalUtils import g_config
base = getattr(g_config, "storageRecordDir", "") or os.path.join(
getattr(g_config, "storageDir", ""), "record")
code = "".join(c for c in str(stream.code or stream.id) if c.isalnum() or c in "_-")
day = datetime.now().strftime("%Y%m%d")
d = os.path.join(base, code, day)
os.makedirs(d, exist_ok=True)
return d
def _rtsp_url(self, stream):
from app.analysis.manager import AnalysisManager
return AnalysisManager.build_rtsp_url(stream)
def start_stream(self, stream):
2026-09-04 18:16:14 +08:00
from monitor_runtime.licensing import require_license
require_license()
2026-08-30 22:22:11 +08:00
if not self._running:
return False, "recording service is not running in this process"
sid = stream.id
with self._lock:
if sid in self._processes:
proc = self._processes[sid].get("proc")
if proc and proc.poll() is None:
return True, "already recording"
url = self._rtsp_url(stream)
if not url:
return False, "no rtsp url"
out_dir = self._record_dir(stream)
seg = self._segment_seconds()
pattern = os.path.join(out_dir, "%s_%%Y%%m%%d_%%H%%M%%S.mp4" % sid)
try:
from app.utils.GlobalUtils import g_config
ffmpeg = g_config.ffmpeg
except Exception:
ffmpeg = "ffmpeg"
cmd = [
ffmpeg, "-loglevel", "warning", "-rtsp_transport", "tcp",
"-i", url,
2026-09-04 18:16:14 +08:00
# MP4 容器不支持摄像头常见的 pcm_alaw 音频,只复制视频流。
"-map", "0:v", "-c", "copy", "-f", "segment",
2026-08-30 22:22:11 +08:00
"-segment_time", str(seg),
"-reset_timestamps", "1",
"-strftime", "1",
pattern,
]
try:
2026-09-04 18:16:14 +08:00
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
2026-08-30 22:22:11 +08:00
except Exception as e:
return False, str(e)
self._processes[sid] = {"proc": proc, "path": out_dir, "stream": stream}
logger.info("录像启动 stream=%s dir=%s", sid, out_dir)
return True, "started"
def stop_stream(self, stream_id):
with self._lock:
item = self._processes.pop(stream_id, None)
if not item:
return False, "not recording"
proc = item.get("proc")
if proc:
try:
2026-09-04 18:16:14 +08:00
# 先优雅退出(向 stdin 发 'q'),让 FFmpeg 正常收尾写出 mp4 的
# moov 原子Windows 上 TerminateProcess 硬杀会导致当前分段
# 无 moov、文件无法播放。
if proc.stdin:
try:
proc.stdin.write(b"q")
proc.stdin.flush()
proc.stdin.close()
except Exception:
pass
try:
proc.wait(timeout=8)
except Exception:
proc.terminate()
proc.wait(timeout=5)
2026-08-30 22:22:11 +08:00
except Exception:
try:
proc.kill()
except Exception:
pass
return True, "stopped"
def is_recording(self, stream_id):
with self._lock:
item = self._processes.get(stream_id)
if not item:
return False
proc = item.get("proc")
return proc is not None and proc.poll() is None
def list_recording(self):
with self._lock:
return [sid for sid, item in self._processes.items()
if item.get("proc") and item["proc"].poll() is None]
def _retain_days(self):
try:
from app.utils.GlobalUtils import g_config
return int(getattr(g_config, "recordingRetainDays", 7))
except Exception:
return 7
def _retain_gb(self):
try:
from app.utils.GlobalUtils import g_config
return float(getattr(g_config, "recordingRetainGb", 0))
except Exception:
return 0
def _retention_loop(self):
while self._running:
try:
self._run_retention()
except Exception as e:
logger.warning("retention 异常: %s", e)
for _ in range(3600):
if not self._running:
break
time.sleep(1)
def _run_retention(self):
from app.utils.GlobalUtils import g_config
base = getattr(g_config, "storageRecordDir", "")
if not base or not os.path.isdir(base):
return
days = self._retain_days()
cutoff = datetime.now() - timedelta(days=max(1, days))
deleted = 0
for root, _dirs, files in os.walk(base):
for fn in files:
if not fn.endswith((".mp4", ".ts", ".mkv")):
continue
fp = os.path.join(root, fn)
try:
mtime = datetime.fromtimestamp(os.path.getmtime(fp))
if mtime < cutoff:
os.remove(fp)
deleted += 1
self._delete_recording_row(fp)
except Exception:
pass
max_gb = self._retain_gb()
if max_gb > 0:
self._enforce_size_cap(base, max_gb)
if deleted:
logger.info("retention 删除 %d 个过期录像文件", deleted)
def _enforce_size_cap(self, base, max_gb):
files = []
for root, _d, fns in os.walk(base):
for fn in fns:
if fn.endswith((".mp4", ".ts", ".mkv")):
fp = os.path.join(root, fn)
try:
files.append((os.path.getmtime(fp), os.path.getsize(fp), fp))
except Exception:
pass
files.sort()
total = sum(x[1] for x in files)
limit = int(max_gb * (1024 ** 3))
while total > limit and files:
_mt, sz, fp = files.pop(0)
try:
os.remove(fp)
total -= sz
self._delete_recording_row(fp)
except Exception:
pass
def _delete_recording_row(self, filepath):
try:
from app.models import RecordingModel
RecordingModel.objects.filter(file_path=filepath).delete()
except Exception:
pass
def index_recording_file(self, stream_id, filepath, start_time, duration, file_size):
try:
from app.models import StreamModel, RecordingModel
stream = StreamModel.objects.get(id=stream_id)
RecordingModel.objects.create(
stream=stream,
file_path=filepath,
start_time=start_time,
end_time=start_time + timedelta(seconds=duration) if duration else start_time,
duration=duration or 0,
file_size=file_size or 0,
)
except Exception as e:
logger.debug("index recording: %s", e)
def get_recording_manager():
global _MANAGER
with _MANAGER_LOCK:
if _MANAGER is None:
_MANAGER = RecordingManager()
return _MANAGER