|
|
|
@ -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 的进程发不出 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(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 判断错误码是否属于"设备端拒绝建立实时流"。 |
|
|
|
* |
|
|
|
* <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 相邻握手间隔,并记录本次尝试开始时间。 |
|
|
|
* |
|
|
|
|