From 95a656bb31fe8d91f7d053ed428abc165184d772 Mon Sep 17 00:00:00 2001 From: wangguangyuan Date: Fri, 11 Sep 2026 09:18:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=9E=E6=97=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E9=BB=91=E7=AA=97=E4=B8=8ENVR=5FRTSP=5FBUSY=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + docker-compose.yml | 2 + .../controller/HikNvrUiActionController.java | 10 + .../hik/service/HikNvrLiveStreamService.java | 416 ++++++++++++++++-- .../nvr/hik/vo/HikNvrLiveStreamConfigVo.java | 26 ++ src/main/resources/application-docker.yml | 4 + src/main/resources/application.yml | 19 + src/main/resources/static/index.html | 123 +++++- 8 files changed, 555 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 3879345e..71e94233 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,12 @@ APP_PORT=8081 ZLM_RTMP_BASE_URL=rtmp://192.168.1.200:1935 ZLM_HTTP_BASE_URL=http://192.168.1.200:18081 +# ZLMediaKit HTTP API used by the service to verify that a live stream is really registered. +# The API address defaults to ZLM_HTTP_BASE_URL; if the container cannot reach that address, +# append -Dhik.playback.hls.api-base-url= to JAVA_TOOL_OPTIONS. +# An empty secret makes .../ui/actions/live/start fail fast with ZLM_PUSH_FAILED. +ZLM_API_SECRET=change_me + # Maven mirror used while building the image MAVEN_MIRROR_URL=https://maven.aliyun.com/repository/public # MAVEN_MIRROR_URL=https://repo.maven.apache.org/maven2 diff --git a/docker-compose.yml b/docker-compose.yml index 7588342b..5851bb55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: FFMPEG_PATH: /usr/bin/ffmpeg ZLM_RTMP_BASE_URL: ${ZLM_RTMP_BASE_URL:?remote_ZLM_RTMP_BASE_URL_required_in_.env} ZLM_HTTP_BASE_URL: ${ZLM_HTTP_BASE_URL:?remote_ZLM_HTTP_BASE_URL_required_in_.env} + # 服务端调用 ZLMediaKit API 校验实时流是否注册;未配置时实时预览会直接返回配置错误。 + ZLM_API_SECRET: ${ZLM_API_SECRET:-} 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_PORT: ${REDIS_PORT:-6379} diff --git a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java index aa771abe..3540d3b4 100644 --- a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java +++ b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java @@ -136,6 +136,16 @@ public class HikNvrUiActionController { return ResponseEntity.ok(liveStreamService.stop(cameraCode)); } + /** + * 停止本服务创建的全部实时拉流任务。 + */ + @PostMapping("/live/stop-all") + /** 运维与批量任务收尾使用:一次释放所有 NVR 实时会话。 */ + public ResponseEntity stopAllLive() { + // 批量清理不需要 cameraCode,直接释放全部会话和排队任务。 + return ResponseEntity.ok(liveStreamService.stopAll()); + } + /** * 查询指定通道的实时拉流状态。 */ diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java index 55d9583c..e9d5c0ff 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java @@ -13,6 +13,7 @@ import com.inspect.nvr.hik.vo.HikNvrLiveStreamConfigVo; import com.inspect.nvr.ivs.service.IvsOpenApiService; import com.inspect.nvr.service.HikLoginService; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; @@ -51,8 +52,14 @@ import java.util.concurrent.ConcurrentHashMap; @Service public class HikNvrLiveStreamService { - /** NVR 拒绝并发 RTSP SETUP 时返回的稳定错误码。 */ + /** NVR 明确返回连接数/客户端数超限时返回的稳定错误码。 */ private static final String NVR_RTSP_BUSY = "NVR_RTSP_BUSY"; + /** NVR 明确返回带宽不足时返回的稳定错误码。 */ + private static final String NVR_RTSP_BANDWIDTH = "NVR_RTSP_BANDWIDTH"; + /** NVR 在 SETUP 阶段返回裸 5XX(通道/码流当前不可用、并发或带宽受限)时的稳定错误码。 */ + private static final String NVR_RTSP_SETUP_REJECTED = "NVR_RTSP_SETUP_REJECTED"; + /** 本服务为单台 NVR 设置的并发实时流上限已占满时的稳定错误码。 */ + private static final String NVR_LIVE_SESSION_LIMIT = "NVR_LIVE_SESSION_LIMIT"; /** 通道离线或在时限内没有任何视频数据时返回的稳定错误码。 */ private static final String CHANNEL_OFFLINE = "CHANNEL_OFFLINE"; /** NVR 网络不可达、拒绝连接或连接超时时返回的稳定错误码。 */ @@ -98,6 +105,20 @@ public class HikNvrLiveStreamService { private int maxStartQueueSize; /** ZLMediaKit API 单次连接和读取超时。 */ private int zlmApiTimeoutMillis; + /** 单路实时流在返回失败前的最大尝试次数。 */ + private int maxAttempts; + /** 设备端拒绝后重试前等待设备释放 RTSP 会话的基础时长。 */ + private long retryReleaseWaitMillis; + /** 重试前等待设备释放会话的时长上限。 */ + private long retryReleaseWaitMaxMillis; + /** 停流时等待 FFmpeg 优雅退出并发送 RTSP TEARDOWN 的最长时间。 */ + private long stopGraceMillis; + /** 单台 NVR 允许同时播放的实时流上限,0 表示不限制。 */ + private int maxSessionsPerNvr; + /** 是否回收长时间无人观看的实时流。 */ + private boolean idleReapEnabled; + /** 无人观看多少毫秒后回收实时流。 */ + private long idleReaderTimeoutMillis; /** 按 IVS 完整 cameraCode 保存本服务拥有的 FFmpeg 拉流会话。 */ private final Map sessions = new ConcurrentHashMap<>(); @@ -126,6 +147,18 @@ public class HikNvrLiveStreamService { this.maxStartQueueSize = Math.max(1, config.getMaxStartQueueSize()); // ZLM 探测本身必须显著短于总启动时限,避免探测请求反向拖死队列。 this.zlmApiTimeoutMillis = Math.max(100, config.getZlmApiTimeoutMillis()); + // 设备端在 SETUP 阶段的拒绝现场实测是可恢复的,因此允许有限次退避重试。 + this.maxAttempts = Math.max(1, config.getMaxAttempts()); + // 重试前先等设备释放上一次握手的会话,避免刚销毁的 FFmpeg 还占着设备侧名额。 + this.retryReleaseWaitMillis = Math.max(0L, config.getRetryReleaseWaitMillis()); + this.retryReleaseWaitMaxMillis = Math.max( + this.retryReleaseWaitMillis, config.getRetryReleaseWaitMaxMillis()); + // 优雅退出时限必须显著长于 SIGTERM 的响应时间,否则 FFmpeg 来不及发送 RTSP TEARDOWN。 + this.stopGraceMillis = Math.max(200L, config.getStopGraceMillis()); + // 上限 0 表示不限制;现场 NVR 远程预览预算更小时可下调。 + this.maxSessionsPerNvr = Math.max(0, config.getMaxSessionsPerNvr()); + this.idleReapEnabled = config.isIdleReapEnabled(); + this.idleReaderTimeoutMillis = Math.max(10000L, config.getIdleReaderTimeoutMillis()); } /** @@ -260,10 +293,11 @@ public class HikNvrLiveStreamService { // 排队请求无法直接接收异常,因此把稳定错误码和消息保留给状态轮询接口。 failedStarts.put(sessionKey, toFailureResponse(pending, failure)); pending.future.completeExceptionally(failure); - log.warn("[live] start failed cameraCode={} nvrIp={} errorCode={} log={}", + log.warn("[live] start failed cameraCode={} nvrIp={} errorCode={} reason={} log={}", pending.target.getCameraCode(), pending.target.getNvrInfo().getNvrIp(), failure.getErrorCode(), + failure.getMessage(), logFile(pending.streamId)); } catch (RuntimeException failure) { // 未分类运行时错误也转换成 ZLM_PUSH_FAILED,保证前端得到明确类型。 @@ -327,8 +361,11 @@ public class HikNvrLiveStreamService { long deadline = System.currentTimeMillis() + startupTimeoutMillis; HikNvrPlaybackException lastFailure = null; - // 最多尝试两次;第二次之前会主动失效海康登录缓存并重登。 - for (int attempt = 1; attempt <= 2; attempt++) { + String nvrIp = pending.target.getNvrInfo().getNvrIp(); + // 设备端拒绝时允许退避重试到 maxAttempts;其他失败保持原有的一次重登重试。 + int attempt = 0; + while (attempt < maxAttempts) { + attempt++; // 停止或码流切换已取消本任务时立即结束,不再占用 NVR 会话。 if (pending.cancelled) { return toStoppedResponse(pending, "实时预览启动已取消"); @@ -342,11 +379,24 @@ public class HikNvrLiveStreamService { throw classifyFailure("", ZlmProbeResult.ABSENT, loginFailure); } } + // 重试前等待设备释放上一次握手的 RTSP 会话,避免立刻重连继续被拒。 + if (attempt > 1 && !lane.awaitRelease(deadline, retryWaitMillis(attempt))) { + break; + } + // 本服务限制单台 NVR 的并发实时流数量,避免一次把设备远程预览预算打满。 + if (!awaitSessionQuota(nvrIp, deadline)) { + throw HikNvrPlaybackException.conflict( + NVR_LIVE_SESSION_LIMIT, + NVR_LIVE_SESSION_LIMIT + ":该NVR并发实时流已达上限" + + maxSessionsPerNvr + "路,请先关闭其他窗口后重试"); + } // 相同 NVR 的相邻握手保持配置间隔,避免设备瞬时并发 SETUP 500。 if (!lane.awaitSpacing(deadline, startSpacingMillis)) { break; } + // 记录本次尝试前的日志长度,失败分类只看本次新增的 stderr,避免上一次尝试的原始错误干扰判断。 + int logOffset = readLog(logFile).length(); String rtspUrl; try { // 通过 IVS 厂商路由生成当前码流的 RTSP 地址,海康路径会复用 SDK 登录布局。 @@ -354,7 +404,7 @@ public class HikNvrLiveStreamService { pending.target.getCameraCode(), pending.streamType); } catch (RuntimeException resolveFailure) { lastFailure = classifyFailure( - readLog(logFile), ZlmProbeResult.ABSENT, resolveFailure); + readLogFrom(logFile, logOffset), ZlmProbeResult.ABSENT, resolveFailure); // 第一次解析失败仍执行一次登录重建;第二次失败直接返回分类结果。 if (attempt == 1) { continue; @@ -365,11 +415,11 @@ public class HikNvrLiveStreamService { String rtmpUrl = rtmpBaseUrl + "/live/" + pending.streamId; Process process; try { - // 启动 FFmpeg 并把两次尝试的原始 stderr 追加到同一个每路日志文件。 + // 启动 FFmpeg 并把各次尝试的原始 stderr 追加到同一个每路日志文件。 process = startFfmpeg(buildFfmpegCommand(rtspUrl, rtmpUrl), logFile); } catch (IOException processFailure) { lastFailure = classifyFailure( - readLog(logFile), ZlmProbeResult.UNAVAILABLE, processFailure); + readLogFrom(logFile, logOffset), ZlmProbeResult.UNAVAILABLE, processFailure); // 首次本地启动失败仍按统一自愈流程重建登录并重试一次。 if (attempt == 1) { continue; @@ -409,13 +459,12 @@ public class HikNvrLiveStreamService { // 未注册或进程退出时立即销毁 FFmpeg,快速断开 NVR RTSP 会话。 stopInternal(sessionKey); - lastFailure = classifyFailure(readLog(logFile), probe, null); - // SETUP 500 是已确认的设备带宽预算拒绝,重登不会释放带宽且会继续冲击 NVR。 - if (NVR_RTSP_BUSY.equals(lastFailure.getErrorCode())) { - throw lastFailure; - } - // 第一次失败进入一次登录失效和重试;第二次返回最终明确分类。 - if (attempt == 2) { + lastFailure = classifyFailure(readLogFrom(logFile, logOffset), probe, null); + // 设备端拒绝建立实时流属于瞬时可恢复的故障,允许退避重试; + // 登录、网络、通道无数据等其他分类保持原有的一次重登重试。 + int attemptLimit = isDeviceRefusal(lastFailure.getErrorCode()) + ? maxAttempts : 2; + if (attempt >= Math.min(attemptLimit, maxAttempts)) { throw lastFailure; } } @@ -471,6 +520,33 @@ public class HikNvrLiveStreamService { .build(); } + /** + * 停止本服务创建的全部实时流与排队任务。 + * + *

