记录监控摄像头的接入过程及web端播放

1.rtsp视频流网页播放概述

需求:当我们通过ONVIF协议,获取到了摄像头的rtsp流地址(长这样:rtsp://admin:123456789@192.168.9.16:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif)后,通过vlc播放器,我们可以查看监控视频内容,可是,我们应该如何在网页上查看视频内容呢?因为现在的浏览器都不支持rtsp流,因此我所选用的解决方案便是推流 + 转码

(1)转码推流工具ffmpeg(安装教程详见:https://www.cnblogs.com/h2285409/p/16982120.html),安装好之后,便可使用命令 ffmpeg -re -rtsp_transport tcp -i rtsp://admin:123456789@192.168.9.16:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif -c:v copy -c:a copy -f flv rtmp://127.0.0.1/live/16 将我们的rtsp视频流转码并推至流媒体服务器上,在这个命令中含有两个URL,前面的是我们的rtsp流地址,而后面的URL便是我们流媒体服务器的地址,以及一个-f参数,指定了我们视频流转码后的格式为flv

(2)流媒体服务器,主要调研了2款,一是整合了Rtmp模块的Nginx,二是SRS视频服务器,而我所选用的是SRS(官方文档:http://ossrs.net/lts/zh-cn/),在使用ffmpeg推流上SRS后,便可直接从SRS获得webRTC流地址(如本例:webrtc://192.168.4.23/live/16 ),为什么选用webRTC呢,因为它的延迟很低(也可以根据自己的需求选择其他的如http-flv或hls),然后,前端再通过webRTC播放器便可直接在页面上播放该直播流

SRS与ffmpeg参考:https://blog.csdn.net/diyangxia/article/details/120172920

ffmpeg进阶参考:https://segmentfault.com/a/1190000039782685

webRTC参考:https://www.cnblogs.com/ziyue7575/p/13927894.html

2.rtsp推流转码以及关闭推流相关代码实现

@Service
@Slf4j
public class MonitorServiceImpl {

    //ffmpeg安装路径
    @Value("${ffmpegPath}")
    private String ffmpegPathPrefix;

    //srs视频服务器地址
    @Value("${srsAddress}")
    private String srsAddress;

    //srs-http-api端口,默认为1985
    @Value("${srsHttpApiPort}")
    private String srsHttpApiPort;

    @Resource
    private MonitorMapper monitorMapper;

    @Resource
    private RestTemplate restTemplate;

    @Resource
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;

    private ConcurrentMap<String, TranscodeModel> transcodeMap = new ConcurrentHashMap<>();

    private CopyOnWriteArrayList<ProcessOutputModel> processOutputList = new CopyOnWriteArrayList<>();

    /**
     * 进行推流转码
     */
    public String transcode(String id) {
        boolean success = false;
        try {
            if(transcodeMap.putIfAbsent(id, new TranscodeModel()) == null) {
                Ipc ipc = monitorMapper.getIpcInfoById(id);
                if(ipc == null) {
                    throw new RuntimeException("id为" + id + "的ipc不存在");
                }
                String rtspUrls = ipc.getRtspUrls();
                if(!StringUtils.hasText(rtspUrls)) {
                    try {
                        //如果流地址不存在,则输入ipc的ip地址,端口号,帐号,密码以及传输协议进行获取
                        rtspUrls = OnvifUtil.getRTSPUrl(ipc.getIp(), ipc.getPort(), ipc.getAccount(), ipc.getPassword(), TransportProtocol.TCP);
                    } catch (Exception e) {
                        throw new RuntimeException("获取id为 " + id +  " 的ipc的rtsp流地址失败,请检查该ipc是否开启或其配置信息是否正确");
                    }
                }
                if (!StringUtils.hasText(rtspUrls)) {
                    throw new RuntimeException("获取id为 " + id + "的ipc的rtsp流地址失败,请检查该摄像机是否开启或其配置信息是否正确");
                }
                //一般情况下获取到的ipc的rtsp流地址存在多个(主码流,辅码流或第三方码流等),我这里用了#号将它们进行分隔
                String[] allRtspUrl = rtspUrls.split("#");
                for (String rtspUrl : allRtspUrl) {
                    //使用ffprobe来获取流的一些信息,比如该流当前是否可用(在线),它的编码格式,分辨率以及fps等基本信息   将ffmpeg与ffprobe置于同一目录下
                    String requestStreamInfoCommand = String.format("%sffprobe %s", this.ffmpegPathPrefix,  rtspUrl);
                    //用于判断该ipc是否在线
                    boolean online = true;
                    //用于判断该获取流信息的进程是否应该结束
                    long lastReadTime = System.currentTimeMillis();
                    log.info("准备请求流 {} 的信息,其命令为 {}", rtspUrl, requestStreamInfoCommand);
                    Process requestStreamInfoProcess = Runtime.getRuntime().exec(requestStreamInfoCommand);
                    BufferedReader reader = new BufferedReader(new InputStreamReader(requestStreamInfoProcess.getErrorStream()));
                    while (requestStreamInfoProcess.isAlive()) {
                        while (reader.ready()) {
                            String msg = reader.readLine().trim();
                            lastReadTime = System.currentTimeMillis();
                            log.info("流 {} 的信息为: {}", rtspUrl, msg);
                            if (msg.contains("Server returned 404 Not Found")) {
                                log.error("流 {} 不在线", rtspUrl);
                                online = false;
                                break;
                            }
                        }

                        if (!online) {
                            requestStreamInfoProcess.destroy();
                        } else if (System.currentTimeMillis() - lastReadTime > 6000) {
                            //预防异常情况,超过6s未读取到数据,结束
                            online = false;
                            requestStreamInfoProcess.destroy();
                            break;
                        }
                    }
                    if (online) {
                        //如果视频编码格式不为h264,则将其转码为h264格式,网页端才能播放,因此这里选用了libx264编码库
                        //-preset的参数主要调节编码速度和质量的平衡          -tune zerolatency表示设置零延时
                        String transcodeCommand = String.format("%sffmpeg -re -rtsp_transport tcp -i %s -c:a copy -c:v libx264 -preset ultrafast -tune zerolatency -f flv %s",this.ffmpegPathPrefix,  rtspUrl, "rtmp://" + srsAddress + "/live/" + id);
                        //通过命令行执行推流转码
                        log.info("启动对ipc {} 的推流转码, 其命令为: {}", id, transcodeCommand);
                        Process process = Runtime.getRuntime().exec(transcodeCommand);
                        transcodeMap.put(id, new TranscodeModel(id, process, transcodeCommand));
                        //观察推流进程的输出日志
                        this.addProcessOutput(new ProcessOutputModel(id, process, new BufferedReader(new InputStreamReader(process.getErrorStream()))));
                        success = true;
                        break;
                    }
                }
            }
        } catch (Exception e) {
            this.removeProcessOutput(id);
            this.closeTranscode(id);
            throw new RuntimeException("启动对ipc " + id + " 推流转码失败,原因: " + e.getMessage());
        }
        if (!success) {
            throw new RuntimeException("启动转码推流失败,原因: 设备不在线");
        }
        //返回转码后的webrtc流地址,前端可用该地址 + webRTC播放器直接播放了
        return "webrtc://" + srsAddress + "/live/" + id;
    }

    private synchronized void addProcessOutput(ProcessOutputModel processOutputModel) {
        processOutputList.add(processOutputModel);
        //当有一个以上的推流进程的时候,启动一个线程,来轮询观察所有推流进程的输出日志,并对无效的流进行关闭
        if(processOutputList.size() == 1) {
            watchProcessOutput();
        }
    }

    private void watchProcessOutput() {
        threadPoolTaskExecutor.execute(() -> {
            Map<String, ProcessLastOutputModel> outputDetailMap = new HashMap<>();
            while(!processOutputList.isEmpty()) {
                for (ProcessOutputModel processOutput : processOutputList) {
                    BufferedReader output = processOutput.getOutput();
                    try {
                        ProcessLastOutputModel lastReportDetail = outputDetailMap.get(processOutput.getId());
                        if (lastReportDetail != null) {
                            long lastReadTime = lastReportDetail.getLastReadTime();
                            //超过30s未读到推流进程的输出日志或frame不再变化,则认为该流已无效,需关闭该推流进程
                            if(System.currentTimeMillis() / 1000 - lastReadTime > 30) {
                                log.error("推流进程 {} 超过30s未接收到输出日志", processOutput.getId());
                                closeTranscode(processOutput.getId());
                                processOutputList.remove(processOutput);
                            }
                        } else {
                            lastReportDetail = new ProcessLastOutputModel(System.currentTimeMillis() / 1000, null);
                            outputDetailMap.put(processOutput.getId(), lastReportDetail);
                        }

                        if(output != null && output.ready()) {
                            String msg = output.readLine();
                            log.info("读取到推流进程 {} 的输出日志为: {}", processOutput.getId(), msg);
                            //如何判断推流是否正常: 通过frame,如果正常推流,frame肯定是递增的,如果前后的frame相等,则说明当前流已无效,即可关闭推流
                            if (msg.startsWith("frame=")) {
                                String frame = msg.substring("frame=".length(), msg.indexOf("fps=")).trim();
                                Integer nowFrame = Integer.parseInt(frame);
                                Integer lastFrame = lastReportDetail.getLastFrame();
                                //一旦nowFrame和lastFrame相等,则lastReadTime不再更新,这样过30s后结束对该进程的推流
                                if(lastFrame == null || nowFrame > lastFrame) {
                                    lastReportDetail.setLastFrame(nowFrame);
                                    lastReportDetail.setLastReadTime(System.currentTimeMillis() / 1000);
                                }
                            }
                        }
                    } catch (IOException e) {
                        log.error(e.getMessage());
                        closeTranscode(processOutput.getId());
                        processOutputList.remove(processOutput);
                    }
                }

                if (processOutputList.size() != outputDetailMap.size()) {
                    Iterator<String> iterator = outputDetailMap.keySet().iterator();
                    while (iterator.hasNext()) {
                        String key = iterator.next();
                        boolean found = false;
                        for (ProcessOutputModel processOutput : processOutputList) {
                            if (key.equals(processOutput.getId())) {
                                found = true;
                                break;
                            }
                        }
                        if (!found) {
                            iterator.remove();
                        }
                    }
                }
                try {
                    Thread.sleep(400);
                } catch (Exception e) {
                    log.error("监听推流线程 {} 停止执行", Thread.currentThread().getName());
                    for (ProcessOutputModel processOutput : processOutputList) {
                        closeTranscode(processOutput.getId());
                        processOutputList.remove(processOutput);
                    }
                    break;
                }
            }
        });
    }

    private void removeProcessOutput(String id) {
        processOutputList.forEach(outputModel -> {
            if (outputModel.getId().equals(id)) {
                processOutputList.remove(outputModel);
            }
        });
    }

    /**
     * 关闭推流进程
     */
    private void closeTranscode(String id) {
        TranscodeModel transcodeModel = null;
        if((transcodeModel = transcodeMap.get(id)) != null) {
            //停止推流转码进程
            if(transcodeModel.getProcess() != null) {
                transcodeModel.getProcess().destroy();
            }
            transcodeMap.remove(id);
            log.info("关闭对ipc {} 的推流转码", id);
        }
    }

    /**
     * 补充功能:客户端结束播放流后,srs可配置触发一个on_stop回调,通过该回调,我们就可以知道哪些流可能没人看了,然后结束对该流进行的推流转码工作
     */
    public void stopPlay(CallbackOnStopPlay data) {
        int currentPage = 0,pageSize = 100;
        try {
            while (true) {
                JSONArray streams = this.requestSrsStream(currentPage, pageSize);
                boolean found = false;
                for (int i = 0;i < streams.size();i++) {
                    JSONObject stream = streams.getJSONObject(i);
                    //哪个ipc所推的流
                    String streamName = stream.getString("name");
                    if(data.getStream().equals(streamName)) {
                        //clients = 推流的人数 + 拉流的人数, 当clients人数等于1时,说明没有客户端观看该流,停止对该流的推流转码
                        Integer clients = stream.getInteger("clients");
                        if(clients <= 1) {
                            this.closeTranscode(streamName);
                            this.removeProcessOutput(streamName);
                        }
                        found = true;
                    }
                }
                if (found || streams.size() < pageSize) {
                    break;
                }
                currentPage += pageSize;
            }
        } catch (Exception e) {
            log.error("关闭视频流 {} 失败, 原因: {}", data.getStream(), e.getMessage());
        }
    }

    //srs的stream接口需要传递分页参数,不能直接请求到它下面的所有流,渐进式遍历srs当前所有的流
    private JSONArray requestSrsStream(int currentPage, int pageSize) {
        String url = "http://" + srsAddress + ":" + this.srsHttpApiPort + "/api/v1/streams?" + "start=" + currentPage + "count=" + pageSize;
        ResponseEntity<JSONObject> exchange = null;
        try {
            exchange = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null, new HttpHeaders()), JSONObject.class);
        } catch (Exception e) {
            log.error("请求srs下所有的流失败,原因: {}", e.getMessage());
            return new JSONArray();
        }
        if (exchange.getBody() == null || exchange.getBody().getInteger("code") != 0) {
            log.error("请求srs下所有的流失败");
            return new JSONArray();
        }
        log.info("请求到此时srs的视频流为: {}, 起始值为: {}, 偏移量为: {}", exchange.getBody(), currentPage, pageSize);
        return exchange.getBody().getJSONArray("streams");
    }

    //转码推流类
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class TranscodeModel {

        private String id;

        private Process process;

        private String command;
    }

    //推流进程日志输出类
    @Data
    @AllArgsConstructor
    public class ProcessOutputModel {
        private String id;

        private Process process;

        private BufferedReader output;

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ProcessOutputModel that = (ProcessOutputModel) o;
            return Objects.equals(id, that.id);
        }

        @Override
        public int hashCode() {
            return Objects.hash(id);
        }
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class ProcessLastOutputModel {

        private Long lastReadTime;

        private Integer lastFrame;
    }

    //客户端关闭流时触发的回调所传递的参数
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class CallbackOnStopPlay {
        private String server_id;

        private String action;

        private String client_id;

        private String ip;

        private String vhost;

        private String app;

        private String stream;

        private String param;
    }

    //ipc类
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Ipc {
        //ipc的主键id
        private String id;
        //ipc的rtsp流地址
        private String rtspUrls;
        //ipc的ip地址
        private String ip;
        //ipc的帐号
        private String account;
        //ipc的密码
        private String password;
        //ipc的端口号
        private Integer port;
    }
}

对推流进程的关闭,可以选择定时任务轮询srs中流的信息,然后对那些没人看的流进行关闭,也可以选择配置srs客户端关闭流时的回调,来进行关闭,至于回调如何配置使用,可以详看官方文档中开放接口相关内容和这篇文章:https://blog.csdn.net/weixin_44341110/article/details/120829847

3.获取大华NVR通道下的IPC并下载指定时间段内的回放录像

(1) 到大华官网https://support.dahuatech.com/tools/sdkExploit,下载Linux64 + Java版本的SDK,使用IDEA打开后将其打成jar包,引入项目,打包的过程略,需要的话可直接留言或私信,我会发给你

(2) 在文档中找到网络SDK开发手册,如下图,它提供了大华sdk中所支持的所有方法的详细说明,使用sdk前必看

(3) 通过sdk,我们就可以获取大华NVR下的IPC信息或下载指定时间段内的回放录像,具体实现代码如下,其实就是调用sdk中所提供的方法,并对方法返回的结果进行解析处理而已,如果有看到不懂的方法,可前往网络SDK开发手册中进行搜索

@Service
@Slf4j
public class DaHuaSdk {
    //ffmpeg安装路径
    @Value("${config.positioning.ffmpeg.path}")
    private String ffmpegPathPrefix;

    //项目端口,用于访问下载后的回放录像
    @Value("${server.port:8130}")
    private Integer port;

    //本项目部署的服务器ip
    private final String serverIp = "192.168.4.23";

    //回放录像的下载路径
    private final String recordFilePath = "/usr/local/recordFile/";

    @Resource
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;

    private static final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");

    //获取nvr下的通道的ipc信息
    public List<IpcModel> getNvrIpc(NVR nvr) {
        //使用大华提供的netsdk所提供的方法

        //1.初始化方法CLIENT_Init 参数为:
        //          设备断线回调: 通过 CLIENT_Init 设置该回调函数,当设备出现断线时,SDK会调用该函数
        //          设备重连成功回调: 通过 CLIENT_SetAutoReconnect 设置该回调函数,当已断线的设备重连成功时,SDK会调用该函数
        if(!netsdk.CLIENT_Init(null, null)) {
            throw new RuntimeException("加载大华SDK失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }
        //2.若未登录,则先登录
        NetSDKLib.NET_DEVICEINFO_Ex deviceInfo = new NetSDKLib.NET_DEVICEINFO_Ex();
        NetSDKLib.LLong dhLoginHandle = loginDaHua(nvr.getIp(), nvr.getPort(), nvr.getAccount(), nvr.getPassword(), deviceInfo);
        if (dhLoginHandle.longValue() == 0) {
            throw new RuntimeException("登录大华nvr " + nvr.getIp() + " 失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }
        int maxChannelNumber = deviceInfo.byChanNum;
        log.info("大华NVR {} 的最大通道数量为 {}", nvr.getIp(), maxChannelNumber);
        if (maxChannelNumber <= 0) {
            throw new RuntimeException("大华NVR " + nvr.getIp() + " 的最大通道数量为0");
        }

        NetSDKLib.AV_CFG_RemoteDevice[] remoteDevices = new NetSDKLib.AV_CFG_RemoteDevice[maxChannelNumber];
        for (int i = 0; i < remoteDevices.length; ++i) {
            remoteDevices[i] = new NetSDKLib.AV_CFG_RemoteDevice();
        }
        IntByReference result = new IntByReference(0);

        int memorySize = remoteDevices[0].size() * remoteDevices.length;
        byte[] szBuffer =new byte[memorySize];
        Pointer pointer = new Memory(memorySize);
        pointer.clear(memorySize);

        ToolKits.SetStructArrToPointerData(remoteDevices, pointer);
        //3.获取ipc配置信息
        if(!netsdk.CLIENT_GetNewDevConfig(dhLoginHandle, NetSDKLib.CFG_CMD_REMOTEDEVICE, -1, szBuffer, memorySize, result,5000)) {
            throw new RuntimeException("获取大华NVR下通道信息失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }
        //4.解析获取到的数据
        if(!configsdk.CLIENT_ParseData(NetSDKLib.CFG_CMD_REMOTEDEVICE, szBuffer, pointer, memorySize, null)) {
            throw new RuntimeException("解析大华NVR下通道信息失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }
        //使用大华jar包中的方法
        ToolKits.GetPointerDataToStructArr(pointer, remoteDevices);

        //ipc的id前缀,去掉前缀后,就是通道号(从0开始)
        String idPrefix = "uuid:System_CONFIG_NETCAMERA_INFO_";
        List<IpcModel> ipcList = new ArrayList<>();
        Arrays.stream(remoteDevices).forEach(ipc -> {
            //如果该通道启用
            if(ipc.bEnable == 1) {
                IpcModel ipcModel = new IpcModel();
                String ip = new String(ipc.szIP).trim();
                String account = new String(ipc.szUser).trim();
                String password = new String(ipc.szPassword).trim();
                ipcModel.setIp(ip);
                ipcModel.setVendor((short) 2);
                ipcModel.setAccount(account);
                ipcModel.setPassword(password);
                int channel = Integer.parseInt(new String(ipc.szID).trim().substring(idPrefix.length()));
                ipcModel.setChannel(channel + 1);
                ipcModel.setPort(ipc.nPort);
                NetSDKLib.CFG_VIDEO_IN_INFO videoInfo = new NetSDKLib.CFG_VIDEO_IN_INFO();
                //使用大华jar包中的方法获取通道描述信息
                ToolKits.GetDevConfig(dhLoginHandle, channel, NetSDKLib.CFG_CMD_VIDEOIN, videoInfo);
                String channelName = new String(videoInfo.szChnName).trim();
                ipcModel.setChannelName(channelName);
                log.info("通道 {} 启用, 获得到的摄像机 ip 为 {},帐号为 {},密码为 {},端口为 {},通道描述为 {}", channel + 1, ip, account, password, ipc.nPort, channelName);
                ipcList.add(ipcModel);
            }
        });
        // 5.退出登录
        logout();
        // 6.释放资源
        netsdk.CLIENT_Cleanup();
        return ipcList;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class IpcModel {
        private Long id;
        /**
         * ip地址
         */
        private String ip;

        /**
         * 设备厂商(1 海康,2 大华)
         */
        private Short vendor;

        /**
         * 帐号
         */
        private String account;

        /**
         * 密码
         */
        private String password;

        /**
         * ONVIF端口
         */
        private Integer port;

        /**
         * 该ipc的通道号
         */
        private Integer channel;

        /**
         * 通道描述信息
         */
        private String channelName;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class NVR {

        private Long id;

        /**
         * 名称
         */
        private String nvrName;

        /**
         * 厂商
         */
        private String vendor;

        /**
         * ip地址
         */
        private String ip;

        /**
         * 帐号
         */
        private String account;

        /**
         * 密码
         */
        private String password;

        /**
         * 端口
         */
        private Integer port;
    }

    //登录大华设备
    private NetSDKLib.LLong loginDaHua(String ip, int port, String user, String password, NetSDKLib.NET_DEVICEINFO_Ex deviceInfo) {
        //入参
        NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY pstInParam = new NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY();
        pstInParam.nPort = port;
        pstInParam.szIP = ip.getBytes();
        pstInParam.szPassword = password.getBytes();
        pstInParam.szUserName = user.getBytes();
        //出参
        NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY pstOutParam = new NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY();
        pstOutParam.stuDeviceInfo = deviceInfo;

        //使用CLIENT_LoginWithHighLevelSecurity进行登录
        NetSDKLib.LLong dhLoginHandle = netsdk.CLIENT_LoginWithHighLevelSecurity(pstInParam, pstOutParam);
        if(dhLoginHandle.longValue() == 0) {
            log.error("Login Device {} Port {} Failed. {}", ip, port, ToolKits.getErrorCodePrint());
        } else {
            log.info("Login Success [ {} ]", ip);
        }
        return dhLoginHandle;
    }

    /**
     * 下载某个NVR下某个通道的ipc的回放录像
     * @param ipcList  nvr下的ipc信息,是通过上面的getNvrIpc方法获取到的
     * @param nvr
     * @param startTime 指定需要下载的回放录像的开始时间
     * @param endTime 指定需要下载的回放录像的结束时间
     * @return
     */
    public List<RecordFileModel> downloadRecordFileByTime(List<IpcModel> ipcList, NVR nvr, LocalDateTime startTime, LocalDateTime endTime) {
        if(!netsdk.CLIENT_Init(null, null)) {
            throw new RuntimeException("加载大华SDK失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }
        //2.若未登录,则先登录
        NetSDKLib.NET_DEVICEINFO_Ex deviceInfo = new NetSDKLib.NET_DEVICEINFO_Ex();
        NetSDKLib.LLong dhLoginHandle = loginDaHua(nvr.getIp(), nvr.getPort(), nvr.getAccount(), nvr.getPassword(), deviceInfo);
        if (dhLoginHandle.longValue() == 0) {
            throw new RuntimeException("登录大华nvr " + nvr.getIp() + " 失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }

        //3.设置码流类型 0-主辅码流,1-主码流,2-辅码流
        IntByReference steamType = new IntByReference(0);

        boolean bret = netsdk.CLIENT_SetDeviceMode(dhLoginHandle, NetSDKLib.EM_USEDEV_MODE.NET_RECORD_STREAM_TYPE, steamType.getPointer());
        if (!bret) {
            throw new RuntimeException("设置大华nvr " + nvr.getIp() + " 码流类型失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
        }

        //4.查找指定时间范围内的回放录像是否存在
        List<Future<String>> downloadRecordFileWorks = new ArrayList<>();
        for (int i = 0; i < ipcList.size(); i++) {
            IpcModel ipc = ipcList.get(i);
            Integer channel = ipc.getChannel();
            Future<String> downloadDaHuaRecordFileResult = threadPoolTaskExecutor.submit(() -> {
                String fileNamePrefix = "nvr_" + nvr.getIp().replace('.', '-') + "_" + channel + "_" + startTime.format(fmt);
                File file = new File(recordFilePath);
                long totalSpace = file.getTotalSpace();
                long freeSpace = file.getFreeSpace();
                long usedSpace = totalSpace - freeSpace;
                if(usedSpace > (long) (totalSpace * 0.95)) {
                    throw new RuntimeException("下载录像 " + fileNamePrefix + " 失败,原因: 磁盘使用量已超过95%,请先清理磁盘");
                }
                //指定录像的开始时间
                NetSDKLib.NET_TIME stTimeStart = new NetSDKLib.NET_TIME();
                stTimeStart.setTime(startTime.getYear(), startTime.getMonthValue(), startTime.getDayOfMonth(), startTime.getHour(), startTime.getMinute(), startTime.getSecond());
                //指定录像的结束时间
                NetSDKLib.NET_TIME stTimeEnd = new NetSDKLib.NET_TIME();
                stTimeEnd.setTime(endTime.getYear(), endTime.getMonthValue(), endTime.getDayOfMonth(), endTime.getHour(), endTime.getMinute(), endTime.getSecond());
                IntByReference recordFileCount = new IntByReference();
                //通道号从0开始
                if (!netsdk.CLIENT_QueryRecordTime(dhLoginHandle, channel - 1, 0, stTimeStart, stTimeEnd, null, recordFileCount, 5000)) {
                    throw new RuntimeException("查找录像 " + fileNamePrefix + " 失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
                }
                if (recordFileCount.getValue() <= 0) {
                    throw new RuntimeException("录像 " + fileNamePrefix + " 不存在,请检查nvr该通道下的录像配置是否开启");
                }

                //5.下载指定时间范围内的录像
                CountDownLatch countDownLatch = new CountDownLatch(1);
                //下载进度回调,用于确定录像文件下载是否完成
                DaHuaTimeDownloadPosCallback daHuaTimeDownloadPosCallback = new DaHuaTimeDownloadPosCallback(fileNamePrefix, countDownLatch);
                NetSDKLib.LLong dhDownLoadHandle = netsdk.CLIENT_DownloadByTimeEx(dhLoginHandle, channel - 1, 0, stTimeStart, stTimeEnd, recordFilePath + fileNamePrefix + ".dav",
                        daHuaTimeDownloadPosCallback, null, null, null, null);
                if (dhDownLoadHandle.longValue() == 0) {
                    throw new RuntimeException("下载录像 " + fileNamePrefix + " 失败,原因: (0x80000000|" + (netsdk.CLIENT_GetLastError() & 0x7fffffff) + ")");
                }
                //等待回放录像下载完毕
                try {
                    countDownLatch.await();
                } catch (Exception e) {
                    log.error(e.getMessage());
                }

                if (!daHuaTimeDownloadPosCallback.isSuccess()) {
                    throw new RuntimeException("录像 " + fileNamePrefix + " 写入失败");
                }
                //回放录像下载完毕后,得到的是.dav视频文件,需使用ffmpeg将其转码为.mp4视频文件,便于观看
                String command = String.format("%sffmpeg -i %s.dav -c:v copy -c:a copy %s.mp4", ffmpegPathPrefix, recordFilePath + "/" + fileNamePrefix, recordFilePath + "/" + fileNamePrefix);
                log.info("对录像{}.dav进行推流转码,其命令为 {}", fileNamePrefix, command);
                //通过命令行执行推流转码
                try {
                    Process process = Runtime.getRuntime().exec(command);
                    while (process.isAlive()) {
                        Thread.sleep(200);
                    }
                    log.info("完成对录像{}.dav的推流转码工作", fileNamePrefix);
                } catch (Exception e) {
                    log.error(e.getMessage());
                }
                return fileNamePrefix;
            });
            downloadRecordFileWorks.add(downloadDaHuaRecordFileResult);
        }

        List<String> deleteDavFileList = new ArrayList<>();

        List<RecordFileModel> downloads = new ArrayList<>();
        for (int i = 0; i < ipcList.size(); i++) {
            IpcModel ipc = ipcList.get(i);
            Future<String> downloadResult = downloadRecordFileWorks.get(i);
            try {
                String fileNamePrefix = downloadResult.get(30, TimeUnit.SECONDS);
                deleteDavFileList.add(fileNamePrefix);
                                                                                                                //需配置springboot静态资源读取
                downloads.add(new RecordFileModel(ipc.getId(), ipc.getIp(), nvr.getIp(), ipc.getChannel(), "http://" + serverIp + ":" + port + "/vidicon/playback/" + fileNamePrefix + ".mp4", true, ""));
            }catch (TimeoutException e) {
                downloads.add(new RecordFileModel(ipc.getId(), ipc.getIp(), nvr.getIp(), ipc.getChannel(),"", false, "下载回放录像文件超时, 请稍后再试"));
            } catch (Exception e) {
                log.error(e.getMessage());
                downloads.add(new RecordFileModel(ipc.getId(), ipc.getIp(), nvr.getIp(), ipc.getChannel(),"", false, e.getMessage()));
            }
        }

        //最后,再异步删除掉.dav视频文件,只保留.mp4视频文件
        threadPoolTaskExecutor.execute(() -> {
            for (String fileNamePrefix : deleteDavFileList) {
                String davFile = recordFilePath + "/" + fileNamePrefix + ".dav";
                File file = new File(davFile);
                if (file.exists() && file.delete()) {
                    log.info("{} 删除成功", davFile);
                } else {
                    log.info("{} 删除失败", davFile);
                }
            }
        });

        //退出登录
        logout();

        //释放资源
        netsdk.CLIENT_Cleanup();

        return downloads;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class RecordFileModel {
        //ipc的id
        private Long ipcId;

        //摄像机的ip
        private String ipcIp;

        //nvr的ip
        private String nvrIp;

        //ipc的通道号
        private Integer channel;

        //成功下载后得到的回放录像文件
        private String url;

        //是否下载成功,为true,则errorMessage为空; 为false,则url为空
        private boolean success;

        //下载失败得到的异常信息
        private String errorMessage;
    }

    @Slf4j
    public class DaHuaTimeDownloadPosCallback implements NetSDKLib.fTimeDownLoadPosCallBack {

        private CountDownLatch countDownLatch;

        private volatile boolean success;

        private String fileNamePrefix;

        public DaHuaTimeDownloadPosCallback(String fileNamePrefix, CountDownLatch countDownLatch){
            this.fileNamePrefix = fileNamePrefix;
            this.countDownLatch = countDownLatch;
            this.success = false;
        }

        @Override
        public void invoke(NetSDKLib.LLong lPlayHandle, int dwTotalSize, int dwDownLoadSize, int index, NetSDKLib.NET_RECORDFILE_INFO.ByValue recordfileinfo, Pointer dwUser) {
            log.info("回放录像 {}.dav 已经下载: {}",fileNamePrefix, dwDownLoadSize);
            if (dwDownLoadSize == -1) {
                log.info("录像{}.dav下载完毕", fileNamePrefix);
                success = true;
                countDownLatch.countDown();
            } else if (dwDownLoadSize == -2) {
                log.info("录像{}.dav写入文件失败", fileNamePrefix);
                countDownLatch.countDown();
            }
        }

        public boolean isSuccess() {
            return success;
        }
    }
}

4.获取海康NVR通道下的IPC并下载指定时间段内的回放录像

posted @   shame丶  阅读(1521)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示