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

定义

定义

视频除了常见格式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(三)

例子:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270