供运维和批量任务收尾使用:一次释放所有 NVR 会话,避免残留流继续占用设备预算。

+ * + * @return 已释放的实时流数量 + */ + public HikNvrLiveStreamResponse stopAll() { + // 先取消全部排队任务,避免清理过程中又启动新的 FFmpeg。 + for (PendingStart pending : new ArrayList<>(pendingStarts.values())) { + pending.cancelled = true; + } + pendingStarts.clear(); + int stopped = 0; + // 再逐路释放本地会话,关闭其持有的 NVR RTSP 连接。 + for (String sessionKey : new ArrayList<>(sessions.keySet())) { + if (stopInternal(sessionKey)) { + stopped++; + } + } + failedStarts.clear(); + return HikNvrLiveStreamResponse.builder() + .status("STOPPED") + .message("已停止" + stopped + "路实时预览") + .build(); + } + /** * 查询指定 cameraCode 当前排队、播放或失败状态。 * @@ -548,22 +624,64 @@ public class HikNvrLiveStreamService { pendingStarts.clear(); } + /** + * 定时回收长时间无人观看的实时流。 + * + *

浏览器异常退出、断网或标签页崩溃时 {@code live/stop} 可能永远到不了服务端, + * 残留的 FFmpeg 会一直占着 NVR 的实时会话名额;这里以 ZLMediaKit 的观众数为依据 + * 回收这类流。只有 ZLM 明确报告流已注册且观众数为 0 时才计时,无法确认时不动。

+ */ + @Scheduled(fixedDelayString = "${hik.playback.live.idle-reap-interval-millis:60000}") + public void reapIdleSessions() { + // 配置关闭时不做任何探测,保持旧行为。 + if (!idleReapEnabled) { + return; + } + long now = System.currentTimeMillis(); + for (Map.Entry entry : new ArrayList<>(sessions.entrySet())) { + LiveSession session = entry.getValue(); + // 进程已经退出的会话直接从本地表移除,状态接口随后返回明确的停止结果。 + if (!session.isAlive()) { + sessions.remove(entry.getKey(), session); + continue; + } + ZlmStreamProbe probe = queryZlmStreamProbe(session.streamId); + // 只有 ZLM 确认流已注册且当前无人观看时才累计空闲时间。 + if (probe.result != ZlmProbeResult.REGISTERED || probe.readerCount > 0) { + session.idleSinceMillis = 0L; + continue; + } + if (session.idleSinceMillis == 0L) { + session.idleSinceMillis = now; + continue; + } + // 连续无人观看超过配置时限才回收,避免 HLS 观众分段请求间隙被误判。 + if (now - session.idleSinceMillis >= idleReaderTimeoutMillis) { + log.info("[live] 回收长时间无人观看的实时流 cameraCode={} streamId={} idleMillis={}", + session.cameraCode, session.streamId, now - session.idleSinceMillis); + stopInternal(entry.getKey()); + } + } + } + /** * 移除并销毁指定本地会话。 * * @param sessionKey 规范化 cameraCode 会话键 + * @return 确实移除并销毁了本地会话时返回 true */ - private void stopInternal(String sessionKey) { + private boolean stopInternal(String sessionKey) { // 先从会话表移除,避免销毁过程中被其他请求复用。 LiveSession session = sessions.remove(sessionKey); // 会话已经被并发停止时保持幂等。 if (session == null) { - return; + return false; } // 尽快结束 FFmpeg,关闭其持有的 NVR RTSP socket。 destroyProcess(session.process); log.info("[live] stopped cameraCode={} streamId={}", session.cameraCode, session.streamId); + return true; } /** @@ -577,10 +695,11 @@ public class HikNvrLiveStreamService { return; } try { - // 先发送正常终止信号,让 FFmpeg 有机会关闭 RTSP 和日志句柄。 + // 先发送 SIGTERM,让 FFmpeg 有机会发送 RTSP TEARDOWN 并关闭日志句柄; + // 被 SIGKILL 的进程发不出 TEARDOWN,NVR 只能等 TCP 超时回收会话名额。 process.destroy(); - // 300ms 内未退出就强制结束,不能继续占用 NVR 会话数秒。 - if (!process.waitFor(300L, TimeUnit.MILLISECONDS)) { + // 超过优雅退出时限仍未结束时才强制结束,避免长期占用 NVR 会话。 + if (!process.waitFor(stopGraceMillis, TimeUnit.MILLISECONDS)) { process.destroyForcibly(); } } catch (InterruptedException interrupted) { @@ -660,12 +779,12 @@ public class HikNvrLiveStreamService { } /** - * 调用 ZLMediaKit getMediaList 确认同名 RTMP 流是否注册。 + * 调用 ZLMediaKit getMediaList 确认同名 RTMP 流是否注册,并读取当前观众数。 * * @param streamId ZLMediaKit 流标识 - * @return 已注册、未注册或 API 不可用 + * @return 注册状态与正在读取该流的客户端数量 */ - private ZlmProbeResult queryZlmStream(String streamId) { + private ZlmStreamProbe queryZlmStreamProbe(String streamId) { HttpURLConnection connection = null; try { String query = "secret=" + encode(zlmApiSecret) @@ -679,7 +798,7 @@ public class HikNvrLiveStreamService { connection.setUseCaches(false); // 非 200 表示 ZLM API 本身不可用,不能把流误报为 PLAYING。 if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { - return ZlmProbeResult.UNAVAILABLE; + return new ZlmStreamProbe(ZlmProbeResult.UNAVAILABLE, 0); } String body; // 读取 ZLM JSON 响应,原始 API 密钥不会写入日志。 @@ -689,12 +808,12 @@ public class HikNvrLiveStreamService { JSONObject response = JSON.parseObject(body); // ZLM code 非 0 表示鉴权或 API 调用失败,不等同于流不存在。 if (response == null || response.getIntValue("code") != 0) { - return ZlmProbeResult.UNAVAILABLE; + return new ZlmStreamProbe(ZlmProbeResult.UNAVAILABLE, 0); } JSONArray data = response.getJSONArray("data"); // data 为空表示精确查询的同名流尚未注册。 if (data == null || data.isEmpty()) { - return ZlmProbeResult.ABSENT; + return new ZlmStreamProbe(ZlmProbeResult.ABSENT, 0); } // 即使 ZLM 忽略部分过滤参数,也只接受 live/streamId 的精确匹配。 for (int index = 0; index < data.size(); index++) { @@ -704,13 +823,16 @@ public class HikNvrLiveStreamService { && "live".equals(media.getString("app")) && streamId.equals(media.getString("stream")) && "rtmp".equalsIgnoreCase(media.getString("schema"))) { - return ZlmProbeResult.REGISTERED; + // readerCount 是 ZLM 统计的正在观看该流的客户端数量,用于判断是否还有人看。 + return new ZlmStreamProbe( + ZlmProbeResult.REGISTERED, + Math.max(0, media.getIntValue("readerCount"))); } } - return ZlmProbeResult.ABSENT; + return new ZlmStreamProbe(ZlmProbeResult.ABSENT, 0); } catch (Exception failure) { // 连接、超时或 JSON 解析失败都表示 ZLM API 不可确认,禁止返回 PLAYING。 - return ZlmProbeResult.UNAVAILABLE; + return new ZlmStreamProbe(ZlmProbeResult.UNAVAILABLE, 0); } finally { // 每次短轮询都主动关闭 HTTP 连接,避免探测自身耗尽连接池。 if (connection != null) { @@ -719,6 +841,16 @@ public class HikNvrLiveStreamService { } } + /** + * 只关心同名流是否已注册时使用的简化探测。 + * + * @param streamId ZLMediaKit 流标识 + * @return 已注册、未注册或 API 不可用 + */ + private ZlmProbeResult queryZlmStream(String streamId) { + return queryZlmStreamProbe(streamId).result; + } + /** * 读取 HTTP 文本响应。 * @@ -766,16 +898,35 @@ public class HikNvrLiveStreamService { String causeText = throwableText(cause).toLowerCase(Locale.ROOT); String combined = logText + "\n" + causeText; - // RTSP SETUP 500、带宽不足或连接数过多都属于 NVR 瞬时会话拒绝。 + // 设备明确报带宽不足:与并发会话数无关,需要提高带宽预算或改用子码流。 + if (combined.contains("453 not enough bandwidth") + || combined.contains("not enough bandwidth")) { + return HikNvrPlaybackException.sdkFailure( + NVR_RTSP_BANDWIDTH, + NVR_RTSP_BANDWIDTH + ":NVR带宽不足,无法建立实时流" + + primaryErrorLine(ffmpegLog, cause), + cause); + } + // 设备明确报连接数或客户端数超限:这才是真正的"实时会话已满"。 + if (combined.contains("too many connections") + || combined.contains("maximum number of clients")) { + return HikNvrPlaybackException.sdkFailure( + NVR_RTSP_BUSY, + NVR_RTSP_BUSY + ":NVR实时会话数已满" + + primaryErrorLine(ffmpegLog, cause), + cause); + } + // SETUP 阶段的裸 5XX:设备拒绝建立这一路实时流。现场实测同一地址稍后单独拉取可成功, + // 常见原因是通道/码流当前不可用、并发或带宽预算受限,因此按可重试分类处理。 if (combined.contains("setup failed: 500") || combined.contains("setup: 500") || combined.contains("rtsp/1.0 500") - || combined.contains("453 not enough bandwidth") - || combined.contains("too many connections") - || combined.contains("maximum number of clients")) { + || combined.contains("server returned 5xx")) { return HikNvrPlaybackException.sdkFailure( - NVR_RTSP_BUSY, - NVR_RTSP_BUSY + ":NVR拒绝RTSP SETUP或实时会话已满", + NVR_RTSP_SETUP_REJECTED, + NVR_RTSP_SETUP_REJECTED + + ":NVR在SETUP阶段拒绝建立实时流(通道/码流当前不可用或并发受限,稍后重试通常可恢复)" + + primaryErrorLine(ffmpegLog, cause), cause); } // RTSP 401/Unauthorized 与 SDK 密码、权限或登录句柄失败统一归为登录失败。 @@ -884,6 +1035,142 @@ public class HikNvrLiveStreamService { return text.toString(); } + /** + * 判断错误码是否属于"设备端拒绝建立实时流"。 + * + *

现场实测:同一 RTSP 地址在稍后单独拉取时可以成功,说明这类拒绝是瞬时的、 + * 可以通过退避重试恢复,因此与登录失败、网络不可达等确定性错误分开处理。

+ * + * @param errorCode 分类得到的稳定错误码 + * @return 允许退避重试时返回 true + */ + private boolean isDeviceRefusal(String errorCode) { + return NVR_RTSP_BUSY.equals(errorCode) + || NVR_RTSP_BANDWIDTH.equals(errorCode) + || NVR_RTSP_SETUP_REJECTED.equals(errorCode); + } + + /** + * 提取 FFmpeg stderr 中最具代表性的一行设备原文,附在错误消息和应用日志中。 + * + * @param ffmpegLog 当前流的原始 stderr + * @param cause SDK、进程或内部异常 + * @return 可直接拼接的原文片段;没有可用原文时返回空字符串 + */ + private String primaryErrorLine(String ffmpegLog, Throwable cause) { + String line = lastErrorLine(ffmpegLog); + // FFmpeg 没有输出可用错误行时退回异常链消息,保证调用方仍能看到设备原文。 + if (line.isEmpty()) { + line = firstLine(throwableText(cause)); + } + // 没有原文时不加多余标点,保持消息整洁。 + return line.isEmpty() ? "" : ";原始信息:" + line; + } + + /** 取 FFmpeg stderr 中最后一条包含失败语义的行。 */ + private String lastErrorLine(String ffmpegLog) { + // 日志缺失时没有可提取的原文。 + if (ffmpegLog == null || ffmpegLog.isEmpty()) { + return ""; + } + String matched = ""; + // 逐行扫描并保留最后一条命中行,FFmpeg 的最后一条错误通常最具体。 + for (String raw : ffmpegLog.split("\\r?\\n")) { + String line = raw.trim(); + if (line.isEmpty()) { + continue; + } + String lower = line.toLowerCase(Locale.ROOT); + if (lower.contains("error") || lower.contains("failed") + || lower.contains("denied") || lower.contains("refused") + || lower.contains("timeout") || lower.contains("timed out")) { + matched = line; + } + } + return truncate(matched); + } + + /** 取文本中第一行非空内容。 */ + private String firstLine(String text) { + // 空文本没有可返回的内容行。 + if (text == null) { + return ""; + } + for (String raw : text.split("\\r?\\n")) { + String line = raw.trim(); + if (!line.isEmpty()) { + return truncate(line); + } + } + return ""; + } + + /** 限制单行错误长度,避免异常消息在日志和响应中过长。 */ + private String truncate(String value) { + int limit = 200; + // 空值按空字符串处理,调用方无需额外判空。 + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "..."; + } + + /** + * 计算本次重试前等待设备释放会话的时长。 + * + * @param attempt 即将执行的尝试序号,从 1 开始 + * @return 基础时长按尝试序号线性递增后的等待毫秒数 + */ + private long retryWaitMillis(int attempt) { + // 第一次重试使用基础时长,后续递增,避免对设备形成连续冲击。 + long wait = retryReleaseWaitMillis * Math.max(1, attempt - 1); + return Math.min(retryReleaseWaitMaxMillis, Math.max(0L, wait)); + } + + /** + * 等待目标 NVR 的并发实时流名额。 + * + * @param nvrIp 目标 NVR 地址 + * @param deadline 本次起流总截止时间 + * @return 取得名额或不限制并发时返回 true + */ + private boolean awaitSessionQuota(String nvrIp, long deadline) { + // 上限为 0 表示不限制,保持历史行为兼容。 + if (maxSessionsPerNvr <= 0) { + return true; + } + // 名额未满即可继续;已满则等其他窗口释放,避免直接冲击设备。 + while (System.currentTimeMillis() < deadline) { + if (activeSessionsOn(nvrIp) < maxSessionsPerNvr) { + return true; + } + try { + Thread.sleep(100L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + + /** + * 统计仍在运行且属于指定 NVR 的本地实时流数量。 + * + * @param nvrIp 目标 NVR 地址 + * @return 本地活跃会话数 + */ + private int activeSessionsOn(String nvrIp) { + int count = 0; + // 本地会话表规模很小,逐条比对 NVR 地址即可。 + for (LiveSession session : sessions.values()) { + if (session.isAlive() && nvrIp.equalsIgnoreCase(session.nvrIp)) { + count++; + } + } + return count; + } + /** 读取当前流的 FFmpeg 原始日志,文件本身不做改写。 */ private String readLog(Path logFile) { // 日志尚未生成时返回空文本,由 ZLM 探测结果补充分类依据。 @@ -899,6 +1186,26 @@ public class HikNvrLiveStreamService { } } + /** + * 只读取当前尝试新增的 FFmpeg stderr 片段。 + * + *

同一个日志文件会追加多次尝试的输出,按偏移量截取后分类才能反映本轮握手的真实结果, + * 避免上一轮的错误(例如先 500 后 401)把最终错误码带偏。

+ * + * @param logFile 当前流日志路径 + * @param offset 本次尝试前已有的字符数 + * @return 本次尝试新增的 stderr 文本 + */ + private String readLogFrom(Path logFile, int offset) { + String text = readLog(logFile); + // 没有历史内容时整段都属于本次尝试。 + if (offset <= 0) { + return text; + } + // 偏移量已到文本末尾说明本次尝试没有产生新的 stderr。 + return offset >= text.length() ? "" : text.substring(offset); + } + /** * 调用现有 IVS 实时媒体能力取得 cameraCode 对应的 RTSP 地址。 * @@ -1176,6 +1483,20 @@ public class HikNvrLiveStreamService { UNAVAILABLE } + /** ZLMediaKit 单次流查询的完整结果,包含注册状态和当前观众数量。 */ + private static final class ZlmStreamProbe { + /** 查询结果分类。 */ + private final ZlmProbeResult result; + /** 当前读取该流的客户端数量。 */ + private final int readerCount; + + /** 保存一次 ZLM 探测的分类结果和观众数量。 */ + private ZlmStreamProbe(ZlmProbeResult result, int readerCount) { + this.result = result; + this.readerCount = readerCount; + } + } + /** 本服务拥有的一路 FFmpeg 实时拉流会话。 */ private static final class LiveSession { /** IVS 完整摄像机编码。 */ @@ -1194,6 +1515,8 @@ public class HikNvrLiveStreamService { private final String hlsUrl; /** HTTP-FLV 浏览器地址。 */ private final String flvUrl; + /** 连续无人观看的起始时间;0 表示当前有人观看或无法确认。 */ + private volatile long idleSinceMillis; /** 保存一路本地 FFmpeg 实时流的完整生命周期信息。 */ private LiveSession( @@ -1282,6 +1605,31 @@ public class HikNvrLiveStreamService { new ThreadPoolExecutor.AbortPolicy()); } + /** + * 重试前等待设备释放上一次握手的 RTSP 会话。 + * + * @param deadline 当前起流总截止时间 + * @param waitMillis 本次计划等待的毫秒数 + * @return 截止时间前等待完成并仍可继续尝试时返回 true + */ + private boolean awaitRelease(long deadline, long waitMillis) { + // 等待后已经越过总截止时间时不再发起新的握手。 + if (System.currentTimeMillis() + Math.max(0L, waitMillis) >= deadline) { + return false; + } + // 只需让出设备侧会话释放窗口,不占用 Tomcat 请求线程。 + if (waitMillis > 0L) { + try { + Thread.sleep(waitMillis); + } catch (InterruptedException interrupted) { + // 应用关闭或任务取消时恢复中断标志并停止重试。 + Thread.currentThread().interrupt(); + return false; + } + } + return System.currentTimeMillis() < deadline; + } + /** * 等待满足同 NVR 相邻握手间隔,并记录本次尝试开始时间。 * diff --git a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java index 9e876d0a..776e6332 100644 --- a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java +++ b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java @@ -36,4 +36,30 @@ public class HikNvrLiveStreamConfigVo { /** 单次 ZLMediaKit API 连接和读取超时时间。 */ @Value("${hik.playback.live.zlm-api-timeout-millis:300}") private int zlmApiTimeoutMillis = 300; + /** + * 单路实时流在返回失败前的最大尝试次数。 + * + *

现场实测:NVR 在 SETUP 阶段返回 500 后,同一 RTSP 地址稍后单独拉取可以成功, + * 说明这类拒绝是瞬时可恢复的,因此允许有限次退避重试。

+ */ + @Value("${hik.playback.live.max-attempts:3}") + private int maxAttempts = 3; + /** 设备端拒绝后重试前等待设备释放上一次 RTSP 会话的基础时长。 */ + @Value("${hik.playback.live.retry-release-wait-millis:1500}") + private long retryReleaseWaitMillis = 1500L; + /** 重试前等待设备释放会话的时长上限。 */ + @Value("${hik.playback.live.retry-release-wait-max-millis:5000}") + private long retryReleaseWaitMaxMillis = 5000L; + /** 停流时等待 FFmpeg 优雅退出(发送 RTSP TEARDOWN)的最长时间。 */ + @Value("${hik.playback.live.stop-grace-millis:2000}") + private long stopGraceMillis = 2000L; + /** 单台 NVR 允许同时播放的实时流上限,0 表示不限制。 */ + @Value("${hik.playback.live.max-sessions-per-nvr:8}") + private int maxSessionsPerNvr = 8; + /** 是否回收长时间无人观看的实时流。 */ + @Value("${hik.playback.live.idle-reap-enabled:true}") + private boolean idleReapEnabled = true; + /** 无人观看多少毫秒后回收实时流。 */ + @Value("${hik.playback.live.idle-reader-timeout-millis:600000}") + private long idleReaderTimeoutMillis = 600000L; } diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 81311363..fc2eb162 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -24,6 +24,10 @@ hik: ffmpeg-path: ${FFMPEG_PATH:/usr/bin/ffmpeg} rtmp-base-url: ${ZLM_RTMP_BASE_URL:rtmp://inspect-nvr-zlmedia:1935} http-base-url: ${ZLM_HTTP_BASE_URL:http://host.docker.internal:33381} + # 服务端校验流是否注册复用同一个 ZLMediaKit HTTP 地址;容器不可达时用 + # JAVA_TOOL_OPTIONS 追加 -Dhik.playback.hls.api-base-url=<容器可达地址> 覆盖。 + api-base-url: "${ZLM_HTTP_BASE_URL}" + api-secret: "${ZLM_API_SECRET:}" clip: # 录像片段直接复用镜像内FFmpeg,不依赖ZLMediaKit。 ffmpeg-path: ${FFMPEG_PATH:/usr/bin/ffmpeg} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2b58e7c2..9087f371 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,6 +54,25 @@ hik: startup-timeout-millis: ${HIK_NVR_HLS_STARTUP_TIMEOUT_MILLIS:30000} no-data-timeout-millis: ${HIK_NVR_HLS_NO_DATA_TIMEOUT_MILLIS:20000} max-concurrent-sessions: ${HIK_NVR_HLS_MAX_SESSIONS:1} + # 服务端调用 ZLMediaKit API 确认流是否已注册;密钥为空时实时预览会直接返回配置错误。 + api-base-url: "${ZLM_API_BASE_URL:http://127.0.0.1:18081}" + api-secret: "${ZLM_API_SECRET:}" + # 实时预览:FFmpeg 拉取 NVR 的 RTSP 子码流并推送到 ZLMediaKit。 + live: + # 设备端在 SETUP 阶段拒绝建立实时流时允许的退避重试次数。 + max-attempts: ${HIK_NVR_LIVE_MAX_ATTEMPTS:3} + # 重试前等待设备释放上一次 RTSP 会话的时间(毫秒),按尝试序号线性递增。 + retry-release-wait-millis: ${HIK_NVR_LIVE_RETRY_RELEASE_WAIT_MILLIS:1500} + retry-release-wait-max-millis: ${HIK_NVR_LIVE_RETRY_RELEASE_WAIT_MAX_MILLIS:5000} + # 停流时等待 FFmpeg 优雅退出(发送 RTSP TEARDOWN)的最长时间(毫秒)。 + stop-grace-millis: ${HIK_NVR_LIVE_STOP_GRACE_MILLIS:2000} + # 单台 NVR 允许同时播放的实时流上限,0 表示不限制;请按设备远程预览预算调整。 + max-sessions-per-nvr: ${HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR:8} + # 长时间无人观看(ZLMediaKit 观众数为 0)时回收实时流,避免浏览器异常退出后占住 NVR 会话。 + idle-reap-enabled: ${HIK_NVR_LIVE_IDLE_REAP_ENABLED:true} + idle-reader-timeout-millis: ${HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS:600000} + # 空闲回收扫描间隔(毫秒)。 + idle-reap-interval-millis: ${HIK_NVR_LIVE_IDLE_REAP_INTERVAL_MILLIS:60000} clip: # 一分钟录像先由SDK下载到临时目录,再由FFmpeg统一封装为MP4。 ffmpeg-path: ${FFMPEG_PATH:C:/tools/ffmpeg/bin/ffmpeg.exe} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 31cf99d7..f9989bb8 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -708,7 +708,13 @@ const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (error) { data = { message: text || ('HTTP ' + response.status) }; } - if (!response.ok) throw new Error((data && data.message) || ('请求失败(HTTP ' + response.status + ')')); + if (!response.ok) { + const error = new Error((data && data.message) || ('请求失败(HTTP ' + response.status + ')')); + // 保留后端稳定错误码和 HTTP 状态,宫格才能显示"这一路为什么起不来"。 + error.errorCode = (data && data.errorCode) || null; + error.status = response.status; + throw error; + } return data; } @@ -778,6 +784,28 @@ && String(slot.cameraCode || '').trim() === target); } + /** + * 实时流失败原因的宫格内短提示。 + * 后端返回的稳定错误码直接对应现场处置动作,避免只显示"启动超时"把排查方向带偏。 + */ + const LIVE_ERROR_HINTS = { + NVR_RTSP_BUSY: 'NVR实时会话已满', + NVR_RTSP_BANDWIDTH: 'NVR带宽不足', + NVR_RTSP_SETUP_REJECTED: 'NVR拒绝建立流', + NVR_LIVE_SESSION_LIMIT: '本机并发已达上限', + NVR_START_QUEUE_FULL: '该NVR起流队列已满', + NVR_UNREACHABLE: 'NVR网络不可达', + NVR_LOGIN_FAILED: 'NVR登录或鉴权失败', + CHANNEL_OFFLINE: '通道无可用视频', + ZLM_PUSH_FAILED: '媒体服务异常' + }; + + /** 返回宫格内显示的失败短提示。 */ + function liveErrorHint(payload) { + const code = payload && payload.errorCode ? String(payload.errorCode) : ''; + return LIVE_ERROR_HINTS[code] || '实时预览启动失败'; + } + function findPlacementSlot() { const capacity = layoutCapacity(); const cursor = ((Number(state.livePlacementCursor) || 0) % capacity + capacity) % capacity; @@ -1202,8 +1230,10 @@ targetCameraCode, { silent: true, syncNumber: true }); const existingIndex = findLiveSlotByCameraCode(targetCameraCode); - if (existingIndex >= 0 || state.liveStartingChannels.has(targetKey)) { - // 同一通道已经在播放或正在启动时只聚焦已有槽位,避免重复开流。 + const existingSlot = existingIndex >= 0 ? state.liveSlots[existingIndex] : null; + // 同一通道已在播放、或正在启动时只聚焦已有槽位,避免重复开流。 + if ((existingSlot && !existingSlot.errorMessage) + || state.liveStartingChannels.has(targetKey)) { renderCameras(); renderDeviceTree(); updateSelection(); @@ -1211,10 +1241,17 @@ return; } - const slotIndex = findPlacementSlot(); - if (state.liveSlots[slotIndex]) { - // 槽位已满时先停掉被替换的旧流,再把新通道放入该位置。 - await stopLiveSlot(slotIndex, { render: false }); + // 上一次启动失败的槽位允许直接重试,不再按"已在播放"处理。 + let slotIndex; + if (existingSlot) { + slotIndex = existingIndex; + destroySlotPlayer(slotIndex); + } else { + slotIndex = findPlacementSlot(); + if (state.liveSlots[slotIndex]) { + // 槽位已满时先停掉被替换的旧流,再把新通道放入该位置。 + await stopLiveSlot(slotIndex, { render: false }); + } } const slot = { cameraCode: targetCameraCode, @@ -1225,7 +1262,9 @@ liveHls: null, liveFlv: null, livePollTimer: null, - started: false + started: false, + errorCode: null, + errorMessage: null }; state.liveSlots[slotIndex] = slot; state.livePlacementCursor = (slotIndex + 1) % layoutCapacity(); @@ -1236,11 +1275,13 @@ showToast('正在打开通道 ' + ch + ' 实时预览…'); try { + // 实时预览统一使用子码流:现场实测部分 NVR 在 SETUP 阶段直接拒绝主码流请求(RTSP 500), + // 且九宫格同时播放主码流会显著推高 NVR 出网带宽压力。 const result = await requestJson(API + '/actions/live/start', { method: 'POST', body: JSON.stringify({ cameraCode: targetCameraCode, - streamType: 1 + streamType: 2 }) }); @@ -1258,8 +1299,24 @@ } slot.started = true; + const fail = (payload) => { + slot.errorCode = (payload && payload.errorCode) || 'START_FAILED'; + slot.errorMessage = (payload && payload.message) || '实时预览启动失败'; + // 失败后清掉流地址并销毁播放器,避免宫格被误判为已出图而隐藏失败原因。 + slot.hlsUrl = null; + slot.flvUrl = null; + clearLivePollTimer(slot); + destroySlotPlayer(slotIndex); + renderCameras(); + showToast(slot.errorMessage, true); + }; const play = (payload) => { if (state.liveSlots[slotIndex] !== slot || !payload) return; + if (payload.status === 'FAILED') { + // 后端已经给出明确失败分类时直接展示,不再等轮询超时。 + fail(payload); + return; + } if (payload.hlsUrl) slot.hlsUrl = payload.hlsUrl; if (payload.flvUrl) slot.flvUrl = payload.flvUrl; if (slot.hlsUrl || slot.flvUrl) { @@ -1282,6 +1339,11 @@ + encodeURIComponent(targetCameraCode), { headers: {} }); if (state.liveSlots[slotIndex] !== slot) return; + if (status && status.status === 'FAILED') { + // 轮询拿到失败分类时立即结束等待,并按真实原因渲染该宫格。 + fail(status); + return; + } if (status && status.hlsUrl) slot.hlsUrl = status.hlsUrl; if (status && status.flvUrl) slot.flvUrl = status.flvUrl; if (status && (status.hlsUrl || status.flvUrl)) { @@ -1305,7 +1367,11 @@ if (state.liveSlots[slotIndex] === slot) { clearLivePollTimer(slot); destroySlotPlayer(slotIndex); - state.liveSlots[slotIndex] = null; + // 保留失败原因和该宫格,用户可直接双击按同一通道重试。 + slot.hlsUrl = null; + slot.flvUrl = null; + slot.errorCode = error.errorCode || 'START_FAILED'; + slot.errorMessage = error.message || '启动实时预览失败'; renderCameras(); } showToast(error.message || '启动实时预览失败', true); @@ -1440,13 +1506,19 @@ ) ? ' selected' : ''; const scene = 'scene-' + ((index % 4) + 1); const cameraName = escapeHtml(channel.name || ('通道 ' + channel.channel)); - const isLive = Boolean(liveSlot.hlsUrl || liveSlot.flvUrl); - const connectionText = isLive ? '正在加载视频…' : '正在连接设备…'; + const failed = Boolean(liveSlot.errorMessage); + const isLive = !failed && Boolean(liveSlot.hlsUrl || liveSlot.flvUrl); + const connectionText = failed + ? escapeHtml(liveErrorHint(liveSlot)) + : (isLive ? '正在加载视频…' : '正在连接设备…'); template.innerHTML = '
' + + + '" data-slot-index="' + index + '"' + + ' title="' + escapeHtml(failed + ? (liveSlot.errorMessage + '(双击重试)') + : '双击放大预览;左向右框选定位放大,右向左框选缩小') + '">' + '
' + '
' + '
' + cameraName + '
' + @@ -1501,12 +1573,26 @@ card.classList.toggle('selected', sameChannel( channel, state.selectedNvrIp, state.selectedChannel)); const feed = card.querySelector('.feed'); - const isLive = Boolean(liveSlot.hlsUrl || liveSlot.flvUrl); + const failed = Boolean(liveSlot.errorMessage); + // 失败槽位不再标记为已出图,否则连接提示会被 .feed.has-video 隐藏。 + const isLive = !failed && Boolean(liveSlot.hlsUrl || liveSlot.flvUrl); if (feed) feed.classList.toggle('has-video', isLive); const label = card.querySelector('.feed-label'); if (label) label.innerHTML = '' + escapeHtml(channel.name || ('通道 ' + channel.channel)); const connection = card.querySelector('.feed-connection span'); - if (connection) connection.textContent = isLive ? '正在加载视频…' : '正在连接设备…'; + if (connection) { + if (failed) { + // 直接在宫格上显示失败分类,避免只留下一个没有说明的黑窗。 + connection.textContent = liveErrorHint(liveSlot); + connection.style.color = '#ff9a94'; + } else { + connection.textContent = isLive ? '正在加载视频…' : '正在连接设备…'; + connection.style.color = ''; + } + } + card.title = failed + ? (liveSlot.errorMessage + '(双击重试)') + : '双击放大预览;左向右框选定位放大,右向左框选缩小'; } function renderCameras() { @@ -1552,9 +1638,12 @@ if (liveSlot) syncCameraCard(card, liveSlot, channel); } - // 只有槽位没有播放器时 attachLivePlayer 才会真正创建播放器;已有播放器会直接复用。 + // 只有槽位没有播放器且未失败时 attachLivePlayer 才会真正创建播放器;已有播放器会直接复用。 state.liveSlots.slice(0, total).forEach((liveSlot, index) => { - if (liveSlot && (liveSlot.hlsUrl || liveSlot.flvUrl)) attachLivePlayer(index); + if (liveSlot && !liveSlot.errorMessage + && (liveSlot.hlsUrl || liveSlot.flvUrl)) { + attachLivePlayer(index); + } }); $('#channelCount').textContent = channels.length + ' 个通道'; const liveCount = state.liveSlots.slice(0, total).filter(Boolean).length;