327 lines
14 KiB
Python
327 lines
14 KiB
Python
|
|
"""Monitor 启动配置 — 所有可配置项均来自 config.json,后台「启动配置」页可编辑。"""
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
from urllib.parse import quote
|
|||
|
|
|
|||
|
|
from framework.settings import BASE_DIR
|
|||
|
|
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()
|
|||
|
|
self.fontPath = _resolve_path(config_data.get("fontPath", ""), base)
|
|||
|
|
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")
|
|||
|
|
self.mediaStartPath = _resolve_path(config_data.get("mediaStartPath", ""), base)
|
|||
|
|
self.mediaStartConfigPath = _resolve_path(config_data.get("mediaStartConfigPath", ""), base)
|
|||
|
|
self.autoStartMedia = _bool(config_data.get("autoStartMedia"), False)
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
self.analysisInferenceWorkers = max(1, _int(config_data.get("analysisInferenceWorkers"), 2))
|
|||
|
|
|
|||
|
|
# —— 录像 ——
|
|||
|
|
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)
|