Browse Source

实时预览优先直连摄像机取流,绕开CVR远程转发额度瓶颈

master
wangguangyuan 2 weeks ago
parent
commit
06ae73b8f3
5 changed files with 233 additions and 25 deletions
  1. +12
    -0
      .env.example
  2. +12
    -0
      docker-compose.yml
  3. +177
    -24
      src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java
  4. +20
    -0
      src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java
  5. +12
    -1
      src/main/resources/application.yml

+ 12
- 0
.env.example View File

@ -13,6 +13,18 @@ ZLM_HTTP_BASE_URL=http://192.168.1.200:18081
# An empty secret makes .../ui/actions/live/start fail fast with ZLM_PUSH_FAILED. # An empty secret makes .../ui/actions/live/start fail fast with ZLM_PUSH_FAILED.
ZLM_API_SECRET=change_me ZLM_API_SECRET=change_me
# Live preview: how many simultaneous streams one NVR is allowed to serve through this service.
# Field measurements show some NVRs only accept 1 remote RTSP stream at a time; set this to the
# value measured for the site so that extra windows queue up instead of being refused by the device.
HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR=1
# Live preview pulls RTSP directly from each camera (ivs_camera.dev_ip) first and automatically
# falls back to the NVR/CVR path when the camera is unreachable or rejects the credentials.
# Keep this enabled for sites whose recorder is a CVR (central storage) with a tiny remote
# forward quota; set to false to always stream through the NVR.
HIK_NVR_LIVE_PREFER_CAMERA_DIRECT=true
HIK_NVR_LIVE_CAMERA_RTSP_PORT=554
# Maven mirror used while building the image # Maven mirror used while building the image
MAVEN_MIRROR_URL=https://maven.aliyun.com/repository/public MAVEN_MIRROR_URL=https://maven.aliyun.com/repository/public
# MAVEN_MIRROR_URL=https://repo.maven.apache.org/maven2 # MAVEN_MIRROR_URL=https://repo.maven.apache.org/maven2


+ 12
- 0
docker-compose.yml View File

@ -24,6 +24,18 @@ services:
ZLM_HTTP_BASE_URL: ${ZLM_HTTP_BASE_URL:?remote_ZLM_HTTP_BASE_URL_required_in_.env} ZLM_HTTP_BASE_URL: ${ZLM_HTTP_BASE_URL:?remote_ZLM_HTTP_BASE_URL_required_in_.env}
# 服务端调用 ZLMediaKit API 校验实时流是否注册;未配置时实时预览会直接返回配置错误。 # 服务端调用 ZLMediaKit API 校验实时流是否注册;未配置时实时预览会直接返回配置错误。
ZLM_API_SECRET: ${ZLM_API_SECRET:-} ZLM_API_SECRET: ${ZLM_API_SECRET:-}
# 实时预览参数。注意:Compose 只把这里显式列出的变量传入容器,写在 .env 里但没在这里
# 列出的 HIK_NVR_* 变量不会生效。默认值与 application.yml 保持一致,且必须非空,
# 否则 Spring 会把空字符串绑定到整型配置导致启动失败。
# 单台 NVR 允许同时播放的实时流上限(现场实测部分 NVR 只给 1 路),0 表示不限制。
HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR: ${HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR:-8}
HIK_NVR_LIVE_MAX_ATTEMPTS: ${HIK_NVR_LIVE_MAX_ATTEMPTS:-3}
HIK_NVR_LIVE_STOP_GRACE_MILLIS: ${HIK_NVR_LIVE_STOP_GRACE_MILLIS:-2000}
HIK_NVR_LIVE_IDLE_REAP_ENABLED: ${HIK_NVR_LIVE_IDLE_REAP_ENABLED:-true}
HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS: ${HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS:-600000}
# 实时预览优先直连摄像机取流(CVR 等设备远程转发额度小,直连可绕开该瓶颈)。
HIK_NVR_LIVE_PREFER_CAMERA_DIRECT: ${HIK_NVR_LIVE_PREFER_CAMERA_DIRECT:-true}
HIK_NVR_LIVE_CAMERA_RTSP_PORT: ${HIK_NVR_LIVE_CAMERA_RTSP_PORT:-554}
JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -Djava.library.path=/app/lib:/app/lib/HCNetSDKCom:/app/lib/linux64} JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -Djava.library.path=/app/lib:/app/lib/HCNetSDKCom:/app/lib/linux64}
REDIS_HOST: ${REDIS_HOST:?REDIS_HOST_required_in_.env} REDIS_HOST: ${REDIS_HOST:?REDIS_HOST_required_in_.env}
REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PORT: ${REDIS_PORT:-6379}


