Browse Source

修复实时预览黑窗与NVR_RTSP_BUSY误报

master
wangguangyuan 2 weeks ago
parent
commit
95a656bb31
8 changed files with 555 additions and 51 deletions
  1. +6
    -0
      .env.example
  2. +2
    -0
      docker-compose.yml
  3. +10
    -0
      src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java
  4. +382
    -34
      src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java
  5. +26
    -0
      src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java
  6. +4
    -0
      src/main/resources/application-docker.yml
  7. +19
    -0
      src/main/resources/application.yml
  8. +106
    -17
      src/main/resources/static/index.html

+ 6
- 0
.env.example View File

@ -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=<reachable address> 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


+ 2
- 0
docker-compose.yml View File

@ -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}


+ 10
- 0
src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java View File

@ -136,6 +136,16 @@ public class HikNvrUiActionController {
return ResponseEntity.ok(liveStreamService.stop(cameraCode));
}
/**
* 停止本服务创建的全部实时拉流任务
*/
@PostMapping("/live/stop-all")
/** 运维与批量任务收尾使用:一次释放所有 NVR 实时会话。 */
public ResponseEntity<HikNvrLiveStreamResponse> stopAllLive() {
// 批量清理不需要 cameraCode直接释放全部会话和排队任务
return ResponseEntity.ok(liveStreamService.stopAll());
}
/**
* 查询指定通道的实时拉流状态
*/


+ 382
- 34
src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java View File

@ -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<String, LiveSession> 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();
}
/**
* 停止本服务创建的全部实时流与排队任务
*
* <p>供运维和批量任务收尾使用一次释放所有 NVR 会话避免残留流继续占用设备预算</p>
*
* @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();
}
/**
* 定时回收长时间无人观看的实时流
*
* <p>浏览器异常退出断网或标签页崩溃时 {@code live/stop} 可能永远到不了服务端
* 残留的 FFmpeg 会一直占着 NVR 的实时会话名额这里以 ZLMediaKit 的观众数为依据
* 回收这类流只有 ZLM 明确报告流已注册且观众数为 0 时才计时无法确认时不动</p>
*/
@Scheduled(fixedDelayString = "${hik.playback.live.idle-reap-interval-millis:60000}")
public void reapIdleSessions() {
// 配置关闭时不做任何探测保持旧行为
if (!idleReapEnabled) {
return;
}
long now = System.currentTimeMillis();
for (Map.Entry<String, LiveSession> 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 的进程发不出 TEARDOWNNVR 只能等 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();
}
/**
* 判断错误码是否属于"设备端拒绝建立实时流"
*
* <p>现场实测同一 RTSP 地址在稍后单独拉取时可以成功说明这类拒绝是瞬时的
* 可以通过退避重试恢复因此与登录失败网络不可达等确定性错误分开处理</p>
*
* @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 片段
*
* <p>同一个日志文件会追加多次尝试的输出按偏移量截取后分类才能反映本轮握手的真实结果
* 避免上一轮的错误例如先 500 401把最终错误码带偏</p>
*
* @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 相邻握手间隔并记录本次尝试开始时间
*


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

@ -36,4 +36,30 @@ public class HikNvrLiveStreamConfigVo {
/** 单次 ZLMediaKit API 连接和读取超时时间。 */
@Value("${hik.playback.live.zlm-api-timeout-millis:300}")
private int zlmApiTimeoutMillis = 300;
/**
* 单路实时流在返回失败前的最大尝试次数
*
* <p>现场实测NVR SETUP 阶段返回 500 同一 RTSP 地址稍后单独拉取可以成功
* 说明这类拒绝是瞬时可恢复的因此允许有限次退避重试</p>
*/
@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;
}

+ 4
- 0
src/main/resources/application-docker.yml View File

@ -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}


+ 19
- 0
src/main/resources/application.yml View File

@ -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}


+ 106
- 17
src/main/resources/static/index.html View File

@ -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 = '<article class="camera-card' + active
+ '" data-nvr-ip="' + escapeHtml(liveSlot.nvrIp)
+ '" data-channel="' + Number(channel.channel)
+ '" data-camera-code="' + escapeHtml(liveSlot.cameraCode || '')
+ '" data-slot-index="' + index + '" title="双击放大预览;左向右框选定位放大,右向左框选缩小">' +
+ '" data-slot-index="' + index + '"'
+ ' title="' + escapeHtml(failed
? (liveSlot.errorMessage + '(双击重试)')
: '双击放大预览;左向右框选定位放大,右向左框选缩小') + '">' +
'<div class="feed' + (isLive ? ' has-video' : '') + '">' +
'<div class="feed-scene ' + scene + '"></div>' +
'<div class="feed-label"><span class="camera-led"></span>' + cameraName + '</div>' +
@ -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 = '<span class="camera-led"></span>' + 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;


Loading…
Cancel
Save