美文网首页M3U8微服务Java web
下载m3u8视频并将ts合并为mp4格式

下载m3u8视频并将ts合并为mp4格式

作者: 敏捷Studio | 来源:发表于2020-07-13 19:13 被阅读0次

    定义

    定义

    视频除了常见格式mp4flv之外,还有m3u8格式。m3u8是苹果公司推出一种视频播放标准,是m3u的一种,不过编码方式是utf-8,是一种文件检索格式,将视频切割成一小段一小段的ts格式的视频文件,然后存在服务器中(现在为了减少I/o访问次数,一般存在服务器的内存中),通过m3u8解析出来路径,然后去请求。这样每次请求很小一段视频,可以做到近似于实时播放的效果。

    分析

    1、视频播放地址必须是m3u8链接。当播放视频的时候,如果你打开了浏览器的开发者工具的话,就会发现有许多的ts片段。这些ts片段也就是加载的视频片段。我们要做的就是下载这些ts片段,然后合并。
    2、当你打开m3u8链接的时候,会发现m3u8实际上是一个可以用文本打开的一个文件,它包含了一些和视频相关的标签。通过这些标签,我们可以获取我们要下载的ts片段。
    3、现在大部分网站都对ts片段进行加密,所以我们首先要从m3u8文件拿到ts密钥。然后再进行下载,当然有的ts片段是没有被加密的。
    4、每一个解密后ts片段都是可以单独播放的,所以合并的时候我们就直接流合并就行了,无需做任何处理,合并的文件我们就用mp4

    优点

    可以识别m3u8获取的ts片段是否需要解密
    可以自定义下载线程数,达到多线程快速下载
    可以自定义ts片段下载失败重试次数,很难下载失败

    缺点

    当重试次数耗尽时或者部分片段解密失败时,不能够再次重新下载失败的ts片段。但是不影响视频后期合并,导致观看合并完成的视频的时候,播放不衔接;
    线程越多,占用内存越高。当线程数为100时,下载400M视频需要700M内存,而10个线程则需要70M左右内存。当然线程越多,下载越快,需要自行权衡。

    示例

    示例:

    http://cdn.can.cibntv.net/12/201702161000/rexuechangan01/1.m3u8

    请求:

    模拟HTTP请求,获取链接相应内容

     /**
      * 模拟http请求获取内容
      *  
      * @param urls http链接
      * @return 内容
      */
      private StringBuilder getUrlContent(String urls) {
        int count = 1;
        HttpURLConnection httpURLConnection = null;
        StringBuilder content = new StringBuilder();
        while (count <= retryCount) {
          try {
            URL url = new URL(urls);
            httpURLConnection = (HttpURLConnection) url.openConnection();
            httpURLConnection.setConnectTimeout((int) timeoutMillisecond);
            httpURLConnection.setReadTimeout((int) timeoutMillisecond);
            httpURLConnection.setUseCaches(false);
            httpURLConnection.setDoInput(true);
            String line;
            InputStream inputStream = httpURLConnection.getInputStream();
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
            while ((line = bufferedReader.readLine()) != null)
              content.append(line).append("\n");
              bufferedReader.close();
              inputStream.close();
              System.out.println(content);
              break;
            } catch (Exception e) {
              System.out.println("第" + count + "获取链接重试!\t" + urls);
              count++;
              e.printStackTrace();
            } finally {
              if (httpURLConnection != null) {
                httpURLConnection.disconnect();
              }
            }
          }
          if (count > retryCount) {
            throw new M3u8Exception("连接超时!");
            return content;
          }
        }
    
    响应:
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:14
    #EXTINF:11.480, 
    20170215T224129-1-0.ts
    #EXTINF:11.480, 
    20170215T224129-1-1.ts
    #EXTINF:10.480, 
    20170215T224129-1-2.ts
    #EXTINF:11.400, 
    20170215T224129-1-3.ts
    #EXTINF:11.120, 
    20170215T224129-1-4.ts
    #EXTINF:11.200, 
    20170215T224129-1-5.ts
    #EXTINF:13.600, 
    20170215T224129-1-6.ts
    #EXTINF:11.360, 
    20170215T224129-1-7.ts
    #EXTINF:10.240, 
    20170215T224129-1-8.ts
    #EXTINF:12.000, 
    20170215T224129-1-9.ts
    #EXTINF:13.760, 
    20170215T224129-1-10.ts
    #EXT-X-ENDLIST
    

    #EXT-X-ENDLIST标识ts结尾的文件,这才是视频真正的存放路径:http://cdn.can.cibntv.net/12/201702161000/rexuechangan01/20170215T224129-1-0.ts ,这时候用浏览器下载就可以播放。不过这个播放不用我们去解析 android 4.0以后的videoView 就支持自动解析,并拼接播放。

    TAG含义

    M3U8格式讲解及实际应用分析
    流媒体开发之--HLS--M3U8解析(2): HLS草案

    判断是否需要解密

    首先将m3u8链接内容通过getUrlContent()方法获取到,然后解析,如果内容含有#EXT-X-KEY标签,则说明这个链接是需要进行ts文件解密的,然后通过下面的m3u8if语句获取含有密钥以及ts片段的链接。
    如果含有#EXTINF则说明这个链接就是含有ts视频片段的链接,没有第二个m3u8链接了。
    之后我们要获取密钥的getKey方法,即时不需要密钥。并把ts片段加进set集合,即tsSet字段。

    /**
     * 获取所有的ts片段下载链接
     *
     * @return 链接是否被加密,null为非加密
     */
     private String getTsUrl() {
       StringBuilder content = getUrlContent(DOWNLOADURL);
       // 判断是否是m3u8链接
       if (!content.toString().contains("#EXTM3U")) {
         throw new M3u8Exception(DOWNLOADURL + "不是m3u8链接!");
       }
       String[] split = content.toString().split("\\n");
       String keyUrl = "";
       boolean isKey = false;
       for (String s : split) {
         // 如果含有此字段,则说明只有一层m3u8链接
         if (s.contains("#EXT-X-KEY") || s.contains("#EXTINF")) {
           isKey = true;
           keyUrl = DOWNLOADURL;
           break;
         }
         // 如果含有此字段,则说明ts片段链接需要从第二个m3u8链接获取
         if (s.contains(".m3u8")) {
           if (StringUtils.isUrl(s)) {
             return s;
           }
           String relativeUrl = DOWNLOADURL.substring(0, DOWNLOADURL.lastIndexOf("/") + 1);
           keyUrl = relativeUrl + s;
           break;
         }
       }
       if (StringUtils.isEmpty(keyUrl)) {
         throw new M3u8Exception("未发现key链接!");
       }
       // 获取密钥
       String key1 = isKey ? getKey(keyUrl, content) : getKey(keyUrl, null);
       if (StringUtils.isNotEmpty(key1)) {
         key = key1;
       } else {
         key = null;
       }
       return key;
     }
    
    获取密钥

    如果参数content不为空,则说明密钥信息从此字段取,否则则访问第二个m3u8链接,然后获取信息。
    也就是说,如果content为空,说明则为样例一,三的情况,第一个m3u8文件里面没有ts片段信息,需要从第二个m3u8文件取。
    如果发现不需要解密,此方法将会返回null。需要解密的话,那么解密算法将会存在method字段,密钥将存在key字段。

    /**
     * 获取ts解密的密钥,并把ts片段加入set集合
     *
     * @param url     密钥链接,如果无密钥的m3u8,则此字段可为空
     * @param content 内容,如果有密钥,则此字段可以为空
     * @return ts是否需要解密,null为不解密
     */
     private String getKey(String url, StringBuilder content) {
       StringBuilder urlContent;
       if (content == null || StringUtils.isEmpty(content.toString())) {
         urlContent = getUrlContent(url);
       } else {
         urlContent = content;
       } 
       if (!urlContent.toString().contains("#EXTM3U")) {
         throw new M3u8Exception(DOWNLOADURL + "不是m3u8链接!");
       }
       String[] split = urlContent.toString().split("\\n");
       for (String s : split) {
         // 如果含有此字段,则获取加密算法以及获取密钥的链接
         if (s.contains("EXT-X-KEY")) {
           String[] split1 = s.split(",", 2);
           if (split1[0].contains("METHOD")) {
             method = split1[0].split("=", 2)[1];
           }
           if (split1[1].contains("URI")) {
             key = split1[1].split("=", 2)[1];
           }
         }
       }
       String relativeUrl = url.substring(0, url.lastIndexOf("/") + 1);
       // 将ts片段链接加入set集合
       for (int i = 0; i < split.length; i++) {
         String s = split[i];
         if (s.contains("#EXTINF")) {
           tsSet.add(relativeUrl + split[++i]);
         }
       }
       if (!StringUtils.isEmpty(key)) {
         key = key.replace("\"", "");
         return getUrlContent(relativeUrl + key).toString().replaceAll("\\s+", "");
       }
       return null;
     }
    
    解密ts:
    /**
     * 解密ts
     *
     * @param sSrc ts文件字节数组
     * @param sKey 密钥
     * @return 解密后的字节数组
     */
     private static byte[] decrypt(byte[] sSrc, String sKey, String method) {
       try {
         if (StringUtils.isNotEmpty(method) && !method.contains("AES")) {
           throw new M3u8Exception("未知的算法!");
         }
         // 判断Key是否正确
         if (StringUtils.isEmpty(sKey)) {
           return sSrc;
         }
         // 判断Key是否为16位
         if (sKey.length() != 16) {
           System.out.print("Key长度不是16位");
           return null;
         }
         Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
         SecretKeySpec keySpec = new SecretKeySpec(sKey.getBytes("utf-8"), "AES");
         // 如果m3u8有IV标签,那么IvParameterSpec构造函数就把IV标签后的内容转成字节数组传进去
         AlgorithmParameterSpec paramSpec = new IvParameterSpec(new byte[16]);
         cipher.init(Cipher.DECRYPT_MODE, keySpec, paramSpec);
         return cipher.doFinal(sSrc);
       } catch (Exception ex) {
         ex.printStackTrace();
         return null;
       }
     }
    
    启动线程下载ts片段

    代码中xy后缀文件是未解密的ts片段,xyz是解密后的ts片段,这两个后缀起成什么无所谓。

    /**
     * 开启下载线程
     *
     * @param urls ts片段链接
     * @param i    ts片段序号
     * @return 线程
     */
     private Thread getThread(String urls, int i) {
       return new Thread(() -> {
         int count = 1;
         HttpURLConnection httpURLConnection = null;
         // xy为未解密的ts片段,如果存在,则删除
         File file2 = new File(dir + "\\" + i + ".xy");
         if (file2.exists()) {
           file2.delete();
         }
         OutputStream outputStream = null;
         InputStream inputStream1 = null;
         FileOutputStream outputStream1 = null;
         // 重试次数判断
         while (count <= retryCount) {
           try {
             // 模拟http请求获取ts片段文件
             URL url = new URL(urls);
             httpURLConnection = (HttpURLConnection) url.openConnection();
             httpURLConnection.setConnectTimeout((int) timeoutMillisecond);
             httpURLConnection.setUseCaches(false);
             httpURLConnection.setReadTimeout((int) timeoutMillisecond);
             httpURLConnection.setDoInput(true);
             InputStream inputStream = httpURLConnection.getInputStream();
             try {
               outputStream = new FileOutputStream(file2);
             } catch (FileNotFoundException e) {
               e.printStackTrace();
             }
             int len;
             byte[] bytes = new byte[1024];
             // 将未解密的ts片段写入文件
             while ((len = inputStream.read(bytes)) != -1) {
               outputStream.write(bytes, 0, len);
               synchronized (this) {
                 downloadBytes = downloadBytes.add(new BigDecimal(len));
               }
             }
             outputStream.flush();
             inputStream.close();
             inputStream1 = new FileInputStream(file2);
             byte[] bytes1 = new byte[inputStream1.available()];
             inputStream1.read(bytes1);
             File file = new File(dir + "\\" + i + ".xyz");
             outputStream1 = new FileOutputStream(file);
             //开始解密ts片段,这里我们把ts后缀改为了xyz,改不改都一样
             outputStream1.write(decrypt(bytes1, key, method));
             finishedFiles.add(file);
             break;
           } catch (Exception e) {
             System.out.println("第" + count + "获取链接重试!\t" + urls);
             count++;
             e.printStackTrace();
           } finally {
             try {
               if (inputStream1 != null) {
                 inputStream1.close();
               }
               if (outputStream1 != null) {
                 outputStream1.close();
               }
               if (outputStream != null) {
                 outputStream.close();
               }
             } catch (IOException e) {
               e.printStackTrace();
             }
             if (httpURLConnection != null) {
               httpURLConnection.disconnect();
             }
           }
         }
         if (count > retryCount) {
           // 自定义异常
           throw new M3u8Exception("连接超时!");
         }
         finishedCount++;
         System.out.println(urls + "下载完毕!\t已完成" + finishedCount + "个,还剩" + (tsSet.size() - finishedCount) + "个!");
       });
     }
    
    合并以及删除多余的ts片段
    /**
     * 合并下载好的ts片段
     */
     private void mergeTs() {
       try {
         File file = new File(dir + "/" + fileName + ".mp4");
         if (file.exists()) {
           file.delete();
         } else {
           file.createNewFile();
         }
         FileOutputStream fileOutputStream = new FileOutputStream(file);
         byte[] b = new byte[4096];
         for (File f : finishedFiles) {
           FileInputStream fileInputStream = new FileInputStream(f);
           int len;
           while ((len = fileInputStream.read(b)) != -1) {
             fileOutputStream.write(b, 0, len);
           }
           fileInputStream.close();
           fileOutputStream.flush();
         }
         fileOutputStream.close();
        } catch (Exception e) {
          e.printStackTrace();
        }
      }
    
     /**
      * 删除下载好的片段
      */
      private void deleteFiles() {
        File file = new File(dir);
        for (File f : file.listFiles()) {
          if (!f.getName().contains(fileName + ".mp4")) {
            f.deleteOnExit();
          }
        }
      }
    
    播放:
    Uri uri =     Uri.parse("http://cdn.can.cibntv.net/12/201702161000/rexuechangan01/rexuechangan01.m3u8");
    video_view.setMediaController(new MediaController(this));
    video_view.setVideoURI(uri);  
    video_view.requestFocus();
    ideo_view.start();
    

    参考:

    源码:M3U8Download
    源码:M3U8Downloader
    java下载m3u8视频,解密并合并ts(一)
    java下载m3u8视频,解密并合并ts(二)
    java下载m3u8视频,解密并合并ts(三)

    例子:

    相关文章

      网友评论

        本文标题:下载m3u8视频并将ts合并为mp4格式

        本文链接:https://www.haomeiwen.com/subject/tnancktx.html