|
|
@ -3,9 +3,9 @@ package com.inspect.ivs.controller; |
|
|
import com.alibaba.fastjson.JSONObject; |
|
|
import com.alibaba.fastjson.JSONObject; |
|
|
import com.inspect.base.core.domain.Response; |
|
|
import com.inspect.base.core.domain.Response; |
|
|
import com.inspect.base.core.utils.StringUtils; |
|
|
import com.inspect.base.core.utils.StringUtils; |
|
|
import com.inspect.base.core.utils.uuid.UUID; |
|
|
|
|
|
import com.inspect.base.redis.service.RedisService; |
|
|
import com.inspect.base.redis.service.RedisService; |
|
|
import com.inspect.ivs.constant.IvsConst; |
|
|
import com.inspect.ivs.constant.IvsConst; |
|
|
|
|
|
import com.inspect.ivs.constant.ZlMediaConst; |
|
|
import com.inspect.ivs.enums.RtspType; |
|
|
import com.inspect.ivs.enums.RtspType; |
|
|
import com.inspect.ivs.service.HlsPlaybackComponent; |
|
|
import com.inspect.ivs.service.HlsPlaybackComponent; |
|
|
import com.inspect.ivs.service.IvsCommonService; |
|
|
import com.inspect.ivs.service.IvsCommonService; |
|
|
@ -28,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController; |
|
|
|
|
|
|
|
|
import javax.annotation.Resource; |
|
|
import javax.annotation.Resource; |
|
|
import java.net.URLEncoder; |
|
|
import java.net.URLEncoder; |
|
|
|
|
|
import java.util.concurrent.TimeUnit; |
|
|
|
|
|
import java.util.concurrent.atomic.AtomicLong; |
|
|
|
|
|
|
|
|
@RestController |
|
|
@RestController |
|
|
@RequestMapping({"/api/v1/stream"}) |
|
|
@RequestMapping({"/api/v1/stream"}) |
|
|
@ -41,8 +43,15 @@ public class IvsStreamController { |
|
|
private Boolean bIvsTestMode; |
|
|
private Boolean bIvsTestMode; |
|
|
@Value("${rtsp.type:xintong}") |
|
|
@Value("${rtsp.type:xintong}") |
|
|
private RtspType rtspType; |
|
|
private RtspType rtspType; |
|
|
|
|
|
/** 实时预览是否使用子码流。NVR 主码流并发/带宽受限时,子码流更稳定 */ |
|
|
|
|
|
@Value("${video.preview.use-sub-stream:true}") |
|
|
|
|
|
private Boolean useSubStream; |
|
|
@Resource |
|
|
@Resource |
|
|
private RedisService redisService; |
|
|
private RedisService redisService; |
|
|
|
|
|
/** 无人观看超过该秒数即回收,需大于浏览器正常建连耗时 */ |
|
|
|
|
|
private static final long STREAM_IDLE_SECONDS = 120L; |
|
|
|
|
|
private static final long CLEANUP_INTERVAL_MS = 30_000L; |
|
|
|
|
|
private static final AtomicLong LAST_CLEANUP = new AtomicLong(0L); |
|
|
|
|
|
|
|
|
public IvsStreamController(IvsCommonService ivsCommonService, ZlMediaComponent zlMediaComponent, XinTongComponent xinTongComponent, HlsPlaybackComponent hlsPlaybackComponent) { |
|
|
public IvsStreamController(IvsCommonService ivsCommonService, ZlMediaComponent zlMediaComponent, XinTongComponent xinTongComponent, HlsPlaybackComponent hlsPlaybackComponent) { |
|
|
this.ivsCommonService = ivsCommonService; |
|
|
this.ivsCommonService = ivsCommonService; |
|
|
@ -81,7 +90,13 @@ public class IvsStreamController { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
log.debug("[IVS] {}", ivsVideoRtspView); |
|
|
log.debug("[IVS] {}", ivsVideoRtspView); |
|
|
String streamId = UUID.randomUUID().toString(); |
|
|
|
|
|
|
|
|
if (Boolean.TRUE.equals(useSubStream)) { |
|
|
|
|
|
String subStreamUrl = toSubStream(ivsVideoRtspView.getRtspURL()); |
|
|
|
|
|
if (StringUtils.isNotEmpty(subStreamUrl)) { |
|
|
|
|
|
ivsVideoRtspView.setRtspURL(subStreamUrl); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
String streamId = streamIdOf(ivsVideoRtspVo.getCameraCode()); |
|
|
redisService.setCacheObject(String.format(IvsConst.IVS_STREAM_ID, streamId), ivsVideoRtspVo.getCameraCode()); |
|
|
redisService.setCacheObject(String.format(IvsConst.IVS_STREAM_ID, streamId), ivsVideoRtspVo.getCameraCode()); |
|
|
log.debug("[IVS] => => rtspType: {}, rtspType.ordinal: {}, streamId: {}", rtspType, rtspType.ordinal(), streamId); |
|
|
log.debug("[IVS] => => rtspType: {}, rtspType.ordinal: {}, streamId: {}", rtspType, rtspType.ordinal(), streamId); |
|
|
switch (rtspType.ordinal()) { |
|
|
switch (rtspType.ordinal()) { |
|
|
@ -91,6 +106,8 @@ public class IvsStreamController { |
|
|
case 1: |
|
|
case 1: |
|
|
xinTongComponent.convert(ivsVideoRtspView.getRtspURL(), streamId, ivsVideoRtspView); |
|
|
xinTongComponent.convert(ivsVideoRtspView.getRtspURL(), streamId, ivsVideoRtspView); |
|
|
} |
|
|
} |
|
|
|
|
|
addViewer(streamId); |
|
|
|
|
|
cleanupIdleStreamsAsync(); |
|
|
|
|
|
|
|
|
IvsStreamStartView ivsStreamStartView = new IvsStreamStartView(streamId, ivsVideoRtspView); |
|
|
IvsStreamStartView ivsStreamStartView = new IvsStreamStartView(streamId, ivsVideoRtspView); |
|
|
log.debug("[IVS] {}", ivsStreamStartView); |
|
|
log.debug("[IVS] {}", ivsStreamStartView); |
|
|
@ -99,8 +116,21 @@ public class IvsStreamController { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@GetMapping({"stop"}) |
|
|
@GetMapping({"stop"}) |
|
|
public Response stopStream(String streamId) { |
|
|
|
|
|
log.debug("[GAB] streamId: {}", streamId); |
|
|
|
|
|
|
|
|
public Response stopStream(@RequestParam(value = "streamId", required = false) String streamId, |
|
|
|
|
|
@RequestParam(value = "cameraCode", required = false) String cameraCode) { |
|
|
|
|
|
String id = StringUtils.isNotEmpty(streamId) |
|
|
|
|
|
? streamId |
|
|
|
|
|
: (StringUtils.isNotEmpty(cameraCode) ? streamIdOf(cameraCode) : null); |
|
|
|
|
|
if (StringUtils.isEmpty(id)) { |
|
|
|
|
|
return Response.fail("streamId 或 cameraCode 不能为空"); |
|
|
|
|
|
} |
|
|
|
|
|
long remaining = removeViewer(id); |
|
|
|
|
|
log.info("[IVS] stop stream, streamId={}, cameraCode={}, remainingViewers={}", id, cameraCode, remaining); |
|
|
|
|
|
// 最后一个观看者退出时才真正关流,避免误关其他页面正在看的流 |
|
|
|
|
|
if (remaining <= 0) { |
|
|
|
|
|
zlMediaComponent.stop(id); |
|
|
|
|
|
redisService.deleteObject(String.format(IvsConst.IVS_STREAM_ID, id)); |
|
|
|
|
|
} |
|
|
return Response.ok(); |
|
|
return Response.ok(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -149,4 +179,80 @@ public class IvsStreamController { |
|
|
String uri = IvsConst.URI_LIVE_STATUS + "?cameraCode=" + URLEncoder.encode(cameraCode, "UTF-8"); |
|
|
String uri = IvsConst.URI_LIVE_STATUS + "?cameraCode=" + URLEncoder.encode(cameraCode, "UTF-8"); |
|
|
return this.hlsPlaybackComponent.get(uri); |
|
|
return this.hlsPlaybackComponent.get(uri); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 预览切换子码流: |
|
|
|
|
|
* 海康 .../Streaming/Channels/1001 -> .../Streaming/Channels/1002; |
|
|
|
|
|
* 大华 .../cam/realmonitor?channel=3&subtype=0 -> subtype=1。 |
|
|
|
|
|
* 其它格式原样返回。 |
|
|
|
|
|
*/ |
|
|
|
|
|
private static String toSubStream(String rtspUrl) { |
|
|
|
|
|
if (StringUtils.isEmpty(rtspUrl)) { |
|
|
|
|
|
return rtspUrl; |
|
|
|
|
|
} |
|
|
|
|
|
int idx = rtspUrl.lastIndexOf("/Channels/"); |
|
|
|
|
|
if (idx >= 0) { |
|
|
|
|
|
String prefix = rtspUrl.substring(0, idx + "/Channels/".length()); |
|
|
|
|
|
String tail = rtspUrl.substring(idx + "/Channels/".length()); |
|
|
|
|
|
if (tail.length() > 2 && tail.matches("\\d+") && tail.endsWith("01")) { |
|
|
|
|
|
return prefix + tail.substring(0, tail.length() - 2) + "02"; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
if (rtspUrl.contains("/cam/realmonitor") && rtspUrl.contains("subtype=0")) { |
|
|
|
|
|
return rtspUrl.replace("subtype=0", "subtype=1"); |
|
|
|
|
|
} |
|
|
|
|
|
return rtspUrl; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 摄像头编码转 streamId:只保留字母数字,保证可作为 ZLM 的 stream/key。 */ |
|
|
|
|
|
private static String streamIdOf(String cameraCode) { |
|
|
|
|
|
if (StringUtils.isEmpty(cameraCode)) { |
|
|
|
|
|
// 缺少摄像头编码时无法做去重,退化为一次性流,避免不同摄像头共用同一个 streamId |
|
|
|
|
|
return ZlMediaConst.STREAM_ID_PREFIX + java.util.UUID.randomUUID().toString().replace("-", ""); |
|
|
|
|
|
} |
|
|
|
|
|
String code = cameraCode.replaceAll("[^A-Za-z0-9]", "_"); |
|
|
|
|
|
return ZlMediaConst.STREAM_ID_PREFIX + code; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private void addViewer(String streamId) { |
|
|
|
|
|
String key = String.format(IvsConst.IVS_STREAM_VIEWER, streamId); |
|
|
|
|
|
redisService.redisTemplate.opsForValue().increment(key); |
|
|
|
|
|
redisService.expire(key, IvsConst.IVS_STREAM_VIEWER_EXPIRE, TimeUnit.MINUTES); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private long removeViewer(String streamId) { |
|
|
|
|
|
String key = String.format(IvsConst.IVS_STREAM_VIEWER, streamId); |
|
|
|
|
|
Long remaining = redisService.redisTemplate.opsForValue().decrement(key); |
|
|
|
|
|
if (remaining == null || remaining <= 0) { |
|
|
|
|
|
redisService.deleteObject(key); |
|
|
|
|
|
return 0L; |
|
|
|
|
|
} |
|
|
|
|
|
return remaining; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 兜底回收空闲流:前端未显式调用 stop(关页面、断网等)时, |
|
|
|
|
|
* 清掉长时间无人观看的拉流代理,避免占满 NVR 会话和带宽。 |
|
|
|
|
|
* 每 30 秒最多触发一次,异步执行不阻塞取流。 |
|
|
|
|
|
*/ |
|
|
|
|
|
private void cleanupIdleStreamsAsync() { |
|
|
|
|
|
long now = System.currentTimeMillis(); |
|
|
|
|
|
long last = LAST_CLEANUP.get(); |
|
|
|
|
|
if (now - last < CLEANUP_INTERVAL_MS || !LAST_CLEANUP.compareAndSet(last, now)) { |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
Thread thread = new Thread(() -> { |
|
|
|
|
|
try { |
|
|
|
|
|
zlMediaComponent.closeIdleStreams(STREAM_IDLE_SECONDS); |
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
log.warn("[IVS] cleanup idle streams failed: {}", e.getMessage()); |
|
|
|
|
|
} |
|
|
|
|
|
}, "ivs-stream-cleanup"); |
|
|
|
|
|
thread.setDaemon(true); |
|
|
|
|
|
thread.start(); |
|
|
|
|
|
} catch (Exception e) { |
|
|
|
|
|
log.warn("[IVS] start cleanup thread failed: {}", e.getMessage()); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
} |