src/main/java/com/genersoft/iot/vmp/common/StreamInfo.java
@@ -1,5 +1,6 @@ package com.genersoft.iot.vmp.common; import com.genersoft.iot.vmp.service.bean.DownloadFileInfo; import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; @@ -76,6 +77,8 @@ private String endTime; @Schema(description = "进度(录像下载使用)") private double progress; @Schema(description = "文件下载地址(录像下载使用)") private DownloadFileInfo downLoadFilePath; @Schema(description = "是否暂停(录像回放使用)") private boolean pause; @@ -605,5 +608,11 @@ this.subStream = subStream; } public DownloadFileInfo getDownLoadFilePath() { return downLoadFilePath; } public void setDownLoadFilePath(DownloadFileInfo downLoadFilePath) { this.downLoadFilePath = downLoadFilePath; } } src/main/java/com/genersoft/iot/vmp/media/zlm/ZLMHttpHookListener.java
@@ -275,7 +275,7 @@ List<SsrcTransaction> ssrcTransactionForAll = sessionManager.getSsrcTransactionForAll(null, null, null, param.getStream()); if (ssrcTransactionForAll != null && ssrcTransactionForAll.size() == 1) { // 为录制国标模拟一个鉴权信息 // 为录制国标模拟一个鉴权信息, 方便后续写入录像文件时使用 StreamAuthorityInfo streamAuthorityInfo = StreamAuthorityInfo.getInstanceByHook(param); streamAuthorityInfo.setApp(param.getApp()); streamAuthorityInfo.setStream(ssrcTransactionForAll.get(0).getStream()); @@ -291,8 +291,18 @@ } // 如果是录像下载就设置视频间隔十秒 if (ssrcTransactionForAll.get(0).getType() == InviteSessionType.DOWNLOAD) { result.setMp4_max_second(30); // 获取录像的总时长,然后设置为这个视频的时长 InviteInfo inviteInfo = inviteStreamService.getInviteInfo(InviteSessionType.DOWNLOAD, deviceId, channelId, param.getStream()); if (inviteInfo.getStreamInfo() != null ) { String startTime = inviteInfo.getStreamInfo().getStartTime(); String endTime = inviteInfo.getStreamInfo().getEndTime(); long difference = DateUtil.getDifference(startTime, endTime)/1000; result.setMp4_max_second((int)difference); result.setEnable_mp4(true); // 设置为2保证得到的mp4的时长是正常的 result.setModify_stamp(2); } } } if (param.getApp().equalsIgnoreCase("rtp")) { src/main/java/com/genersoft/iot/vmp/media/zlm/dto/HookSubscribeFactory.java
@@ -41,4 +41,15 @@ return hookSubscribe; } public static HookSubscribeForRecordMp4 on_record_mp4(String mediaServerId, String app, String stream) { HookSubscribeForRecordMp4 hookSubscribe = new HookSubscribeForRecordMp4(); JSONObject subscribeKey = new com.alibaba.fastjson2.JSONObject(); subscribeKey.put("app", app); subscribeKey.put("stream", stream); subscribeKey.put("mediaServerId", mediaServerId); hookSubscribe.setContent(subscribeKey); return hookSubscribe; } } src/main/java/com/genersoft/iot/vmp/media/zlm/dto/HookSubscribeForRecordMp4.java
New file @@ -0,0 +1,44 @@ package com.genersoft.iot.vmp.media.zlm.dto; import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.annotation.JSONField; import java.time.Instant; /** * hook订阅-录像完成 * @author lin */ public class HookSubscribeForRecordMp4 implements IHookSubscribe{ private HookType hookType = HookType.on_record_mp4; private JSONObject content; @JSONField(format="yyyy-MM-dd HH:mm:ss") private Instant expires; @Override public HookType getHookType() { return hookType; } @Override public JSONObject getContent() { return content; } public void setContent(JSONObject content) { this.content = content; } @Override public Instant getExpires() { return expires; } @Override public void setExpires(Instant expires) { this.expires = expires; } } src/main/java/com/genersoft/iot/vmp/media/zlm/dto/hook/HookResultForOnPublish.java
@@ -7,6 +7,7 @@ private int mp4_max_second; private String mp4_save_path; private String stream_replace; private Integer modify_stamp; public HookResultForOnPublish() { } @@ -60,14 +61,23 @@ this.stream_replace = stream_replace; } public Integer getModify_stamp() { return modify_stamp; } public void setModify_stamp(Integer modify_stamp) { this.modify_stamp = modify_stamp; } @Override public String toString() { return "HookResultForOnPublish{" + "enable_audio=" + enable_audio + ", enable_mp4=" + enable_mp4 + ", mp4_max_second=" + mp4_max_second + ", stream_replace=" + stream_replace + ", mp4_save_path='" + mp4_save_path + '\'' + ", stream_replace='" + stream_replace + '\'' + ", modify_stamp='" + modify_stamp + '\'' + '}'; } } src/main/java/com/genersoft/iot/vmp/service/IPlayService.java
@@ -4,6 +4,7 @@ import com.genersoft.iot.vmp.conf.exception.ServiceException; import com.genersoft.iot.vmp.gb28181.bean.Device; import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem; import com.genersoft.iot.vmp.service.bean.DownloadFileInfo; import com.genersoft.iot.vmp.service.bean.ErrorCallback; import com.genersoft.iot.vmp.service.bean.SSRCInfo; @@ -44,5 +45,5 @@ void getSnap(String deviceId, String channelId, String fileName, ErrorCallback errorCallback); void getFilePath(String deviceId, String channelId, String stream, ErrorCallback<DownloadFileInfo> callback); } src/main/java/com/genersoft/iot/vmp/service/bean/DownloadFileInfo.java
New file @@ -0,0 +1,41 @@ package com.genersoft.iot.vmp.service.bean; public class DownloadFileInfo { private String httpPath; private String httpsPath; private String httpDomainPath; private String httpsDomainPath; public String getHttpPath() { return httpPath; } public void setHttpPath(String httpPath) { this.httpPath = httpPath; } public String getHttpsPath() { return httpsPath; } public void setHttpsPath(String httpsPath) { this.httpsPath = httpsPath; } public String getHttpDomainPath() { return httpDomainPath; } public void setHttpDomainPath(String httpDomainPath) { this.httpDomainPath = httpDomainPath; } public String getHttpsDomainPath() { return httpsDomainPath; } public void setHttpsDomainPath(String httpsDomainPath) { this.httpsDomainPath = httpsDomainPath; } } src/main/java/com/genersoft/iot/vmp/service/impl/MediaServerServiceImpl.java
@@ -592,6 +592,7 @@ if (mediaServerItem.getRecordPath() != null) { File recordPathFile = new File(mediaServerItem.getRecordPath()); param.put("protocol.mp4_save_path", recordPathFile.getParentFile().getPath()); param.put("protocol.downloadRoot", recordPathFile.getParentFile().getPath()); param.put("record.appName", recordPathFile.getName()); } src/main/java/com/genersoft/iot/vmp/service/impl/MediaServiceImpl.java
@@ -67,7 +67,7 @@ if (data == null) { return null; } JSONObject mediaJSON = JSON.parseObject(JSON.toJSONString(data.get(0)), JSONObject.class); JSONObject mediaJSON = data.getJSONObject(0); JSONArray tracks = mediaJSON.getJSONArray("tracks"); if (authority) { streamInfo = getStreamInfoByAppAndStream(mediaInfo, app, stream, tracks, addr, calld); src/main/java/com/genersoft/iot/vmp/service/impl/PlayServiceImpl.java
@@ -1,5 +1,7 @@ package com.genersoft.iot.vmp.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONArray; import com.alibaba.fastjson2.JSONObject; import com.genersoft.iot.vmp.common.InviteInfo; import com.genersoft.iot.vmp.common.InviteSessionStatus; @@ -21,16 +23,12 @@ import com.genersoft.iot.vmp.media.zlm.ZLMRESTfulUtils; import com.genersoft.iot.vmp.media.zlm.ZLMServerFactory; import com.genersoft.iot.vmp.media.zlm.ZlmHttpHookSubscribe; import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeFactory; import com.genersoft.iot.vmp.media.zlm.dto.HookSubscribeForStreamChange; import com.genersoft.iot.vmp.media.zlm.dto.MediaServerItem; import com.genersoft.iot.vmp.media.zlm.dto.*; import com.genersoft.iot.vmp.media.zlm.dto.hook.HookParam; import com.genersoft.iot.vmp.media.zlm.dto.hook.OnRecordMp4HookParam; import com.genersoft.iot.vmp.media.zlm.dto.hook.OnStreamChangedHookParam; import com.genersoft.iot.vmp.service.*; import com.genersoft.iot.vmp.service.bean.CloudRecordItem; import com.genersoft.iot.vmp.service.bean.ErrorCallback; import com.genersoft.iot.vmp.service.bean.InviteErrorCode; import com.genersoft.iot.vmp.service.bean.SSRCInfo; import com.genersoft.iot.vmp.service.bean.*; import com.genersoft.iot.vmp.storager.IRedisCatchStorage; import com.genersoft.iot.vmp.storager.IVideoManagerStorage; import com.genersoft.iot.vmp.storager.dao.CloudRecordServiceMapper; @@ -77,16 +75,13 @@ private IInviteStreamService inviteStreamService; @Autowired private DeferredResultHolder resultHolder; private ZlmHttpHookSubscribe subscribe; @Autowired private ZLMRESTfulUtils zlmresTfulUtils; @Autowired private ZLMServerFactory zlmServerFactory; @Autowired private AssistRESTfulUtils assistRESTfulUtils; @Autowired private IMediaService mediaService; @@ -105,9 +100,6 @@ @Autowired private DynamicTask dynamicTask; @Autowired private ZlmHttpHookSubscribe subscribe; @Autowired private CloudRecordServiceMapper cloudRecordServiceMapper; @@ -741,8 +733,11 @@ @Override public StreamInfo getDownLoadInfo(String deviceId, String channelId, String stream) { InviteInfo inviteInfo = inviteStreamService.getInviteInfo(InviteSessionType.DOWNLOAD, deviceId, channelId, stream); if (inviteInfo == null || inviteInfo.getStreamInfo() == null) { logger.warn("[获取下载进度] 未查询到录像下载的信息"); return null; } if (inviteInfo != null && inviteInfo.getStreamInfo() != null) { if (inviteInfo.getStreamInfo().getProgress() == 1) { return inviteInfo.getStreamInfo(); } @@ -751,30 +746,38 @@ String mediaServerId = inviteInfo.getStreamInfo().getMediaServerId(); MediaServerItem mediaServerItem = mediaServerService.getOne(mediaServerId); if (mediaServerItem == null) { logger.warn("查询录像信息时发现节点已离线"); logger.warn("[获取下载进度] 查询录像信息时发现节点不存在"); return null; } if (mediaServerItem.getRecordAssistPort() == 0) { throw new ControllerException(ErrorCode.ERROR100.getCode(), "未配置Assist服务,无法完成录像下载"); } SsrcTransaction ssrcTransaction = streamSession.getSsrcTransaction(deviceId, channelId, null, stream); if (ssrcTransaction == null) { logger.warn("[获取下载进度],未找到下载事务信息"); logger.warn("[获取下载进度] 下载已结束"); return null; } // 为了支持多个数据库,这里不能使用求和函数来直接获取总数了 List<CloudRecordItem> cloudRecordItemList = cloudRecordServiceMapper.getList(null, "rtp", inviteInfo.getStream(), null, null, ssrcTransaction.getCallId(), null); if (cloudRecordItemList.isEmpty()) { logger.warn("[获取下载进度],未找到下载视频信息"); JSONObject mediaListJson= zlmresTfulUtils.getMediaList(mediaServerItem, "rtp", stream); if (mediaListJson == null) { logger.warn("[获取下载进度] 从zlm查询进度失败"); return null; } long duration = 0; for (CloudRecordItem cloudRecordItem : cloudRecordItemList) { duration += cloudRecordItem.getTimeLen(); if (mediaListJson.getInteger("code") != 0) { logger.warn("[获取下载进度] 从zlm查询进度出现错误: {}", mediaListJson.getString("msg")); return null; } JSONArray data = mediaListJson.getJSONArray("data"); if (data == null) { logger.warn("[获取下载进度] 从zlm查询进度时未返回数据"); return null; } JSONObject mediaJSON = data.getJSONObject(0); JSONArray tracks = mediaJSON.getJSONArray("tracks"); if (tracks.isEmpty()) { logger.warn("[获取下载进度] 从zlm查询进度时未返回数据"); return null; } JSONObject jsonObject = tracks.getJSONObject(0); long duration = jsonObject.getLongValue("duration"); if (duration == 0) { inviteInfo.getStreamInfo().setProgress(0); } else { @@ -791,10 +794,86 @@ inviteInfo.getStreamInfo().setProgress(process); } inviteStreamService.updateInviteInfo(inviteInfo); return inviteInfo.getStreamInfo(); } return null; @Override public void getFilePath(String deviceId, String channelId, String stream, ErrorCallback<DownloadFileInfo> callback) { InviteInfo inviteInfo = inviteStreamService.getInviteInfo(InviteSessionType.DOWNLOAD, deviceId, channelId, stream); if (inviteInfo == null || inviteInfo.getStreamInfo() == null) { logger.warn("[获取录像下载文件地址] 未查询到录像下载的信息, {}/{}-{}", deviceId, channelId, stream); callback.run(ErrorCode.ERROR100.getCode(), "未查询到录像下载的信息", null); return ; } if (!ObjectUtils.isEmpty(inviteInfo.getStreamInfo().getDownLoadFilePath())) { callback.run(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), inviteInfo.getStreamInfo().getDownLoadFilePath()); return; } StreamAuthorityInfo streamAuthorityInfo = redisCatchStorage.getStreamAuthorityInfo("rtp", stream); if (streamAuthorityInfo == null) { logger.warn("[获取录像下载文件地址] 未查询到录像的视频信息, {}/{}-{}", deviceId, channelId, stream); callback.run(ErrorCode.ERROR100.getCode(), "未查询到录像的视频信息", null); return ; } // 获取当前已下载时长 String mediaServerId = inviteInfo.getStreamInfo().getMediaServerId(); MediaServerItem mediaServerItem = mediaServerService.getOne(mediaServerId); if (mediaServerItem == null) { logger.warn("[获取录像下载文件地址] 查询录像信息时发现节点不存在, {}/{}-{}", deviceId, channelId, stream); callback.run(ErrorCode.ERROR100.getCode(), "查询录像信息时发现节点不存在", null); return ; } List<CloudRecordItem> cloudRecordItemList = cloudRecordServiceMapper.getListByCallId(streamAuthorityInfo.getCallId()); if (!cloudRecordItemList.isEmpty()) { String filePath = cloudRecordItemList.get(0).getFilePath(); DownloadFileInfo downloadFileInfo = getDownloadFilePath(mediaServerItem, filePath); inviteInfo.getStreamInfo().setDownLoadFilePath(downloadFileInfo); inviteStreamService.updateInviteInfo(inviteInfo); callback.run(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), downloadFileInfo); }else { // 可能尚未生成,那就监听hook等着收到对应的录像通知 ZlmHttpHookSubscribe.Event hookEvent = (mediaServerItemInuse, hookParam) -> { logger.info("[录像下载]收到订阅消息: , {}/{}-{}", deviceId, channelId, stream); logger.info("[录像下载]收到订阅消息内容: " + hookParam); dynamicTask.stop(streamAuthorityInfo.getCallId()); OnRecordMp4HookParam recordMp4HookParam = (OnRecordMp4HookParam)hookParam; String filePath = recordMp4HookParam.getFile_path(); DownloadFileInfo downloadFileInfo = getDownloadFilePath(mediaServerItem, filePath); inviteInfo.getStreamInfo().setDownLoadFilePath(downloadFileInfo); inviteStreamService.updateInviteInfo(inviteInfo); callback.run(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMsg(), downloadFileInfo); }; HookSubscribeForRecordMp4 hookSubscribe = HookSubscribeFactory.on_record_mp4(mediaServerId, "rtp", stream); subscribe.addSubscribe(hookSubscribe, hookEvent); // 设置超时,超时结束监听 dynamicTask.startDelay(streamAuthorityInfo.getCallId(), ()->{ logger.info("[录像下载] 接收hook超时, {}/{}-{}", deviceId, channelId, stream); subscribe.removeSubscribe(hookSubscribe); callback.run(ErrorCode.ERROR100.getCode(), "接收hook超时", null); }, 10000); } } private DownloadFileInfo getDownloadFilePath(MediaServerItem mediaServerItem, String filePath) { DownloadFileInfo downloadFileInfo = new DownloadFileInfo(); String pathTemplate = "%s://%s:%s/index/api/downloadFile?file_path=" + filePath; downloadFileInfo.setHttpPath(String.format(pathTemplate, "http", mediaServerItem.getStreamIp(), mediaServerItem.getHttpPort())); if (mediaServerItem.getHttpSSlPort() > 0) { downloadFileInfo.setHttpsPath(String.format(pathTemplate, "https", mediaServerItem.getStreamIp(), mediaServerItem.getHttpSSlPort())); } return downloadFileInfo; } private StreamInfo onPublishHandlerForDownload(MediaServerItem mediaServerItemInuse, HookParam hookParam, String deviceId, String channelId, String startTime, String endTime) { src/main/java/com/genersoft/iot/vmp/storager/dao/CloudRecordServiceMapper.java
@@ -106,4 +106,10 @@ " </script>") int deleteList(List<CloudRecordItem> cloudRecordItemIdList); @Select(" <script>" + "select *" + " from wvp_cloud_record " + "where call_id = #{callId}" + " </script>") List<CloudRecordItem> getListByCallId(@Param("callId") String callId); } src/main/java/com/genersoft/iot/vmp/utils/DateUtil.java
@@ -139,4 +139,13 @@ Instant beforeInstant = Instant.from(formatter.parse(keepaliveTime)); return ChronoUnit.MILLIS.between(beforeInstant, Instant.now()); } public static long getDifference(String startTime, String endTime) { if (ObjectUtils.isEmpty(startTime) || ObjectUtils.isEmpty(endTime)) { return 0; } Instant startInstant = Instant.from(formatter.parse(startTime)); Instant endInstant = Instant.from(formatter.parse(endTime)); return ChronoUnit.MILLIS.between(endInstant, startInstant); } } src/main/java/com/genersoft/iot/vmp/vmanager/gb28181/record/GBRecordController.java
@@ -1,5 +1,7 @@ package com.genersoft.iot.vmp.vmanager.gb28181.record; import com.genersoft.iot.vmp.common.InviteInfo; import com.genersoft.iot.vmp.common.InviteSessionType; import com.genersoft.iot.vmp.common.StreamInfo; import com.genersoft.iot.vmp.conf.UserSetting; import com.genersoft.iot.vmp.conf.exception.ControllerException; @@ -10,7 +12,9 @@ import com.genersoft.iot.vmp.gb28181.transmit.callback.RequestMessage; import com.genersoft.iot.vmp.gb28181.transmit.cmd.impl.SIPCommander; import com.genersoft.iot.vmp.service.IDeviceService; import com.genersoft.iot.vmp.service.IInviteStreamService; import com.genersoft.iot.vmp.service.IPlayService; import com.genersoft.iot.vmp.service.bean.DownloadFileInfo; import com.genersoft.iot.vmp.service.bean.InviteErrorCode; import com.genersoft.iot.vmp.storager.IVideoManagerStorage; import com.genersoft.iot.vmp.utils.DateUtil; @@ -23,6 +27,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.ObjectUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -54,6 +59,9 @@ @Autowired private IPlayService playService; @Autowired private IInviteStreamService inviteStreamService; @Autowired private IDeviceService deviceService; @@ -204,4 +212,32 @@ } return new StreamContent(downLoadInfo); } @Operation(summary = "获取历史媒体下载文件地址") @Parameter(name = "deviceId", description = "设备国标编号", required = true) @Parameter(name = "channelId", description = "通道国标编号", required = true) @Parameter(name = "stream", description = "流ID", required = true) @GetMapping("/download/file/path/{deviceId}/{channelId}/{stream}") public DeferredResult<WVPResult<DownloadFileInfo>> getDownloadFilePath(@PathVariable String deviceId, @PathVariable String channelId, @PathVariable String stream) { DeferredResult<WVPResult<DownloadFileInfo>> result = new DeferredResult<>(); result.onTimeout(()->{ WVPResult<DownloadFileInfo> wvpResult = new WVPResult<>(); wvpResult.setCode(ErrorCode.ERROR100.getCode()); wvpResult.setMsg("timeout"); result.setResult(wvpResult); }); playService.getFilePath(deviceId, channelId, stream, (code, msg, data)->{ WVPResult<DownloadFileInfo> wvpResult = new WVPResult<>(); wvpResult.setCode(code); wvpResult.setMsg(msg); wvpResult.setData(data); result.setResult(wvpResult); }); return result; } } web_src/src/components/dialog/recordDownload.vue
@@ -6,8 +6,7 @@ <el-progress :percentage="percentage"></el-progress> </el-col> <el-col :span="6" > <el-button icon="el-icon-download" v-if="percentage < 100" size="mini" title="点击下载可将以缓存部分下载到本地" @click="download()">停止缓存并下载</el-button> <el-button icon="el-icon-download" v-if="downloadFile" size="mini" title="点击下载" @click="downloadFileClientEvent()">点击下载</el-button> <el-button icon="el-icon-download" v-if="downloadFile" size="mini" title="点击下载" @click="downloadFileClientEvent()">下载</el-button> </el-col> </el-row> </el-dialog>