video_monitor/app/recording/manager.py
2026-09-04 18:16:14 +08:00

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
self._sync_thread = threading.Thread(
target=self._sync_loop, name="recording-sync", daemon=True)
self._sync_thread.start()
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 已停止")
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)
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):
from monitor_runtime.licensing import require_license
require_license()
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,
# MP4 容器不支持摄像头常见的 pcm_alaw 音频,只复制视频流。
"-map", "0:v", "-c", "copy", "-f", "segment",
"-segment_time", str(seg),
"-reset_timestamps", "1",
"-strftime", "1",
pattern,
]
try:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
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:
# 先优雅退出(向 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)
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