|
|
|
@ -145,6 +145,8 @@ public class HikNvrLiveStreamService { |
|
|
|
private String cameraPassword; |
|
|
|
/** 直连摄像机 RTSP 端口连通性探测超时。 */ |
|
|
|
private int cameraProbeTimeoutMillis; |
|
|
|
/** 同一 IP 多通道相机的相机内通道分配顺序:asc / desc。 */ |
|
|
|
private String cameraChannelOrder; |
|
|
|
/** 已确认直连鉴权失败(相机凭据与 NVR 不同)的摄像机 IP,避免反复浪费一次尝试。 */ |
|
|
|
private final Set<String> cameraDirectAuthFailed = ConcurrentHashMap.newKeySet(); |
|
|
|
|
|
|
|
@ -198,6 +200,10 @@ public class HikNvrLiveStreamService { |
|
|
|
? "" : config.getCameraPassword(); |
|
|
|
// 探测必须显著短于启动时限,避免相机不可达时拖慢起流。 |
|
|
|
this.cameraProbeTimeoutMillis = Math.max(100, config.getCameraProbeTimeoutMillis()); |
|
|
|
// 多通道相机(双光谱等)的相机内通道分配顺序,默认按平台通道编码升序。 |
|
|
|
this.cameraChannelOrder = "desc".equalsIgnoreCase( |
|
|
|
config.getCameraChannelOrder() == null ? "" : config.getCameraChannelOrder().trim()) |
|
|
|
? "desc" : "asc"; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
@ -463,13 +469,17 @@ public class HikNvrLiveStreamService { |
|
|
|
} |
|
|
|
|
|
|
|
String rtmpUrl = rtmpBaseUrl + "/live/" + pending.streamId; |
|
|
|
// 并发额度按"实际拉取的设备"统计:直连时是相机 IP,走转发时才是 NVR/CVR IP。 |
|
|
|
// 否则直连摄像机取流会被所属 NVR 的额度限制住,等于没有绕开 CVR 的瓶颈。 |
|
|
|
// 并发额度按"实际拉取的设备"统计:直连时是相机 IP(多镜头相机再细到相机内通道), |
|
|
|
// 走转发时才是 NVR/CVR IP;否则直连取流会被所属 NVR 的额度限制住,等于没绕开 CVR 的瓶颈。 |
|
|
|
String streamHost = cameraTarget == null ? nvrIp : cameraTarget.devIp; |
|
|
|
if (!awaitSessionQuota(streamHost, deadline)) { |
|
|
|
int streamChannel = cameraTarget == null ? 0 : cameraTarget.cameraChannel; |
|
|
|
String quotaKey = streamChannel > 0 ? streamHost + "#" + streamChannel : streamHost; |
|
|
|
if (!awaitSessionQuota(quotaKey, deadline)) { |
|
|
|
String target = streamChannel > 0 |
|
|
|
? streamHost + "(相机通道" + streamChannel + ")" : streamHost; |
|
|
|
throw HikNvrPlaybackException.conflict( |
|
|
|
NVR_LIVE_SESSION_LIMIT, |
|
|
|
NVR_LIVE_SESSION_LIMIT + ":取流设备" + streamHost + "的并发实时流已达上限" |
|
|
|
NVR_LIVE_SESSION_LIMIT + ":取流设备" + target + "的并发实时流已达上限" |
|
|
|
+ maxSessionsPerNvr + "路,请先关闭其他窗口后重试"); |
|
|
|
} |
|
|
|
Process process; |
|
|
|
@ -490,6 +500,7 @@ public class HikNvrLiveStreamService { |
|
|
|
pending.target.getCameraCode(), |
|
|
|
pending.target.getNvrInfo().getNvrIp(), |
|
|
|
streamHost, |
|
|
|
streamChannel, |
|
|
|
pending.target.getChannel(), |
|
|
|
pending.streamType, |
|
|
|
pending.streamId, |
|
|
|
@ -498,9 +509,8 @@ public class HikNvrLiveStreamService { |
|
|
|
pending.flvUrl); |
|
|
|
// 注册本地会话后,停止和状态请求即可立即定位并释放正在启动的进程。 |
|
|
|
sessions.put(sessionKey, session); |
|
|
|
log.info("[live] started cameraCode={} nvrIp={} host={} source={} streamType={} streamId={} attempt={} log={}", |
|
|
|
session.cameraCode, session.nvrIp, |
|
|
|
cameraTarget == null ? session.nvrIp : cameraTarget.devIp, |
|
|
|
log.info("[live] started cameraCode={} nvrIp={} host={} cameraChannel={} source={} streamType={} streamId={} attempt={} log={}", |
|
|
|
session.cameraCode, session.nvrIp, streamHost, streamChannel, |
|
|
|
source, session.streamType, session.streamId, attempt, logFile); |
|
|
|
|
|
|
|
long remaining = Math.max(0L, deadline - System.currentTimeMillis()); |
|
|
|
@ -1231,16 +1241,16 @@ public class HikNvrLiveStreamService { |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 统计仍在运行且正在从指定设备取流的本地实时流数量。 |
|
|
|
* 统计仍在运行的、命中同一并发额度键的本地实时流数量。 |
|
|
|
* |
|
|
|
* @param host 实际取流设备地址(相机 IP 或 NVR/CVR IP) |
|
|
|
* @param quotaKey 额度键:直连相机为 "相机IP#相机通道",转发为设备 IP |
|
|
|
* @return 本地活跃会话数 |
|
|
|
*/ |
|
|
|
private int activeSessionsOn(String host) { |
|
|
|
private int activeSessionsOn(String quotaKey) { |
|
|
|
int count = 0; |
|
|
|
// 本地会话表规模很小,逐条比对取流设备地址即可。 |
|
|
|
// 本地会话表规模很小,逐条比对额度键即可。 |
|
|
|
for (LiveSession session : sessions.values()) { |
|
|
|
if (session.isAlive() && host.equalsIgnoreCase(session.sourceHost)) { |
|
|
|
if (session.isAlive() && quotaKey.equalsIgnoreCase(session.quotaKey())) { |
|
|
|
count++; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -1334,8 +1344,87 @@ public class HikNvrLiveStreamService { |
|
|
|
.password(password) |
|
|
|
.streamType(streamType) |
|
|
|
.build(); |
|
|
|
// 直连摄像机只有一个通道,trackId = 1 * 100 + 码流类型,即 101/102。 |
|
|
|
return new CameraDirectTarget(devIp, urlBuilder.buildLive(cameraRequest, 1)); |
|
|
|
// 双光谱等多镜头相机在平台上一个 IP 占多个通道,必须用相机自己的通道号才能取到对应镜头。 |
|
|
|
int cameraChannel = resolveCameraChannel(target.getCameraCode(), devIp); |
|
|
|
// trackId = 相机内通道 * 100 + 码流类型,例如通道 1 = 101/102,通道 2 = 201/202。 |
|
|
|
return new CameraDirectTarget(devIp, cameraChannel, |
|
|
|
urlBuilder.buildLive(cameraRequest, cameraChannel)); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 计算平台通道对应的相机内通道号。 |
|
|
|
* |
|
|
|
* <p>同一台多镜头相机(双光谱:可见光 + 热成像)在平台上占多个通道,直连 RTSP 时 |
|
|
|
* 必须带上相机自己的通道号,否则两个平台通道会拉到同一个镜头。优先按平台通道名判断 |
|
|
|
* 镜头类型(热成像/测温 → 相机通道 1,可见光 → 相机通道 2,与海康双光谱默认一致), |
|
|
|
* 名称无法区分时按平台通道编码顺序分配 1..N;整站顺序相反时用 |
|
|
|
* {@code hik.playback.live.camera-channel-order=desc} 反转。</p> |
|
|
|
* |
|
|
|
* @param cameraCode 当前平台通道编码 |
|
|
|
* @param devIp 摄像机 IP |
|
|
|
* @return 相机内通道号,从 1 开始 |
|
|
|
*/ |
|
|
|
private int resolveCameraChannel(String cameraCode, String devIp) { |
|
|
|
List<IvsCamera> peers; |
|
|
|
try { |
|
|
|
// 该查询命中 ivs_camera.dev_ip 索引,返回同一台相机在平台上的全部通道。 |
|
|
|
peers = ivsCameraMapper.selectChannelPeersByDevIp(devIp); |
|
|
|
} catch (RuntimeException queryFailure) { |
|
|
|
log.warn("[live] 查询同IP通道失败,按单通道相机处理 devIp={}:{}", |
|
|
|
devIp, queryFailure.getMessage()); |
|
|
|
return 1; |
|
|
|
} |
|
|
|
// 单通道相机(绝大多数)直接使用相机通道 1。 |
|
|
|
if (peers == null || peers.size() <= 1) { |
|
|
|
return 1; |
|
|
|
} |
|
|
|
int index = -1; |
|
|
|
String cameraName = null; |
|
|
|
// 通道记录按 code 升序返回,包含当前通道自己。 |
|
|
|
for (int position = 0; position < peers.size(); position++) { |
|
|
|
IvsCamera peer = peers.get(position); |
|
|
|
if (peer != null && cameraCode.equalsIgnoreCase(peer.getCode())) { |
|
|
|
index = position; |
|
|
|
cameraName = peer.getName(); |
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
boolean reverse = "desc".equals(cameraChannelOrder); |
|
|
|
// 先按名称判断镜头类型:双光谱相机两个通道名通常能区分可见光/热成像。 |
|
|
|
int namedChannel = cameraChannelByThermalName(cameraName); |
|
|
|
if (namedChannel > 0) { |
|
|
|
// 顺序反转时交换通道 1 与通道 2。 |
|
|
|
return reverse ? (namedChannel == 1 ? 2 : 1) : namedChannel; |
|
|
|
} |
|
|
|
if (index < 0) { |
|
|
|
// 当前通道不在同 IP 列表中(数据异常)时退回相机通道 1。 |
|
|
|
return 1; |
|
|
|
} |
|
|
|
// 名称无法区分(例如两个通道都叫“双光谱”)时按平台通道顺序分配。 |
|
|
|
int ordered = reverse ? peers.size() - 1 - index : index; |
|
|
|
return ordered + 1; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 按通道名判断镜头类型并返回对应的相机内通道号。 |
|
|
|
* |
|
|
|
* @param cameraName 平台通道名 |
|
|
|
* @return 热成像/测温返回 1,可见光返回 2,无法判断返回 0 |
|
|
|
*/ |
|
|
|
private int cameraChannelByThermalName(String cameraName) { |
|
|
|
if (!hasText(cameraName)) { |
|
|
|
return 0; |
|
|
|
} |
|
|
|
String name = cameraName.toLowerCase(Locale.ROOT); |
|
|
|
// 海康双光谱相机默认 1xx 为热成像通道、2xx 为可见光通道。 |
|
|
|
if (name.contains("热成像") || name.contains("测温") || name.contains("thermal")) { |
|
|
|
return 1; |
|
|
|
} |
|
|
|
if (name.contains("可见光") || name.contains("optical") |
|
|
|
|| name.contains("visual") || name.contains("白光")) { |
|
|
|
return 2; |
|
|
|
} |
|
|
|
return 0; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
@ -1635,12 +1724,15 @@ public class HikNvrLiveStreamService { |
|
|
|
private static final class CameraDirectTarget { |
|
|
|
/** 摄像机 IP,用于鉴权失败后记忆该相机。 */ |
|
|
|
private final String devIp; |
|
|
|
/** 相机内通道号(双光谱相机为 1/2),用于并发额度口径与日志核对。 */ |
|
|
|
private final int cameraChannel; |
|
|
|
/** 可直接交给 FFmpeg 的摄像机 RTSP 地址。 */ |
|
|
|
private final String rtspUrl; |
|
|
|
|
|
|
|
/** 保存摄像机 IP 与直连 RTSP 地址。 */ |
|
|
|
private CameraDirectTarget(String devIp, String rtspUrl) { |
|
|
|
/** 保存摄像机 IP、相机内通道与直连 RTSP 地址。 */ |
|
|
|
private CameraDirectTarget(String devIp, int cameraChannel, String rtspUrl) { |
|
|
|
this.devIp = devIp; |
|
|
|
this.cameraChannel = cameraChannel; |
|
|
|
this.rtspUrl = rtspUrl; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -1667,6 +1759,8 @@ public class HikNvrLiveStreamService { |
|
|
|
private final String nvrIp; |
|
|
|
/** 本路流实际拉取的设备地址:直连摄像机时为相机 IP,经转发时为 NVR/CVR IP。 */ |
|
|
|
private final String sourceHost; |
|
|
|
/** 相机内通道号;走 NVR/CVR 转发时为 0。 */ |
|
|
|
private final int sourceChannel; |
|
|
|
/** 页面逻辑通道号。 */ |
|
|
|
private final Integer channel; |
|
|
|
/** 主码流 1 或子码流 2。 */ |
|
|
|
@ -1687,6 +1781,7 @@ public class HikNvrLiveStreamService { |
|
|
|
String cameraCode, |
|
|
|
String nvrIp, |
|
|
|
String sourceHost, |
|
|
|
int sourceChannel, |
|
|
|
Integer channel, |
|
|
|
int streamType, |
|
|
|
String streamId, |
|
|
|
@ -1696,6 +1791,7 @@ public class HikNvrLiveStreamService { |
|
|
|
this.cameraCode = cameraCode; |
|
|
|
this.nvrIp = nvrIp; |
|
|
|
this.sourceHost = sourceHost; |
|
|
|
this.sourceChannel = sourceChannel; |
|
|
|
this.channel = channel; |
|
|
|
this.streamType = streamType; |
|
|
|
this.streamId = streamId; |
|
|
|
@ -1708,6 +1804,16 @@ public class HikNvrLiveStreamService { |
|
|
|
private boolean isAlive() { |
|
|
|
return process != null && process.isAlive(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 返回并发额度统计键。 |
|
|
|
* |
|
|
|
* <p>直连摄像机按"相机 IP + 相机内通道"统计,这样双光谱相机的可见光与热成像 |
|
|
|
* 可以同时播放,而同一个镜头不会被重复拉取;走转发时按设备 IP 统计。</p> |
|
|
|
*/ |
|
|
|
private String quotaKey() { |
|
|
|
return sourceChannel > 0 ? sourceHost + "#" + sourceChannel : sourceHost; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** 一次排队或正在执行的起流任务。 */ |
|
|
|
|