+ 177
- 24
src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java View File

@ -8,8 +8,11 @@ import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse;
import com.inspect.nvr.domain.ivs.IvsVideoRtspVo; import com.inspect.nvr.domain.ivs.IvsVideoRtspVo;
import com.inspect.nvr.hik.domain.HikNvrCameraTarget; import com.inspect.nvr.hik.domain.HikNvrCameraTarget;
import com.inspect.nvr.hik.domain.HikNvrLiveStreamResponse; import com.inspect.nvr.hik.domain.HikNvrLiveStreamResponse;
import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest;
import com.inspect.nvr.hik.exception.HikNvrPlaybackException; import com.inspect.nvr.hik.exception.HikNvrPlaybackException;
import com.inspect.nvr.hik.vo.HikNvrLiveStreamConfigVo; import com.inspect.nvr.hik.vo.HikNvrLiveStreamConfigVo;
import com.inspect.nvr.ivs.mapper.IvsCameraMapper;
import com.inspect.nvr.ivs.pojo.IvsCamera;
import com.inspect.nvr.ivs.service.IvsOpenApiService; import com.inspect.nvr.ivs.service.IvsOpenApiService;
import com.inspect.nvr.service.HikLoginService; import com.inspect.nvr.service.HikLoginService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -23,6 +26,8 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -33,6 +38,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@ -70,6 +76,10 @@ public class HikNvrLiveStreamService {
private static final String NVR_LOGIN_FAILED = "NVR_LOGIN_FAILED"; private static final String NVR_LOGIN_FAILED = "NVR_LOGIN_FAILED";
/** 单台 NVR 起流队列已满时返回的稳定错误码。 */ /** 单台 NVR 起流队列已满时返回的稳定错误码。 */
private static final String NVR_START_QUEUE_FULL = "NVR_START_QUEUE_FULL"; private static final String NVR_START_QUEUE_FULL = "NVR_START_QUEUE_FULL";
/** 取流来源:直连摄像机。 */
private static final String SOURCE_CAMERA = "camera";
/** 取流来源:经 IVS/NVR(含 CVR 等中心存储设备)转发。 */
private static final String SOURCE_NVR = "nvr";
/** 从 SDK 异常链中提取海康原始错误码。 */ /** 从 SDK 异常链中提取海康原始错误码。 */
private static final Pattern SDK_ERROR_PATTERN = private static final Pattern SDK_ERROR_PATTERN =
Pattern.compile("错误码[::]\\s*(\\d+)"); Pattern.compile("错误码[::]\\s*(\\d+)");
@ -86,6 +96,12 @@ public class HikNvrLiveStreamService {
/** 实时拉流配置。 */ /** 实时拉流配置。 */
@Resource @Resource
private HikNvrLiveStreamConfigVo config; private HikNvrLiveStreamConfigVo config;
/** 读取 ivs_camera.dev_ip,用于优先直连摄像机取流。 */
@Resource
private IvsCameraMapper ivsCameraMapper;
/** 复用既有海康 RTSP 地址构造器,保证直连摄像机的鉴权编码规则一致。 */
@Resource
private HikPlaybackUrlBuilder urlBuilder;
/** FFmpeg 可执行文件路径。 */ /** FFmpeg 可执行文件路径。 */
private String ffmpegPath; private String ffmpegPath;
@ -119,6 +135,18 @@ public class HikNvrLiveStreamService {
private boolean idleReapEnabled; private boolean idleReapEnabled;
/** 无人观看多少毫秒后回收实时流。 */ /** 无人观看多少毫秒后回收实时流。 */
private long idleReaderTimeoutMillis; private long idleReaderTimeoutMillis;
/** 是否优先直连摄像机取流。 */
private boolean preferCameraDirect;
/** 直连摄像机使用的 RTSP 端口。 */
private int cameraRtspPort;
/** 直连摄像机使用的账号;为空时沿用 NVR 账号。 */
private String cameraUsername;
/** 直连摄像机使用的密码;为空时沿用 NVR 密码。 */
private String cameraPassword;
/** 直连摄像机 RTSP 端口连通性探测超时。 */
private int cameraProbeTimeoutMillis;
/** 已确认直连鉴权失败(相机凭据与 NVR 不同)的摄像机 IP,避免反复浪费一次尝试。 */
private final Set<String> cameraDirectAuthFailed = ConcurrentHashMap.newKeySet();
/** 按 IVS 完整 cameraCode 保存本服务拥有的 FFmpeg 拉流会话。 */ /** 按 IVS 完整 cameraCode 保存本服务拥有的 FFmpeg 拉流会话。 */
private final Map<String, LiveSession> sessions = new ConcurrentHashMap<>(); private final Map<String, LiveSession> sessions = new ConcurrentHashMap<>();
@ -159,6 +187,17 @@ public class HikNvrLiveStreamService {
this.maxSessionsPerNvr = Math.max(0, config.getMaxSessionsPerNvr()); this.maxSessionsPerNvr = Math.max(0, config.getMaxSessionsPerNvr());
this.idleReapEnabled = config.isIdleReapEnabled(); this.idleReapEnabled = config.isIdleReapEnabled();
this.idleReaderTimeoutMillis = Math.max(10000L, config.getIdleReaderTimeoutMillis()); this.idleReaderTimeoutMillis = Math.max(10000L, config.getIdleReaderTimeoutMillis());
// CVR 等设备远程转发额度很小默认优先直连摄像机取流不可达或鉴权失败时无缝回退 NVR
this.preferCameraDirect = config.isPreferCameraDirect();
// RTSP 端口必须落在合法范围内否则直连探测和地址拼装都会失败
this.cameraRtspPort = config.getCameraRtspPort() > 0
&& config.getCameraRtspPort() <= 65535 ? config.getCameraRtspPort() : 554;
this.cameraUsername = config.getCameraUsername() == null
? "" : config.getCameraUsername().trim();
this.cameraPassword = config.getCameraPassword() == null
? "" : config.getCameraPassword();
// 探测必须显著短于启动时限避免相机不可达时拖慢起流
this.cameraProbeTimeoutMillis = Math.max(100, config.getCameraProbeTimeoutMillis());
} }
/** /**
@ -387,13 +426,6 @@ public class HikNvrLiveStreamService {
if (attempt > 1 && !lane.awaitRelease(deadline, retryWaitMillis(attempt))) { if (attempt > 1 && !lane.awaitRelease(deadline, retryWaitMillis(attempt))) {
break; break;
} }
// 本服务限制单台 NVR 的并发实时流数量避免一次把设备远程预览预算打满
if (!awaitSessionQuota(nvrIp, deadline)) {
throw HikNvrPlaybackException.conflict(
NVR_LIVE_SESSION_LIMIT,
NVR_LIVE_SESSION_LIMIT + ":该NVR并发实时流已达上限"
+ maxSessionsPerNvr + "路,请先关闭其他窗口后重试");
}
// 相同 NVR 的相邻握手保持配置间隔避免设备瞬时并发 SETUP 500 // 相同 NVR 的相邻握手保持配置间隔避免设备瞬时并发 SETUP 500
if (!lane.awaitSpacing(deadline, startSpacingMillis)) { if (!lane.awaitSpacing(deadline, startSpacingMillis)) {
break; break;
@ -401,11 +433,25 @@ public class HikNvrLiveStreamService {
// 记录本次尝试前的日志长度失败分类只看本次新增的 stderr避免上一次尝试的原始错误干扰判断 // 记录本次尝试前的日志长度失败分类只看本次新增的 stderr避免上一次尝试的原始错误干扰判断
int logOffset = readLog(logFile).length(); int logOffset = readLog(logFile).length();
String rtspUrl;
String rtspUrl = null;
String source = SOURCE_NVR;
CameraDirectTarget cameraTarget = null;
try { try {
// 通过 IVS 厂商路由生成当前码流的 RTSP 地址海康路径会复用 SDK 登录布局
rtspUrl = resolveLiveRtspUrl(
pending.target.getCameraCode(), pending.streamType);
// 首次尝试优先直连摄像机CVR中心存储等设备的远程转发额度很小
// 直连摄像机可以完全绕开该瓶颈多台相机之间也互不抢额度
if (preferCameraDirect && attempt == 1) {
cameraTarget = resolveCameraDirectTarget(pending.target, pending.streamType);
if (cameraTarget != null) {
rtspUrl = cameraTarget.rtspUrl;
source = SOURCE_CAMERA;
}
}
// 相机无直连地址RTSP 端口不可达或已知鉴权失败时保持原有 IVS/NVR 取流路径
if (rtspUrl == null) {
rtspUrl = resolveLiveRtspUrl(
pending.target.getCameraCode(), pending.streamType);
source = SOURCE_NVR;
}
} catch (RuntimeException resolveFailure) { } catch (RuntimeException resolveFailure) {
lastFailure = classifyFailure( lastFailure = classifyFailure(
readLogFrom(logFile, logOffset), ZlmProbeResult.ABSENT, resolveFailure); readLogFrom(logFile, logOffset), ZlmProbeResult.ABSENT, resolveFailure);
@ -417,6 +463,15 @@ public class HikNvrLiveStreamService {
} }
String rtmpUrl = rtmpBaseUrl + "/live/" + pending.streamId; String rtmpUrl = rtmpBaseUrl + "/live/" + pending.streamId;
// 并发额度按"实际拉取的设备"统计直连时是相机 IP走转发时才是 NVR/CVR IP
// 否则直连摄像机取流会被所属 NVR 的额度限制住等于没有绕开 CVR 的瓶颈
String streamHost = cameraTarget == null ? nvrIp : cameraTarget.devIp;
if (!awaitSessionQuota(streamHost, deadline)) {
throw HikNvrPlaybackException.conflict(
NVR_LIVE_SESSION_LIMIT,
NVR_LIVE_SESSION_LIMIT + ":取流设备" + streamHost + "的并发实时流已达上限"
+ maxSessionsPerNvr + "路,请先关闭其他窗口后重试");
}
Process process; Process process;
try { try {
// 启动 FFmpeg 并把各次尝试的原始 stderr 追加到同一个每路日志文件 // 启动 FFmpeg 并把各次尝试的原始 stderr 追加到同一个每路日志文件
@ -434,6 +489,7 @@ public class HikNvrLiveStreamService {
LiveSession session = new LiveSession( LiveSession session = new LiveSession(
pending.target.getCameraCode(), pending.target.getCameraCode(),
pending.target.getNvrInfo().getNvrIp(), pending.target.getNvrInfo().getNvrIp(),
streamHost,
pending.target.getChannel(), pending.target.getChannel(),
pending.streamType, pending.streamType,
pending.streamId, pending.streamId,
@ -442,9 +498,10 @@ public class HikNvrLiveStreamService {
pending.flvUrl); pending.flvUrl);
// 注册本地会话后停止和状态请求即可立即定位并释放正在启动的进程 // 注册本地会话后停止和状态请求即可立即定位并释放正在启动的进程
sessions.put(sessionKey, session); sessions.put(sessionKey, session);
log.info("[live] started cameraCode={} nvrIp={} streamType={} streamId={} attempt={} log={}",
session.cameraCode, session.nvrIp, session.streamType,
session.streamId, attempt, logFile);
log.info("[live] started cameraCode={} nvrIp={} host={} source={} streamType={} streamId={} attempt={} log={}",
session.cameraCode, session.nvrIp,
cameraTarget == null ? session.nvrIp : cameraTarget.devIp,
source, session.streamType, session.streamId, attempt, logFile);
long remaining = Math.max(0L, deadline - System.currentTimeMillis()); long remaining = Math.max(0L, deadline - System.currentTimeMillis());
// 第一次最多使用剩余时间的一半为失效登录并重试保留预算 // 第一次最多使用剩余时间的一半为失效登录并重试保留预算
@ -464,6 +521,12 @@ public class HikNvrLiveStreamService {
// 未注册或进程退出时立即销毁 FFmpeg快速断开 NVR RTSP 会话 // 未注册或进程退出时立即销毁 FFmpeg快速断开 NVR RTSP 会话
stopInternal(sessionKey); stopInternal(sessionKey);
lastFailure = classifyFailure(readLogFrom(logFile, logOffset), probe, null); lastFailure = classifyFailure(readLogFrom(logFile, logOffset), probe, null);
// 相机直连鉴权失败说明该相机凭据与 NVR 不同记住后在后续起流中直接走 NVR避免反复浪费一次尝试
if (cameraTarget != null && NVR_LOGIN_FAILED.equals(lastFailure.getErrorCode())) {
cameraDirectAuthFailed.add(cameraTarget.devIp);
log.warn("[live] 相机直连鉴权失败,后续改用NVR取流 cameraCode={} cameraIp={}",
pending.target.getCameraCode(), cameraTarget.devIp);
}
// 设备端拒绝建立实时流属于瞬时可恢复的故障允许退避重试 // 设备端拒绝建立实时流属于瞬时可恢复的故障允许退避重试
// 登录网络通道无数据等其他分类保持原有的一次重登重试 // 登录网络通道无数据等其他分类保持原有的一次重登重试
int attemptLimit = isDeviceRefusal(lastFailure.getErrorCode()) int attemptLimit = isDeviceRefusal(lastFailure.getErrorCode())
@ -1141,20 +1204,20 @@ public class HikNvrLiveStreamService {
} }
/** /**
* 等待目标 NVR 的并发实时流名额
* 等待指定取流设备的并发实时流名额
* *
* @param nvrIp 目标 NVR 地址
* @param host 实际取流设备地址相机 IP NVR/CVR IP
* @param deadline 本次起流总截止时间 * @param deadline 本次起流总截止时间
* @return 取得名额或不限制并发时返回 true * @return 取得名额或不限制并发时返回 true
*/ */
private boolean awaitSessionQuota(String nvrIp, long deadline) {
private boolean awaitSessionQuota(String host, long deadline) {
// 上限为 0 表示不限制保持历史行为兼容 // 上限为 0 表示不限制保持历史行为兼容
if (maxSessionsPerNvr <= 0) { if (maxSessionsPerNvr <= 0) {
return true; return true;
} }
// 名额未满即可继续已满则等其他窗口释放避免直接冲击设备 // 名额未满即可继续已满则等其他窗口释放避免直接冲击设备
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
if (activeSessionsOn(nvrIp) < maxSessionsPerNvr) {
if (activeSessionsOn(host) < maxSessionsPerNvr) {
return true; return true;
} }
try { try {
@ -1168,16 +1231,16 @@ public class HikNvrLiveStreamService {
} }
/** /**
* 统计仍在运行且属于指定 NVR 的本地实时流数量
* 统计仍在运行且正在从指定设备取流的本地实时流数量
* *
* @param nvrIp 目标 NVR 地址
* @param host 实际取流设备地址相机 IP NVR/CVR IP
* @return 本地活跃会话数 * @return 本地活跃会话数
*/ */
private int activeSessionsOn(String nvrIp) {
private int activeSessionsOn(String host) {
int count = 0; int count = 0;
// 本地会话表规模很小逐条比对 NVR 地址即可
// 本地会话表规模很小逐条比对取流设备地址即可
for (LiveSession session : sessions.values()) { for (LiveSession session : sessions.values()) {
if (session.isAlive() && nvrIp.equalsIgnoreCase(session.nvrIp)) {
if (session.isAlive() && host.equalsIgnoreCase(session.sourceHost)) {
count++; count++;
} }
} }
@ -1219,6 +1282,78 @@ public class HikNvrLiveStreamService {
return offset >= text.length() ? "" : text.substring(offset); return offset >= text.length() ? "" : text.substring(offset);
} }
/**
* 解析摄像机直连取流目标
*
* <p>直连地址来自 {@code ivs_camera.dev_ip}凭据默认沿用所属 NVR 的数据库记录
* 与项目既有直连抓图3D 定位的做法一致可用配置覆盖只有在地址存在且 RTSP
* 端口探测可达时才返回结果其余情况返回 {@code null} 让调用方回退 NVR 取流</p>
*
* @param target 已解析的摄像机目标
* @param streamType 主码流 1 或子码流 2
* @return 可直连的摄像机目标不可直连时返回 null
*/
private CameraDirectTarget resolveCameraDirectTarget(
HikNvrCameraTarget target, int streamType) {
IvsCamera camera;
try {
// 摄像机表查询失败不能影响实时预览直接回退 NVR 路径
camera = ivsCameraMapper.selectByCode(target.getCameraCode());
} catch (RuntimeException queryFailure) {
log.warn("[live] 读取 ivs_camera 失败,回退NVR取流 cameraCode={}:{}",
target.getCameraCode(), queryFailure.getMessage());
return null;
}
// 没有设备 IP 时无法直连保持原有 NVR 取流行为
if (camera == null || !hasText(camera.getDevIP())) {
return null;
}
String devIp = camera.getDevIP().trim();
// 已确认该相机凭据不可用时不再重复尝试直连
if (cameraDirectAuthFailed.contains(devIp)) {
return null;
}
// RTSP 端口不可达未开放或被网络隔离时直接回退避免白等一次尝试
if (!isCameraRtspReachable(devIp)) {
return null;
}
NvrInfo nvrInfo = target.getNvrInfo();
String username = hasText(cameraUsername)
? cameraUsername : nvrInfo.getAccount();
String password = hasText(cameraPassword)
? cameraPassword : nvrInfo.getPassword();
// 账号缺失时无法建立 RTSP 会话回退 NVR 路径由 IVS 侧给出明确错误
if (!hasText(username) || password == null) {
return null;
}
// 复用统一 URL 构造器保证中文或特殊字符账号的百分号编码规则与 NVR 路径一致
HikNvrPlaybackRequest cameraRequest = HikNvrPlaybackRequest.builder()
.nvrIp(devIp)
.rtspPort(cameraRtspPort)
.username(username)
.password(password)
.streamType(streamType)
.build();
// 直连摄像机只有一个通道trackId = 1 * 100 + 码流类型 101/102
return new CameraDirectTarget(devIp, urlBuilder.buildLive(cameraRequest, 1));
}
/**
* 探测摄像机 RTSP 端口是否可达
*
* @param devIp 摄像机 IP
* @return 限定时间内可建立 TCP 连接时返回 true
*/
private boolean isCameraRtspReachable(String devIp) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(devIp, cameraRtspPort), cameraProbeTimeoutMillis);
return true;
} catch (Exception unreachable) {
// 不可达被拒绝或超时都按"不可直连"处理由调用方回退 NVR
return false;
}
}
/** /**
* 调用现有 IVS 实时媒体能力取得 cameraCode 对应的 RTSP 地址 * 调用现有 IVS 实时媒体能力取得 cameraCode 对应的 RTSP 地址
* *
@ -1496,6 +1631,20 @@ public class HikNvrLiveStreamService {
UNAVAILABLE UNAVAILABLE
} }
/** 一次直连摄像机取流的目标地址。 */
private static final class CameraDirectTarget {
/** 摄像机 IP,用于鉴权失败后记忆该相机。 */
private final String devIp;
/** 可直接交给 FFmpeg 的摄像机 RTSP 地址。 */
private final String rtspUrl;
/** 保存摄像机 IP 与直连 RTSP 地址。 */
private CameraDirectTarget(String devIp, String rtspUrl) {
this.devIp = devIp;
this.rtspUrl = rtspUrl;
}
}
/** ZLMediaKit 单次流查询的完整结果,包含注册状态和当前观众数量。 */ /** ZLMediaKit 单次流查询的完整结果,包含注册状态和当前观众数量。 */
private static final class ZlmStreamProbe { private static final class ZlmStreamProbe {
/** 查询结果分类。 */ /** 查询结果分类。 */
@ -1514,8 +1663,10 @@ public class HikNvrLiveStreamService {
private static final class LiveSession { private static final class LiveSession {
/** IVS 完整摄像机编码。 */ /** IVS 完整摄像机编码。 */
private final String cameraCode; private final String cameraCode;
/** 摄像机所属 NVR IP。 */
/** 摄像机所属 NVR/CVR IP,用于定位设备与展示。 */
private final String nvrIp; private final String nvrIp;
/** 本路流实际拉取的设备地址:直连摄像机时为相机 IP,经转发时为 NVR/CVR IP。 */
private final String sourceHost;
/** 页面逻辑通道号。 */ /** 页面逻辑通道号。 */
private final Integer channel; private final Integer channel;
/** 主码流 1 或子码流 2。 */ /** 主码流 1 或子码流 2。 */
@ -1535,6 +1686,7 @@ public class HikNvrLiveStreamService {
private LiveSession( private LiveSession(
String cameraCode, String cameraCode,
String nvrIp, String nvrIp,
String sourceHost,
Integer channel, Integer channel,
int streamType, int streamType,
String streamId, String streamId,
@ -1543,6 +1695,7 @@ public class HikNvrLiveStreamService {
String flvUrl) { String flvUrl) {
this.cameraCode = cameraCode; this.cameraCode = cameraCode;
this.nvrIp = nvrIp; this.nvrIp = nvrIp;
this.sourceHost = sourceHost;
this.channel = channel; this.channel = channel;
this.streamType = streamType; this.streamType = streamType;
this.streamId = streamId; this.streamId = streamId;


+ 20
- 0
src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java View File

@ -62,4 +62,24 @@ public class HikNvrLiveStreamConfigVo {
/** 无人观看多少毫秒后回收实时流。 */ /** 无人观看多少毫秒后回收实时流。 */
@Value("${hik.playback.live.idle-reader-timeout-millis:600000}") @Value("${hik.playback.live.idle-reader-timeout-millis:600000}")
private long idleReaderTimeoutMillis = 600000L; private long idleReaderTimeoutMillis = 600000L;
/**
* 实时预览是否优先直连摄像机取流
*
* <p>CVR中心存储等设备的远程转发额度很小直连摄像机可以避开该瓶颈直连不可达或
* 鉴权失败时会自动回退到原有的 IVS/NVR 取流路径</p>
*/
@Value("${hik.playback.live.prefer-camera-direct:true}")
private boolean preferCameraDirect = true;
/** 直连摄像机使用的 RTSP 端口。 */
@Value("${hik.playback.live.camera-rtsp-port:554}")
private int cameraRtspPort = 554;
/** 直连摄像机使用的账号;为空时沿用所属 NVR 的数据库账号。 */
@Value("${hik.playback.live.camera-username:}")
private String cameraUsername = "";
/** 直连摄像机使用的密码;为空时沿用所属 NVR 的数据库密码。 */
@Value("${hik.playback.live.camera-password:}")
private String cameraPassword = "";
/** 直连摄像机 RTSP 端口连通性探测超时时间。 */
@Value("${hik.playback.live.camera-probe-timeout-millis:500}")
private int cameraProbeTimeoutMillis = 500;
} }

+ 12
- 1
src/main/resources/application.yml View File

@ -66,13 +66,24 @@ hik:
retry-release-wait-max-millis: ${HIK_NVR_LIVE_RETRY_RELEASE_WAIT_MAX_MILLIS:5000} retry-release-wait-max-millis: ${HIK_NVR_LIVE_RETRY_RELEASE_WAIT_MAX_MILLIS:5000}
# 停流时等待 FFmpeg 优雅退出(发送 RTSP TEARDOWN)的最长时间(毫秒)。 # 停流时等待 FFmpeg 优雅退出(发送 RTSP TEARDOWN)的最长时间(毫秒)。
stop-grace-millis: ${HIK_NVR_LIVE_STOP_GRACE_MILLIS:2000} stop-grace-millis: ${HIK_NVR_LIVE_STOP_GRACE_MILLIS:2000}
# 单台 NVR 允许同时播放的实时流上限,0 表示不限制;请按设备远程预览预算调整。
# 单个取流设备允许同时播放的实时流上限,0 表示不限制;请按设备远程预览预算调整。
# 统计粒度是"实际拉取的设备":直连摄像机时为相机 IP,经转发时为 NVR/CVR IP。
max-sessions-per-nvr: ${HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR:8} max-sessions-per-nvr: ${HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR:8}
# 长时间无人观看(ZLMediaKit 观众数为 0)时回收实时流,避免浏览器异常退出后占住 NVR 会话。 # 长时间无人观看(ZLMediaKit 观众数为 0)时回收实时流,避免浏览器异常退出后占住 NVR 会话。
idle-reap-enabled: ${HIK_NVR_LIVE_IDLE_REAP_ENABLED:true} idle-reap-enabled: ${HIK_NVR_LIVE_IDLE_REAP_ENABLED:true}
idle-reader-timeout-millis: ${HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS:600000} idle-reader-timeout-millis: ${HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS:600000}
# 空闲回收扫描间隔(毫秒)。 # 空闲回收扫描间隔(毫秒)。
idle-reap-interval-millis: ${HIK_NVR_LIVE_IDLE_REAP_INTERVAL_MILLIS:60000} idle-reap-interval-millis: ${HIK_NVR_LIVE_IDLE_REAP_INTERVAL_MILLIS:60000}
# 实时预览是否优先直连摄像机取流:CVR(中心存储)等设备的远程转发额度很小,
# 直连摄像机可绕开该瓶颈;地址缺失、端口不可达或鉴权失败时自动回退 NVR。
prefer-camera-direct: ${HIK_NVR_LIVE_PREFER_CAMERA_DIRECT:true}
# 直连摄像机使用的 RTSP 端口。
camera-rtsp-port: ${HIK_NVR_LIVE_CAMERA_RTSP_PORT:554}
# 直连摄像机的账号密码;留空表示沿用所属 NVR 在数据库中的凭据。
camera-username: "${HIK_NVR_LIVE_CAMERA_USERNAME:}"
camera-password: "${HIK_NVR_LIVE_CAMERA_PASSWORD:}"
# 直连摄像机 RTSP 端口连通性探测超时(毫秒)。
camera-probe-timeout-millis: ${HIK_NVR_LIVE_CAMERA_PROBE_TIMEOUT_MILLIS:500}
clip: clip:
# 一分钟录像先由SDK下载到临时目录,再由FFmpeg统一封装为MP4。 # 一分钟录像先由SDK下载到临时目录,再由FFmpeg统一封装为MP4。
ffmpeg-path: ${FFMPEG_PATH:C:/tools/ffmpeg/bin/ffmpeg.exe} ffmpeg-path: ${FFMPEG_PATH:C:/tools/ffmpeg/bin/ffmpeg.exe}


Loading…
Cancel
Save