video_monitor/app/utils/Config.py

334 lines
15 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""Monitor 启动配置 — 所有可配置项均来自 config.json后台「启动配置」页可编辑。"""
import json
import os
from urllib.parse import quote
from framework.settings import BASE_DIR
2026-09-04 18:16:14 +08:00
from monitor_runtime.paths import RESOURCE_ROOT, DESKTOP
2026-08-30 22:22:11 +08:00
from app.utils.Secrets import get_runtime_secret
def _resolve_path(path, base=None):
"""相对路径基于项目根目录 BASE_DIR 解析为绝对路径。"""
base = base or str(BASE_DIR)
if not path:
return ""
p = str(path).strip()
if not p:
return ""
if os.path.isabs(p):
return os.path.normpath(p)
return os.path.normpath(os.path.join(base, p.replace("\\", "/")))
def _bool(v, default=False):
if v is None:
return default
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
return str(v).strip().lower() in ("1", "true", "yes", "on")
def _int(v, default=0):
try:
return int(v)
except (TypeError, ValueError):
return default
def _float(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
class Config:
def __init__(self, filepath):
self.__filepath = filepath
config_data = None
for encoding in ["utf-8", "gbk"]:
try:
with open(filepath, "r", encoding=encoding) as f:
config_data = json.loads(f.read())
break
except Exception as e:
print("Config.__init__() error:%s,encoding=%s|%s" % (str(e), encoding, str(filepath)))
if not config_data:
raise Exception("Config.__init__() read %s error" % str(filepath))
self.__config_data = config_data
self._apply(config_data)
def _apply(self, config_data):
self.__config_data_str = str(config_data)
base = str(BASE_DIR)
# —— 基础 ——
# Internal credentials are never sourced from the web-editable config.
self.internalApiSecret = get_runtime_secret("internal_api_secret")
self.internalHost = "127.0.0.1"
self.externalHost = config_data.get("host", "127.0.0.1")
self.adminPort = _int(config_data.get("adminPort"), 10001)
self.logDebug = _bool(config_data.get("logDebug"), False)
self.isEnableLoginCaptcha = _bool(config_data.get("isEnableLoginCaptcha"), True)
self.isEnableUpdatePopup = _bool(config_data.get("isEnableUpdatePopup"), False)
# Both flags require an explicit local opt-in. Endpoints are environment-only.
self.telemetryEnabled = _bool(config_data.get("telemetryEnabled"), False)
self.updateCheckEnabled = _bool(config_data.get("updateCheckEnabled"), False)
self.autoAddStreamProxy = _bool(config_data.get("autoAddStreamProxy"), True)
self.autoAddStreamProxySleep = _int(config_data.get("autoAddStreamProxySleep"), 15)
# —— 路径与工具 ——
self.ffmpeg = str(os.environ.get("MONITOR_FFMPEG") or config_data.get("ffmpeg", "ffmpeg") or "ffmpeg").strip()
2026-09-04 18:16:14 +08:00
if DESKTOP:
self.ffmpeg = str(RESOURCE_ROOT / "tools" / "ffmpeg.exe")
self.fontPath = _resolve_path(config_data.get("fontPath", ""), str(RESOURCE_ROOT))
2026-08-30 22:22:11 +08:00
self.uploadDir = _resolve_path(config_data.get("uploadDir", "static/upload"), base)
self.storageDir = _resolve_path(config_data.get("storageDir", "static/storage"), base)
# —— 流媒体 ZLM ——
self.mediaHttpPort = _int(config_data.get("mediaHttpPort"), 10002)
self.mediaRtspPort = _int(config_data.get("mediaRtspPort"), 10554)
self.mediaRtmpPort = _int(config_data.get("mediaRtmpPort"), 10935)
self.isEnableMediaProxyRtmp = _bool(config_data.get("isEnableMediaProxyRtmp"), False)
self.mediaSecret = get_runtime_secret("media_secret")
2026-09-04 18:16:14 +08:00
self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), str(RESOURCE_ROOT))
if DESKTOP:
self.mediaStartPath = str(RESOURCE_ROOT / "zlm" / "monitor_zlm.exe")
2026-08-30 22:22:11 +08:00
self.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base)
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
2026-09-04 18:16:14 +08:00
if DESKTOP:
self.autoStartMedia = True
2026-08-30 22:22:11 +08:00
self.adminHost = "http://" + self.internalHost + ":" + str(self.adminPort)
self.mediaHttpHost = "http://" + self.internalHost + ":" + str(self.mediaHttpPort)
# —— 视频分析 ——
self.analysisTargetFps = _int(config_data.get("analysisTargetFps"), 5)
self.analysisConfThreshold = _float(config_data.get("analysisConfThreshold"), 0.4)
self.analysisProcessMode = _int(config_data.get("analysisProcessMode"), 1)
self.analysisSharedInference = _bool(config_data.get("analysisSharedInference"), True)
2026-09-04 18:16:14 +08:00
self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 1))
2026-08-30 22:22:11 +08:00
# —— 录像 ——
self.recordingEnabled = _bool(config_data.get("recordingEnabled"), False)
self.recordingSegmentSeconds = max(60, _int(config_data.get("recordingSegmentSeconds"), 600))
self.recordingRetainDays = max(0, _int(config_data.get("recordingRetainDays"), 7))
self.recordingRetainGb = max(0.0, _float(config_data.get("recordingRetainGb"), 0.0))
# —— GB28181 SIP ——
__sip = config_data.get("sipServer") or {}
self.sipServer = {
"sipServerIp": str(__sip.get("sipServerIp", "127.0.0.1")).strip(),
"sipServerPort": _int(__sip.get("sipServerPort"), 15060),
"sipServerNonce": get_runtime_secret("sip_server_nonce"),
"sipTransferMode": _int(__sip.get("sipTransferMode"), 0),
"sipServerId": str(__sip.get("sipServerId", "34020000002000000001")).strip(),
"sipServerRealm": str(__sip.get("sipServerRealm", "3402000000")).strip(),
"sipServerPass": get_runtime_secret("sip_server_password"),
"sipServerTimeout": _int(__sip.get("sipServerTimeout"), 1800),
"sipServerExpiry": _int(__sip.get("sipServerExpiry"), 3600),
"rtpTransferMode": _int(__sip.get("rtpTransferMode"), 0),
"rtpTransferAudioType": _int(__sip.get("rtpTransferAudioType"), 0),
"autoInviteAfterRecCateLog": _bool(__sip.get("autoInviteAfterRecCateLog"), True),
}
self._ensure_storage_dirs()
self._sync_media_server_config()
def _sync_media_server_config(self):
"""Inject runtime-only secrets into the selected ZLM config.
ZLM supports a static hook URL but cannot produce our per-request HMAC,
so its two hooks carry the high-entropy token and are additionally
restricted to a direct loopback peer by Django middleware.
"""
source_path = self.mediaStartConfigPath
if not source_path or not os.path.isfile(source_path):
return
try:
lines = None
source_encoding = "utf-8"
for encoding in ("utf-8", "gbk"):
try:
with open(source_path, "r", encoding=encoding) as f:
lines = f.readlines()
source_encoding = encoding
break
except UnicodeDecodeError:
continue
if lines is None:
raise RuntimeError("unsupported config encoding")
section = ""
token = quote(self.internalApiSecret, safe="")
hook_urls = {
"on_publish": "http://127.0.0.1:%d/inner/on_publish?token=%s" % (self.adminPort, token),
"on_stream_not_found": "http://127.0.0.1:%d/inner/on_stream_not_found?token=%s" % (self.adminPort, token),
}
output = []
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1].strip().lower()
if "=" in line and not stripped.startswith(";"):
key = line.split("=", 1)[0].strip()
if section == "api" and key == "secret":
newline = "secret=%s\n" % self.mediaSecret
line = newline
elif section == "hook" and key in hook_urls:
newline = "%s=%s\n" % (key, hook_urls[key])
line = newline
output.append(line)
runtime_dir = os.path.join(str(BASE_DIR), ".runtime")
os.makedirs(runtime_dir, exist_ok=True)
runtime_path = os.path.join(runtime_dir, "zlm-config.ini")
temp = runtime_path + ".tmp"
with open(temp, "w", encoding=source_encoding, newline="") as f:
f.writelines(output)
os.replace(temp, runtime_path)
self.mediaStartConfigPath = runtime_path
except Exception as e:
raise RuntimeError("failed to secure ZLM config %s: %s" % (source_path, e))
def _ensure_storage_dirs(self):
for d in (self.uploadDir, self.storageDir):
if d and not os.path.exists(d):
os.makedirs(d, exist_ok=True)
self.uploadAlgorithmWeightDir = os.path.join(self.uploadDir, "weight")
self.uploadAudioDir = os.path.join(self.uploadDir, "audio")
self.uploadAudioDir_www = "/upload/audio/"
self.storageTempDir = os.path.join(self.storageDir, "temp")
self.storageAlarmDir = os.path.join(self.storageDir, "alarm")
self.storageSnapshotsDir = os.path.join(self.storageDir, "snapshots")
self.storageRecordDir = os.path.join(self.storageDir, "record")
for d in (self.storageTempDir, self.storageAlarmDir, self.storageSnapshotsDir, self.storageRecordDir):
if not os.path.exists(d):
os.makedirs(d, exist_ok=True)
self.storageDir_www = "/storage/openAccess?filename="
def getStr(self):
return json.dumps(self.to_dict(), ensure_ascii=False)
def to_dict(self, include_secrets=False):
"""供启动配置页 / API 使用的完整配置快照。"""
d = dict(self.__config_data)
d.setdefault("sipServer", {})
d.pop("safe", None)
d.pop("internalApiSecret", None)
d.pop("mediaSecret", None)
# 布尔/int 与内存态保持一致
d["host"] = self.externalHost
d["autoAddStreamProxy"] = self.autoAddStreamProxy
d["autoAddStreamProxySleep"] = self.autoAddStreamProxySleep
d["isEnableLoginCaptcha"] = self.isEnableLoginCaptcha
d["logDebug"] = self.logDebug
d["isEnableUpdatePopup"] = self.isEnableUpdatePopup
d["telemetryEnabled"] = self.telemetryEnabled
d["updateCheckEnabled"] = self.updateCheckEnabled
d["isEnableMediaProxyRtmp"] = self.isEnableMediaProxyRtmp
d["autoStartMedia"] = self.autoStartMedia
d["analysisSharedInference"] = self.analysisSharedInference
d["recordingEnabled"] = self.recordingEnabled
d["sipServer"] = dict(self.sipServer)
d["sipServer"].pop("sipServerPass", None)
d["sipServer"].pop("sipServerNonce", None)
if include_secrets:
d["internalApiSecret"] = self.internalApiSecret
d["mediaSecret"] = self.mediaSecret
d["sipServer"]["sipServerPass"] = self.sipServer["sipServerPass"]
d["sipServer"]["sipServerNonce"] = self.sipServer["sipServerNonce"]
return d
def save_from_web(self, params):
"""合并 Web 表单并写回 config.json保留未在表单中的键"""
data = dict(self.__config_data)
p = params or {}
def _set(key, val):
data[key] = val
for key in ("ffmpeg",):
if key in p:
_set(key, str(p.get(key) or "").strip())
if "host" in p:
_set("host", str(p.get("host") or "").strip())
if "uploadDir" in p:
_set("uploadDir", str(p.get("uploadDir") or "").strip())
if "storageDir" in p:
_set("storageDir", str(p.get("storageDir") or "").strip())
if "fontPath" in p:
_set("fontPath", str(p.get("fontPath") or "").strip())
if "mediaStartPath" in p:
_set("mediaStartPath", str(p.get("mediaStartPath") or "").strip())
if "mediaStartConfigPath" in p:
_set("mediaStartConfigPath", str(p.get("mediaStartConfigPath") or "").strip())
int_keys = (
"adminPort", "mediaHttpPort", "mediaRtspPort", "mediaRtmpPort",
"autoAddStreamProxySleep",
"analysisTargetFps", "analysisProcessMode", "analysisInferenceWorkers",
"recordingSegmentSeconds", "recordingRetainDays",
)
for key in int_keys:
if key in p:
_set(key, _int(p.get(key), data.get(key)))
float_keys = ("analysisConfThreshold", "recordingRetainGb")
for key in float_keys:
if key in p:
_set(key, _float(p.get(key), data.get(key)))
bool_keys = (
"autoAddStreamProxy", "isEnableLoginCaptcha", "logDebug", "isEnableUpdatePopup",
"telemetryEnabled", "updateCheckEnabled",
"isEnableMediaProxyRtmp", "autoStartMedia",
"analysisSharedInference", "recordingEnabled",
)
for key in bool_keys:
if key in p:
_set(key, _bool(p.get(key), _bool(data.get(key))))
sip = dict(data.get("sipServer") or {})
sip_map = {
"sipServerIp": "sipServerIp",
"sipServerPort": ("sipServerPort", _int),
"sipTransferMode": ("sipTransferMode", _int),
"sipServerId": "sipServerId",
"sipServerRealm": "sipServerRealm",
"sipServerTimeout": ("sipServerTimeout", _int),
"sipServerExpiry": ("sipServerExpiry", _int),
"rtpTransferMode": ("rtpTransferMode", _int),
"rtpTransferAudioType": ("rtpTransferAudioType", _int),
"autoInviteAfterRecCateLog": ("autoInviteAfterRecCateLog", _bool),
}
for param_key, spec in sip_map.items():
if param_key not in p:
continue
if isinstance(spec, tuple):
sip_key, conv = spec
sip[sip_key] = conv(p.get(param_key), sip.get(sip_key))
else:
sip[spec] = str(p.get(param_key) or "").strip()
data["sipServer"] = sip
for _k in (
"install", "code", "name", "describe", "safe", "internalApiSecret",
"mediaSecret",
):
data.pop(_k, None)
for _k in ("sipServerPass", "sipServerNonce"):
data["sipServer"].pop(_k, None)
with open(self.__filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
self.__config_data = data
self._apply(data)