285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""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
|