"""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