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