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 a1c8306f..e1791990 100644
--- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java
+++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java
@@ -47,6 +47,7 @@ import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -151,6 +152,18 @@ public class HikNvrLiveStreamService {
private boolean idleReapEnabled;
/** 无人观看多少毫秒后回收实时流。 */
private long idleReaderTimeoutMillis;
+ /** 是否自动重建意外中断的实时流。 */
+ private boolean reconnectEnabled;
+ /** 单路自动重连的最大次数。 */
+ private int reconnectMaxAttempts;
+ /** 自动重连的首次退避时长。 */
+ private long reconnectBackoffBaseMillis;
+ /** 自动重连的退避时长上限。 */
+ private long reconnectBackoffMaxMillis;
+ /** 最近出现过观众的时间窗口,超过则不再自动重连。 */
+ private long reconnectRecentReaderMillis;
+ /** 稳定播放该时长后把重连次数清零。 */
+ private long reconnectStableMillis;
/** 是否优先直连摄像机取流。 */
private boolean preferCameraDirect;
/** 直连摄像机使用的 RTSP 端口。 */
@@ -177,6 +190,18 @@ public class HikNvrLiveStreamService {
new ConcurrentHashMap<>();
/** 每台 NVR 独立维护一个有界单线程起流通道。 */
private final Map
只有最近出现过观众才重建,避免为没人看的流占用设备会话;次数、退避和稳定清零 + * 共同保证坏设备不会被无限重试打爆。
+ * + * @param sessionKey 会话语义键 + * @param session 已经退出的旧会话(还带着 cameraCode 和码流类型) + * @param now 当前时间 + */ + private void handleBrokenSession(String sessionKey, LiveSession session, long now) { + if (!reconnectEnabled) { + reconnects.remove(sessionKey); + return; + } + ReconnectState state = reconnects.computeIfAbsent(sessionKey, key -> new ReconnectState()); + // 最近没有观众:不重建,直接清掉状态(浏览器可能已经关掉这一路)。 + if (state.lastReaderAtMillis <= 0 + || now - state.lastReaderAtMillis > reconnectRecentReaderMillis) { + reconnects.remove(sessionKey); + return; + } + if (state.attempts >= reconnectMaxAttempts) { + if (!state.limitLogged) { + state.limitLogged = true; + log.warn("[live] 自动重连达到上限,停止尝试 cameraCode={} attempts={} softIdleMillis={}", + session.cameraCode, state.attempts, now - state.lastReaderAtMillis); + } + return; + } + // 还没到下一次允许重连的时间,等下一个巡检周期。 + if (now < state.nextAttemptAtMillis) { + return; + } + state.attempts += 1; + state.nextAttemptAtMillis = now + reconnectBackoff(state.attempts); + log.info("[live] 检测到意外断流,自动重建 cameraCode={} attempt={} nextAfterMillis={}", + session.cameraCode, state.attempts, reconnectBackoff(state.attempts)); + try { + reconnectExecutor.execute(() -> rebuild(sessionKey, session, state)); + } catch (RuntimeException rejection) { + log.warn("[live] 自动重连任务提交失败 cameraCode={} reason={}", + session.cameraCode, rejection.getMessage()); + } + } + + /** 在重连线程中重建一路流;失败只记日志,等退避后下次再试。 */ + private void rebuild(String sessionKey, LiveSession session, ReconnectState state) { + try { + // 重建走标准起流流程,因此同样受每 NVR 并发与握手间隔节流。 + start(session.cameraCode, session.streamType, ""); + log.info("[live] 自动重连已发起 cameraCode={} attempt={}", + session.cameraCode, state.attempts); + } catch (RuntimeException failure) { + log.warn("[live] 自动重连失败 cameraCode={} attempt={} reason={}", + session.cameraCode, state.attempts, failure.getMessage()); + } + } + + /** 按次数计算退避:首次 base,之后翻倍,封顶 max。 */ + private long reconnectBackoff(int attempts) { + long delay = reconnectBackoffBaseMillis; + for (int i = 1; i < attempts && delay < reconnectBackoffMaxMillis; i++) { + delay = Math.min(reconnectBackoffMaxMillis, delay * 2L); + } + return Math.min(reconnectBackoffMaxMillis, delay); + } + + /** 单路自动重连状态。 */ + private static final class ReconnectState { + /** 最近一次确认有观众的时间。 */ + private volatile long lastReaderAtMillis; + /** 已自动重连次数。 */ + private volatile int attempts; + /** 下一次允许重连的时间。 */ + private volatile long nextAttemptAtMillis; + /** 是否已经记录过"达到上限"日志,避免每个巡检周期刷屏。 */ + private volatile boolean limitLogged; } /** @@ -2039,6 +2184,8 @@ public class HikNvrLiveStreamService { private final Process process; /** 连续无人观看的起始时间;0 表示当前有人观看或无法确认。 */ private volatile long idleSinceMillis; + /** 本次 FFmpeg 启动时间,用于判定"稳定播放"并清零重连次数。 */ + private final long startedAtMillis; /** 保存一路本地 FFmpeg 实时流的完整生命周期信息。 */ private LiveSession( @@ -2058,6 +2205,7 @@ public class HikNvrLiveStreamService { this.streamType = streamType; this.streamId = streamId; this.process = process; + this.startedAtMillis = System.currentTimeMillis(); } /** 判断 FFmpeg 拉流进程是否仍在运行。 */ 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 2c074533..5cc52d57 100644 --- a/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java +++ b/src/main/java/com/inspect/nvr/hik/vo/HikNvrLiveStreamConfigVo.java @@ -62,6 +62,29 @@ public class HikNvrLiveStreamConfigVo { */ @Value("${hik.playback.live.max-attempts:3}") private int maxAttempts = 3; + /** + * 是否自动重建意外中断的实时流。 + * + *FFmpeg 因设备断流、网络抖动退出时,浏览器侧既不会有错误事件也不会自动重连, + * 画面会停在最后一帧;这里在巡检时按退避策略重建,前提是最近仍有观众。
+ */ + @Value("${hik.playback.live.reconnect-enabled:true}") + private boolean reconnectEnabled = true; + /** 单路自动重连的最大次数;达到后停止自动尝试,等待浏览器重新打开该路。 */ + @Value("${hik.playback.live.reconnect-max-attempts:5}") + private int reconnectMaxAttempts = 5; + /** 自动重连的首次退避时长(毫秒),之后按次数翻倍。 */ + @Value("${hik.playback.live.reconnect-backoff-base-millis:30000}") + private long reconnectBackoffBaseMillis = 30000L; + /** 自动重连退避时长上限(毫秒)。 */ + @Value("${hik.playback.live.reconnect-backoff-max-millis:300000}") + private long reconnectBackoffMaxMillis = 300000L; + /** 最近多少毫秒内出现过观众才自动重连;无人观看时不占用设备会话。 */ + @Value("${hik.playback.live.reconnect-recent-reader-millis:120000}") + private long reconnectRecentReaderMillis = 120000L; + /** 重建后稳定播放多久视为恢复、把重连次数清零(毫秒)。 */ + @Value("${hik.playback.live.reconnect-stable-millis:60000}") + private long reconnectStableMillis = 60000L; /** 设备端拒绝后重试前等待设备释放上一次 RTSP 会话的基础时长。 */ @Value("${hik.playback.live.retry-release-wait-millis:1500}") private long retryReleaseWaitMillis = 1500L; @@ -69,15 +92,14 @@ public class HikNvrLiveStreamConfigVo { @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; + @Value("${hik.playback.live.stop-grace-millis:800}") + private long stopGraceMillis = 800L; /** 单台 NVR 允许同时播放的实时流上限,0 表示不限制。 */ @Value("${hik.playback.live.max-sessions-per-nvr:0}") private int maxSessionsPerNvr = 0; /** 是否回收长时间无人观看的实时流。 */ @Value("${hik.playback.live.idle-reap-enabled:true}") - private boolean idleReapEnabled = 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.yml b/src/main/resources/application.yml index a8606ba9..8c90bef1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,7 +81,9 @@ hik: 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} + # 页面停流请求只等 8 秒,聚合层还要排队,这里保持短一些;FFmpeg 收到 SIGTERM 后 + # 通常百毫秒级就会发出 TEARDOWN 并退出,超过该值会强制结束进程。 + stop-grace-millis: ${HIK_NVR_LIVE_STOP_GRACE_MILLIS:800} # 单个取流设备允许同时播放的实时流上限,0 表示不限制;请按设备远程预览预算调整。 # 统计粒度是"实际拉取的设备":直连摄像机时为相机 IP,经转发时为 NVR/CVR IP。 max-sessions-per-nvr: ${HIK_NVR_LIVE_MAX_SESSIONS_PER_NVR:8} @@ -90,6 +92,17 @@ hik: idle-reader-timeout-millis: ${HIK_NVR_LIVE_IDLE_READER_TIMEOUT_MILLIS:600000} # 空闲回收扫描间隔(毫秒)。 idle-reap-interval-millis: ${HIK_NVR_LIVE_IDLE_REAP_INTERVAL_MILLIS:60000} + # 意外断流(FFmpeg 退出)时是否自动重建:浏览器侧不会重连,画面会停在最后一帧。 + reconnect-enabled: ${HIK_NVR_LIVE_RECONNECT_ENABLED:true} + # 单路自动重连的最大次数;达到后不再自动尝试,等浏览器重新打开该路。 + reconnect-max-attempts: ${HIK_NVR_LIVE_RECONNECT_MAX_ATTEMPTS:5} + # 首次退避时长(毫秒),之后按次数翻倍,上限见下一项。 + reconnect-backoff-base-millis: ${HIK_NVR_LIVE_RECONNECT_BACKOFF_BASE_MILLIS:30000} + reconnect-backoff-max-millis: ${HIK_NVR_LIVE_RECONNECT_BACKOFF_MAX_MILLIS:300000} + # 最近多少毫秒内出现过观众才自动重连;无人观看时不占用设备会话。 + reconnect-recent-reader-millis: ${HIK_NVR_LIVE_RECONNECT_RECENT_READER_MILLIS:120000} + # 重建后稳定播放多久视为恢复并把重连次数清零(毫秒)。 + reconnect-stable-millis: ${HIK_NVR_LIVE_RECONNECT_STABLE_MILLIS:60000} # 实时预览是否优先直连摄像机取流:CVR(中心存储)等设备的远程转发额度很小, # 直连摄像机可绕开该瓶颈;地址缺失、端口不可达或鉴权失败时自动回退 NVR。 prefer-camera-direct: ${HIK_NVR_LIVE_PREFER_CAMERA_DIRECT:true}