diff --git a/fastbee-admin/src/main/resources/application-dev.yml b/fastbee-admin/src/main/resources/application-dev.yml index f43efce0..c73727ba 100644 --- a/fastbee-admin/src/main/resources/application-dev.yml +++ b/fastbee-admin/src/main/resources/application-dev.yml @@ -129,10 +129,10 @@ emqx: ApiSecret: emqx.api.123 # sip 配置 sip: - enabled: false # 是否启用视频监控SIP,true为启用 + enabled: true # 是否启用视频监控SIP,true为启用 ## 本地调试时,绑定网卡局域网IP,设备在同一局域网,设备接入IP填写绑定IP ## 部署服务端时,默认绑定容器IP,设备接入IP填写服务器公网IP - ip: 192.168.110.78 # 本服务启动的 IP + ip: 192.168.110.160 # 本服务启动的 IP port: 5061 # SIP端口(保持默认) domain: 3402000000 # 由省级、市级、区级、基层编号组成 id: 34020000002000000001 # 同上,另外增加编号,(可保持默认) diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/media/PlayerController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/media/PlayerController.java index 0012f04d..77b3b73d 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/media/PlayerController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/media/PlayerController.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Slf4j @@ -30,6 +31,12 @@ public class PlayerController extends BaseController { return AjaxResult.success(MessageUtils.message("success"), playService.play(deviceId, channelId, false)); } + @ApiOperation("获取直播流信息(流就绪后返回)") + @GetMapping("/playstream/{deviceId}/{channelId}") + public AjaxResult playstream(@PathVariable String deviceId, @PathVariable String channelId) { + return AjaxResult.success(MessageUtils.message("success"), playService.getStreamInfo(deviceId, channelId)); + } + @ApiOperation("回放播放") @GetMapping("/playback/{deviceId}/{channelId}") public AjaxResult playback(@PathVariable String deviceId, @@ -39,7 +46,13 @@ public class PlayerController extends BaseController { @ApiOperation("停止推流") @GetMapping("/closeStream/{deviceId}/{channelId}/{streamId}") - public AjaxResult closeStream(@PathVariable String deviceId, @PathVariable String channelId, @PathVariable String streamId) { + public AjaxResult closeStream(@PathVariable String deviceId, + @PathVariable String channelId, + @PathVariable String streamId, + @RequestParam(value = "force", required = false, defaultValue = "false") boolean force) { + if (force) { + return AjaxResult.success("success!", playService.closeStreamForce(deviceId, channelId, streamId)); + } return AjaxResult.success("success!", playService.closeStream(deviceId, channelId, streamId)); } diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/IPlayService.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/IPlayService.java index 9d56e6e1..c94b3efe 100644 --- a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/IPlayService.java +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/IPlayService.java @@ -6,10 +6,21 @@ public interface IPlayService { Stream play(String deviceId, String channelId, boolean record); + /** + * 获取流信息(仅当流已在ZLM侧就绪/存在时返回) + */ + Stream getStreamInfo(String deviceId, String channelId); + Stream playback(String deviceId, String channelId, String startTime, String endTime); String closeStream(String deviceId, String channelId, String streamId); + /** + * 强制关闭流(直接发送BYE,不依赖ZLM的readerCount查询) + * 适用于ZLM明确回调“无人观看”的场景。 + */ + String closeStreamForce(String deviceId, String channelId, String streamId); + String playbackPause(String deviceId, String channelId, String streamId); String playbackReplay(String deviceId, String channelId, String streamId); diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/MediaServerServiceImpl.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/MediaServerServiceImpl.java index 070165d4..1ae7af65 100644 --- a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/MediaServerServiceImpl.java +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/MediaServerServiceImpl.java @@ -220,6 +220,11 @@ public class MediaServerServiceImpl implements IMediaServerService { param.put("hook.enable", "1"); param.put("general.mediaServerId", mediaServer.getServerId()); param.put("general.flowThreshold", "64"); + // 无人观看多久后触发 on_stream_none_reader(单位ms)。值越小关流越快,但也更容易受短暂抖动影响。 + // 这里给一个偏保守的默认值(5s),可按现场需要调整。 + param.put("general.streamNoneReaderDelayMS", "5000"); + // 等待流注册的最大时间(单位ms),适当增大可降低“刚开始播放偶发失败需要刷新”的概率 + param.put("general.maxStreamWaitMS", "5000"); param.put("hook.on_play", String.format("%s/on_play", hookPrex)); param.put("hook.on_http_access", String.format("%s/on_http_access", hookPrex)); param.put("hook.on_publish", String.format("%s/on_publish", hookPrex)); diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/PlayServiceImpl.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/PlayServiceImpl.java index c554bdcd..ad2fd5fc 100644 --- a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/PlayServiceImpl.java +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/PlayServiceImpl.java @@ -3,6 +3,7 @@ package com.fastbee.sip.service.impl; import com.alibaba.fastjson2.JSONObject; import com.fastbee.sip.domain.MediaServer; import com.fastbee.sip.domain.SipDevice; +import com.fastbee.sip.enums.SessionType; import com.fastbee.sip.model.Stream; import com.fastbee.sip.model.VideoSessionInfo; import com.fastbee.sip.server.IRtspCmd; @@ -92,6 +93,26 @@ public class PlayServiceImpl implements IPlayService { } } + @Override + public Stream getStreamInfo(String deviceId, String channelId) { + String streamId = String.format("%s_%s_%s", SipUtil.PREFIX_PLAY, deviceId, channelId); + VideoSessionInfo sinfo = streamSession.getSessionInfo(deviceId, channelId, streamId, SessionType.play.name()); + if (sinfo == null) { + return null; + } + MediaServer mediaInfo = mediaServerService.selectMediaServerBydeviceSipId(deviceId); + if (mediaInfo == null) { + log.error("getStreamInfo mediaInfo is null,deviceId:{},channelId:{}", deviceId, channelId); + return null; + } + JSONObject rtpInfo = zlmApiUtils.getRtpInfo(mediaInfo, streamId); + if (rtpInfo != null && rtpInfo.getInteger("code") == 0 && Boolean.TRUE.equals(rtpInfo.getBoolean("exist"))) { + // 流在ZLM侧已存在:返回播放地址 + return zmlHookService.updateStream(sinfo); + } + return null; + } + @Override public String closeStream(String deviceId, String channelId, String streamId) { MediaServer mediaInfo = mediaServerService.selectMediaServerBydeviceSipId(deviceId); @@ -110,6 +131,22 @@ public class PlayServiceImpl implements IPlayService { return ""; } + @Override + public String closeStreamForce(String deviceId, String channelId, String streamId) { + SipDevice dev = sipDeviceService.selectSipDeviceBySipId(deviceId); + try { + if (dev != null) { + sipCmd.streamByeCmd(dev, channelId, streamId, null); + } else { + // 兜底:按deviceId尝试关闭 + sipCmd.streamByeCmd(deviceId, channelId, streamId, null); + } + } catch (Exception e) { + log.warn("closeStreamForce error, deviceId:{}, channelId:{}, streamId:{}", deviceId, channelId, streamId, e); + } + return ""; + } + @Override public Stream playback(String deviceId, String channelId, String startTime, String endTime) { SipDevice dev = sipDeviceService.selectSipDeviceBySipId(deviceId); diff --git a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/ZmlHookServiceImpl.java b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/ZmlHookServiceImpl.java index e748e2b1..719c0fa9 100644 --- a/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/ZmlHookServiceImpl.java +++ b/fastbee-server/sip-server/src/main/java/com/fastbee/sip/service/impl/ZmlHookServiceImpl.java @@ -106,7 +106,6 @@ public class ZmlHookServiceImpl implements IZmlHookService { @Override public JSONObject onPublish(JSONObject json) { log.info("onPublish:{}", json); - String id = json.getString("id"); String mediaServerId = json.getString("mediaServerId"); String schema = json.getString("schema"); String app = json.getString("app"); @@ -188,6 +187,18 @@ public class ZmlHookServiceImpl implements IZmlHookService { break; default: ret.put("close", true); + // 兜底:无人观看时,主动触发关闭(BYE)并清理会话,避免 pushing 状态残留导致设备持续上行耗流量 + try { + // 此回调本身就是“无人观看”,无需再依赖readerCount判断,直接BYE更可靠 + playService.closeStreamForce(sinfo.getDeviceId(), sinfo.getChannelId(), streamId); + sinfo.setPushing(false); + sinfo.setRecording(false); + sinfo.setPlayer(0); + sinfo.setOnPlaytime(0); + videoSessionManager.put(sinfo, null); + } catch (Exception e) { + log.warn("[ZLM HOOK] 无人观看关闭流失败: {}/{} stream={}", sinfo.getDeviceId(), sinfo.getChannelId(), streamId, e); + } } } return ret; @@ -249,7 +260,6 @@ public class ZmlHookServiceImpl implements IZmlHookService { @Override public JSONObject onFlowReport(JSONObject json) { - String id = json.getString("id"); String mediaServerId = json.getString("mediaServerId"); String schema = json.getString("schema"); String app = json.getString("app"); diff --git a/vue/src/api/iot/channel.js b/vue/src/api/iot/channel.js index 7c0b1d7d..63ccdc9e 100644 --- a/vue/src/api/iot/channel.js +++ b/vue/src/api/iot/channel.js @@ -67,10 +67,11 @@ export function playback(deviceId, channelId, query) { }); } -export function closeStream(deviceId, channelId, streamId) { +export function closeStream(deviceId, channelId, streamId, force = false) { return request({ url: '/sip/player/closeStream/' + deviceId + '/' + channelId + '/' + streamId, method: 'get', + params: { force }, }); } diff --git a/vue/src/lang/en-US/dataCenter.json b/vue/src/lang/en-US/dataCenter.json index 5fd36a8c..d5b35dd2 100644 --- a/vue/src/lang/en-US/dataCenter.json +++ b/vue/src/lang/en-US/dataCenter.json @@ -8,6 +8,7 @@ "dataCenter.analysis.349202-6": "Reset", "dataCenter.analysis.349202-7": "There is currently no data available", "dataCenter.analysis.349202-8": "Last 2 hours", + "dataCenter.analysis.384934-25": "Last 2 days", "dataCenter.analysis.349202-9": "Last day", "dataCenter.analysis.349202-10": "In the past seven days", "dataCenter.analysis.349202-11": "Last 30 days", @@ -45,4 +46,4 @@ "dataCenter.history.384934-22": "Last 30 days", "dataCenter.history.384934-23": "total", "dataCenter.history.384934-24": "Export Excel" -} \ No newline at end of file +} diff --git a/vue/src/lang/zh-CN/dataCenter.json b/vue/src/lang/zh-CN/dataCenter.json index f8680331..40091ca4 100644 --- a/vue/src/lang/zh-CN/dataCenter.json +++ b/vue/src/lang/zh-CN/dataCenter.json @@ -8,6 +8,7 @@ "dataCenter.analysis.349202-6": "重置", "dataCenter.analysis.349202-7": "暂无数据", "dataCenter.analysis.349202-8": "最近2小时", + "dataCenter.analysis.384934-25": "最近两天", "dataCenter.analysis.349202-9": "最近一天", "dataCenter.analysis.349202-10": "最近七天", "dataCenter.analysis.349202-11": "最近三十天", @@ -45,4 +46,4 @@ "dataCenter.history.384934-22": "最近三十天", "dataCenter.history.384934-23": "总量", "dataCenter.history.384934-24": "导出Excel" -} \ No newline at end of file +} diff --git a/vue/src/views/components/player/deviceLiveStream.vue b/vue/src/views/components/player/deviceLiveStream.vue index 78bc1ab1..c2be502b 100644 --- a/vue/src/views/components/player/deviceLiveStream.vue +++ b/vue/src/views/components/player/deviceLiveStream.vue @@ -117,10 +117,33 @@ export default { }, startPlayRecord() { console.log('录像状态: [' + this.playrecord + ']'); + // 开启录像时二次确认:提示会产生大量流量/存储占用 + if (this.playrecord) { + this.$confirm( + '开启【直播录像】将持续产生较大上行流量,并占用服务器存储/带宽。\n\n建议:使用完务必关闭,否则可能导致流量快速耗尽。', + '温馨提示', + { + confirmButtonText: '继续开启', + cancelButtonText: '取消', + type: 'warning', + dangerouslyUseHTMLString: false, + } + ) + .then(() => { + this.closeDestroy(true); + setTimeout(() => { + this.startPlayer(); + }, 500); + }) + .catch(() => { + // 用户取消:回滚开关状态 + this.playrecord = false; + }); + return; + } + + // 关闭录像:直接停流 this.closeDestroy(true); - setTimeout(() => { - this.startPlayer(); - }, 500); }, // 开启直播播放器 startPlayer() { @@ -162,7 +185,7 @@ export default { if (force) { if (this.playing && this.streamId) { console.log('强制关闭推流: [' + this.streamId + ']'); - closeStream(this.deviceId, this.channelId, this.streamId).then((res) => { + closeStream(this.deviceId, this.channelId, this.streamId, true).then((res) => { this.streamId = ''; this.ssrc = ''; this.playurl = ''; @@ -177,7 +200,7 @@ export default { } if (this.playing && this.streamId) { console.log('关闭推流: [' + this.streamId + ']'); - closeStream(this.deviceId, this.channelId, this.streamId).then((res) => { + closeStream(this.deviceId, this.channelId, this.streamId, false).then((res) => { this.streamId = ''; this.ssrc = ''; this.playurl = ''; diff --git a/vue/src/views/components/player/deviceVideo.vue b/vue/src/views/components/player/deviceVideo.vue index 532f46fb..979649ed 100644 --- a/vue/src/views/components/player/deviceVideo.vue +++ b/vue/src/views/components/player/deviceVideo.vue @@ -246,7 +246,7 @@ export default { /**关闭播放流*/ closeStream() { if (this.playing && this.streamId) { - closeStream(this.deviceId, this.channelId, this.streamId).then((res) => { + closeStream(this.deviceId, this.channelId, this.streamId, true).then((res) => { this.streamId = ''; this.ssrc = ''; this.playurl = ''; diff --git a/vue/src/views/dataCenter/history.vue b/vue/src/views/dataCenter/history.vue index 5216e4f9..07e3b092 100644 --- a/vue/src/views/dataCenter/history.vue +++ b/vue/src/views/dataCenter/history.vue @@ -180,6 +180,15 @@ export default { picker.$emit('pick', [start, end]); }, }, + { + text: this.$t('dataCenter.analysis.384934-25'), + onClick(picker) { + const end = new Date(); + const start = new Date(); + start.setTime(start.getTime() - 3600 * 1000 * 24 * 2); + picker.$emit('pick', [start, end]); + }, + }, { text: this.$t('dataCenter.history.384934-21'), onClick(picker) { diff --git a/vue/src/views/iot/device/device-inline-video.vue b/vue/src/views/iot/device/device-inline-video.vue index 1e9bd507..1d2ff5d9 100644 --- a/vue/src/views/iot/device/device-inline-video.vue +++ b/vue/src/views/iot/device/device-inline-video.vue @@ -115,7 +115,7 @@ export default { const _this = this; this.sipList.forEach((item, index) => { if (_this.tempSipList[index].streamId) { - closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId).then((res) => {}); + closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId, true).then((res) => {}); } _this.$refs[`player_${index}`][0] && _this.$refs[`player_${index}`][0].close(); }); diff --git a/vue/src/views/iot/sip/splitview.vue b/vue/src/views/iot/sip/splitview.vue index acd989d3..ed9a206e 100644 --- a/vue/src/views/iot/sip/splitview.vue +++ b/vue/src/views/iot/sip/splitview.vue @@ -36,7 +36,7 @@ diff --git a/vue/src/views/scene/list/detail/video.vue b/vue/src/views/scene/list/detail/video.vue index e6b0b0d4..62913f70 100644 --- a/vue/src/views/scene/list/detail/video.vue +++ b/vue/src/views/scene/list/detail/video.vue @@ -112,7 +112,7 @@ export default { const _this = this; this.sipList.forEach((item, index) => { if (_this.tempSipList[index].streamId) { - closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId).then((res) => {}); + closeStream(item.deviceSipId, item.channelId, _this.tempSipList[index].streamId, true).then((res) => {}); } _this.$refs[`player_${index}`][0] && _this.$refs[`player_${index}`][0].close(); });