m3u8下载

有时候你去看一些视频,你想要保存下来,但是呢,它没有给你明显的下载地址,也就是播放器没有显示下载地址,同时F12也看不到类似于.mp4等视频格式的http请求地址,我们是徒呼奈何。试验了各种乱七八糟的下载工具,才发现,要么是效果太差,要么是下载频率和个数限制,就感觉很垃圾。

那么,我们就只能坐以待毙了吗?有一种视频的格式,也许我们可以自己想办法,去下载,那就是一些ffmpeg的视频文件的下载。

如何判别是ffmpeg的格式视频呢?F12打开控制台,如果首先请求了一些类似于http://xxxx/xxx/xxx/index.m3u8地址,然后响应了类似如下的东西:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="/20220430/lnuUxgeH/1500kb/hls/key.key"
#EXTINF:8.342,
/20220430/lnuUxgeH/1500kb/hls/PHQzkZd1.ts
#EXTINF:4.171,
/20220430/lnuUxgeH/1500kb/hls/HzdMqRYt.ts
#EXTINF:4.171,
...

那么这个就是ffmpeg的视频,也许可以下载。

上面这一段的请求,和响应,类似于一个索引文件,表明整个视频的一些格式以及整个视频是由多个分段的小碎片组成的,给出了每个分段视频的请求地址。每个碎片视频是以.ts结尾的,页面播放器按需下载视频碎片,进行播放。对于某些视频,是进行了加密的,也就是如果出现了这一行,就表明了视频字节文件是经过加密了的:

#EXT-X-KEY:METHOD=AES-128,URI="/20220430/lnuUxgeH/1500kb/hls/key.key"

这一行给出了加密的方式是AES-18加密方式,一般是公钥加密,私钥解密。同时也给出了私钥的地址是/20220430/lnuUxgeH/1500kb/hls/key.key。还有这么美好的事情吗?是的,不但给你说了加密方式,还给你说了私钥是什么。联系到上面index.m3u8的整体文件以及每个分段视频的地址,那么,也许可以下载视频了。

先说一下整体思路,首先根据index.m3u8获取响应,知道每个分段视频的地址以及秘钥访问路径。然后分别去下载每个分段视频、下载秘钥,然后将index.m3u8的秘钥地址、ts文件地址替换为本地地址,最后使用ffmpeg这个工具(可以下载)进行ts文件合并,同时它也对视频进行了解密操作。弄好以后,视频就算下载到了本地,可以播放啦。

pom引入http请求的依赖就好了,其余的就是springboot的常规依赖:

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.12</version>
</dependency>

下载index.m3u8内容,并解析为一行一行,形成列表,这一步是为了后续的处理:

private List<String> getM3u8IndexLines(String m3u8Url){
  String indexStr = HttpUtil.doGet(m3u8Url);// 下载index.m3u8
  log.info("从" + m3u8Url + "获取index.m3u8:" + indexStr);
  if(StringUtil.isEmpty(indexStr)){
    throw new RuntimeException("下载index.m3u8无响应");
  }
  String[] split = indexStr.split("\n");
  ParamCheckUtil.arrayEmpty(split,"下载index.m3u8无响应");

  List<String> lines = new ArrayList<>();
  for (int i = 0; i < split.length; i++) {
    lines.add(split[i]);
  }
  return lines;
}

从index.m3u8的响应中提出秘钥等信息,如果有的话(有的没有秘钥,也就是视频未加密,不用额外处理):

private Encry getEncry(List<String> m3u8IndexLines,String m3u8Url) {
  if(CollectionUtils.isEmpty(m3u8IndexLines)){
    return null;
  }
  List<String> keyLines = m3u8IndexLines.stream().filter( v -> v.contains("#EXT-X-KEY")).collect(Collectors.toList());
  if(keyLines == null || keyLines.size() == 0){
    return null;
  }
  ParamCheckUtil.listMoreThanOne(keyLines,"加密的行有2个,有错误");
  String keyLine = keyLines.get(0).split(":")[1];

  String[] arr = keyLine.split(",");

  Encry encry = new Encry();
  encry.setKeyLine(keyLines.get(0));
  encry.setMethod(arr[0].split("=")[1]);
  encry.setUri(arr[1].split("=")[1].replaceAll("\"","").replaceAll("'",""));

  if(encry.getUri().indexOf("/") != 0){
    encry.setUri("/" + encry.getUri());
  }

  String keyUrl = m3u8Url.substring(0,m3u8Url.indexOf("/",8)) + encry.getUri();//只要http://ccc  或者https://ccc 这一段 不要后面的
  String key = HttpUtil.doGet(keyUrl);
  log.info("获取秘钥:" + keyUrl + ",秘钥为:" + key);
  ParamCheckUtil.stringEmpty(key,"加密秘钥接口无响应");
  ParamCheckUtil.notTrue(key.length() == 16,"加密秘钥不是16位");
  encry.setKey(key);
  return encry;
}

@Data
static class Encry {

  private String keyLine;

  private String method;

  private String uri;

  private String key;
}


    将秘钥写入key.key的本地:

String keyFilePath = null;
if(encry != null && StringUtil.isNotEmpty((encry.getKey()))){
    File keyFile = new File(destParentDir,"key.key");
    if(!keyFile.exists()){
        FileUtil.byteArrayToFile(encry.getKey().getBytes("utf-8"),destParentDir,"key.key");
    }
    keyFilePath = new File(destParentDir,"key.key").getAbsolutePath();
}

接下来是下载各个ts文件,为了加快速度,使用多线程,首先创建一个线程池:

private static synchronized ThreadPoolExecutor getThreadPoolExecutor(){
    if(taskThreadPool != null){
        return taskThreadPool;
    }
    //cpu 核心数量 不同主机是不同的,按需修改
    int cpuCoreCount = 8;
    //核心线程数一般取 跟cpu核心数相同,减少上下文切换
    int corePoolSize = cpuCoreCount;
    //该类定时任务,cpu和磁盘操作比较均衡,取2倍的cpu核心数
    int maximumPoolSize = 2 * 8;
    // 10分钟
    long keepAliveTime = 1000 * 60 * 10;
    //有界阻塞队列   定时任务原则上按照先来的先执行
    ArrayBlockingQueue runnableTaskQueue = new ArrayBlockingQueue(1024);
    //拒绝策略:只用调用者所在线程来运行任务  如果想要看到一场,请使用 AbortPolicy
    RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.CallerRunsPolicy();
    taskThreadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime,
            TimeUnit.MILLISECONDS,runnableTaskQueue, rejectedExecutionHandler);
    return taskThreadPool;
}

接下来就是下载各个ts文件了。由于ts文件数可能会成百上千,肯定会有下载失败的,对于下载失败的,重新下载就好了,因此有必要记录一下哪些是下载过的,下载过的就直接跳过:

Map<Integer,String> lineMap = new TreeMap<>();
  for (int i = 0; i < m3u8IndexLines.size(); i++) {
      lineMap.put(i,m3u8IndexLines.get(i));
  }
  
  File[] existsFiles = destParentFile.listFiles();
  List<String> existsFileNames = new ArrayList<>();
  if(existsFiles != null){
      for (int i = 0; i < existsFiles.length; i++) {
          existsFileNames.add(existsFiles[i].getName());
      }
  }
  String urlPrefix =  m3u8Url.substring(0,m3u8Url.indexOf("/",8));
  String urlPrefix_ = m3u8Url.substring(0,m3u8Url.lastIndexOf("/"));
  //下载
  Integer fileCount = m3u8IndexLines.stream().filter(v -> StringUtil.isNotEmpty(v) && !v.startsWith("#")).collect(Collectors.toList()).size();
  Integer currentFile = 1;
  CountDownLatch countDownLatch = new CountDownLatch(fileCount);
  for (int i = 0; i < m3u8IndexLines.size(); i++) {
      String line = m3u8IndexLines.get(i);
      if(StringUtil.isNotEmpty(line)){
          if(line.startsWith("#EXT-X-KEY")){
              keyFilePath = keyFilePath.replaceAll("\\\\","/");//   \  替换成  /  因为是字符串路径
              String keyLine = "#EXT-X-KEY:METHOD=" + encry.getMethod() + ",URI=\"" + keyFilePath + "\"";
              lineMap.put(i,keyLine);
          }
          if(!line.startsWith("#")){//说明是文件
              String tsFileName = line.substring(line.lastIndexOf("/") + 1);
              String tsUri = line;
              if(!tsUri.startsWith("/")){
                  tsUri = "/" + tsUri;
              }
              String downloadTsUrl = null;
              if(tsUri.lastIndexOf("/") > 0){//如果ts uri有多个/  那么用前缀去拼接下载地址
                  downloadTsUrl = urlPrefix + tsUri;
              }else{//将m3u8的最后一个/ 之前的截取  作为前缀
                  downloadTsUrl = urlPrefix_ + tsUri;
              }
              if(existsFileNames.stream().filter(v -> v.equals(tsFileName)).findFirst().isPresent()){
                  log.info("已经存在文件" + tsFileName + " 跳过");
                  countDownLatch.countDown();
                  currentFile ++;
                  continue;
              }
              DownLoadCallable callable = new DownLoadCallable();
              callable.setTotal(fileCount);
              callable.setCountDownLatch(countDownLatch);
              callable.setTsDownloadUrl(downloadTsUrl);
              callable.setTsFileName(tsFileName);
              callable.setParentDir(destParentDir);
              callable.setCurrent(currentFile);
              getThreadPoolExecutor().submit(callable);
              currentFile ++;
          }
      }
  }
  countDownLatch.await();
  log.info(fileCount + "个文件下载完毕");

注意我们使用了CountDownLatch,原因是我们需要等待所有的文件下载完毕之后,进行其它操作。

对应的下载任务类:

public class DownLoadCallable implements Callable {
    private Integer total;
    private Integer current;
    private String tsDownloadUrl;//
    private String tsFileName;//TS保存到本地的文件名
    private String parentDir;//ts保存的父路径
    private CountDownLatch countDownLatch;
    @Override
    public Object call() throws Exception {
        try {
            //log.info("开始下载:" + tsDownloadUrl);
            byte[] bytes = HttpUtil.sendGet(tsDownloadUrl);
            if(bytes == null || bytes.length == 0){
                throw new RuntimeException("第:" + current + "文件下载失败");
            }
            FileUtil.byteArrayToFile(bytes, parentDir, tsFileName);
            log.info("第:" + current + "个文件下载完毕,文件总数为" + total + ",完成了" + (current/(float)total) * 100 + "%");
        }catch (Exception e){
            e.printStackTrace();
            throw e;
        }finally {
            countDownLatch.countDown();
        }
        return null;
    }
}

文件操作的工具类FileUtil:

public class FileUtil {
    // 字节数组写出到文件 需要字节数组的数据源,以及文件的路径
    public static void byteArrayToFile(byte[] src, String filePath, String fileName) {
        File dir = new File(filePath);
        dir.mkdirs();
        File dest = new File(filePath,fileName);//输出图片的目的地,这里是文件写出的路径
        ParamCheckUtil.isTrue(dest.exists(),"文件已存在");
        ByteArrayInputStream is = null;   //字节数组的流,先让它写到程序   src是数据源
        OutputStream os = null;
        try {
            dest.createNewFile();
            is = new ByteArrayInputStream(src);
            os = new FileOutputStream(dest);
            byte[] flush=new byte[5];
            int len = -1;
            while((len = is.read(flush))!= -1){//这里是写入程序
                os.write(flush,0,len);//这一步是将程序写入到文件    这里一定要记住文件流一定要释放
            }
            os.flush();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if(is!=null) {
                    is.close();
                }
                if(os!=null) {
                    os.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    // 文件读取到字节数组写一个方法,字节数组的目的地可以不用管
    public static byte[] fileToByteArray(String filePath){
        //先将图片以字节流的方式输入到程序中
        File src = new File(filePath);
        byte[] dest = null; //这个目的地可有可无
        InputStream is = null;
        ByteArrayOutputStream baos = null;// 有新增方法不能发生多态
        byte[] flush = new byte[1024 * 10];//定义了一个字节数组
        int len = -1;
        try {
            is = new FileInputStream(src);
            baos = new ByteArrayOutputStream();// 有新增方法不能发生多态
            while ((len = is.read(flush)) != -1) {//把文件写入到程序中了(这里是通过)
                baos.write(flush, 0, len);  //从程序写出到字节数组中
            }
            baos.flush();
            return baos.toByteArray();      //返回baos的字节数组
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (is != null) {
                    is.close();
                }
                if (baos != null) {
                    baos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * 输出
     * @param lines
     * @param fileName
     */
    public static void outputLine(List<String> lines, String fileName){
        File file = new File(fileName);
        BufferedWriter writer = null;
        try{
            if(!file.exists()){
                file.createNewFile();
            }
            FileOutputStream fis = new FileOutputStream(file);
            OutputStreamWriter osw = new OutputStreamWriter(fis,"UTF-8");
            writer = new BufferedWriter(osw);
            for(int i = 0; i < lines.size(); i++){
                writer.write(lines.get(i));
                writer.newLine();//换行
            }
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            if(writer != null){
                try {
                    writer.close();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

下载完毕之后,对index.m3u8内容的替换,以及将内容保存到本地文件:

//替换原来的index.m3u8文件内容
existsFiles = new File(destParentDir).listFiles();
Map<String,String> fileMap = new HashMap<>();
for (int i = 0; i < existsFiles.length; i++) {
    File f = existsFiles[i];
    fileMap.put(f.getName(),f.getAbsolutePath());
}

for (int i = 0; i < m3u8IndexLines.size(); i++) {
    String line = m3u8IndexLines.get(i);
    if(StringUtil.isNotEmpty(line)){
        if(!line.startsWith("#")){//说明是文件
            String tsFileName = line.substring(line.lastIndexOf("/") + 1);
            String filePath= fileMap.get(tsFileName);
            ParamCheckUtil.stringEmpty(filePath,"文件下载失败:" + line);
            lineMap.put(i,filePath);
        }
    }
}
//输出到文本
List<String> newLines = new ArrayList<>();
Set<Map.Entry<Integer, String>> entries = lineMap.entrySet();
for (Map.Entry<Integer, String> entry : entries) {
    newLines.add(entry.getValue());
}
File indexM3u8File = new File(destParentDir , "index.m3u8");
if(indexM3u8File.exists()){
    indexM3u8File.delete();
}
FileUtil.outputLine(newLines,destParentDir + "/index.m3u8");
log.info("index.m3u8文件内容替换完毕");

好了,完事具备,此时下载一个ffmpeg安装包就可以了,到官网去下载。windows版本的是zip包,下载下来解压缩,找到bin目录,将该目录加入path环境变量。

以管理员身份打开cmd窗口,切换到下载ts的父目录,执行:

ffmpeg -allowed_extensions ALL -i index.m3u8 -c copy video.mp4

这一步是将所有ts文件合并成一个video.mp4文件。好了,到这里,视频就可以播放啦。不由得感慨,兴趣是最好的老师,爱好是最好的老师,更重要的是,欲望才是行动的催化剂...

其实java中也可以执行cmd命令,但不知道为何执行完毕后,视频无法播放,下面给出代码,有兴趣的小伙伴可以继续研究:

log.info("开始合并ts文件");
String command = "ffmpeg -allowed_extensions ALL -i index.m3u8 -c copy " + fileName;
Runtime.getRuntime().exec("cmd /c " + command,null,new File(destParentDir));
log.info("合并ts文件结束");

还有,我们也可以在代码中进行字节解密,然后使用字节流将所有文件合并成一个文件。由于我担心这样做太粗暴了,所以没有这样做,给出代码吧:

/**
 * 删除ts文件
 */
private static void deleteTs(List<String> urls) {
  for (int i = 0; i < urls.size(); i++) {
    new File(parentDir + "000" + (i + 1) + ".ts").deleteOnExit();
  }
}

/**
 * 合并ts文件
 *
 */
private static void mergeTs(List<String> urls) {
  FileOutputStream fos = null;
  FileInputStream fis = null;
  try {
    if ("".equals(fileName)) {
      fileName = "1" + new Random().nextInt(10000);
    }
    File file = new File(parentDir + fileName + ".mp4");
    fos = new FileOutputStream(file);
    byte[] buf = new byte[4096];
    int len;
    for (int i = 0; i < urls.size(); i++) {
      fis = new FileInputStream(parentDir + "000" + (i + 1) + ".ts");
      while ((len = fis.read(buf)) != -1) {
        fos.write(buf, 0, len);
      }
      fos.flush();
      fis.close();
    }
  } catch (Exception e) {
    e.printStackTrace();
  } finally {
    try {
      if (fis != null) {
        fis.close();
      }
      if (fos != null) {
        fos.close();
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

}

/**
 * AES CBC 解密
 * @param key   sSrc ts文件字节数组
 * @param iv    IV,需要和key长度相同
 * @return  解密后数据
 */
public static byte[] decryptCBC(byte[] src, String key, String iv) {
  try {
    byte[] keyByte = key.getBytes(StandardCharsets.UTF_8);
    SecretKeySpec keySpec = new SecretKeySpec(keyByte, "AES");
    byte[] ivByte = iv.getBytes(StandardCharsets.UTF_8);
    IvParameterSpec ivSpec = new IvParameterSpec(ivByte);
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
    byte[] content = cipher.doFinal(src);
    return content;
  } catch (Exception e) {
    e.printStackTrace();
  }
  return null;
}

最后,这里面有一个问题,如果请求是https的,java就会报错:

PKIX path building failed: sun.security.provider...

这是由于https需要证书,我也不是太懂,总之就是需要将证书导入本地jre的目录,再进行操作。我选择最暴力了,无条件信任所有https的握手和连接,忽略ssl的关键类:

static class miTM implements javax.net.ssl.TrustManager,
            javax.net.ssl.X509TrustManager {
  public java.security.cert.X509Certificate[] getAcceptedIssuers() {
      return null;
  }
  
  public boolean isServerTrusted(
          java.security.cert.X509Certificate[] certs) {
      return true;
  }
  
  public boolean isClientTrusted(
          java.security.cert.X509Certificate[] certs) {
      return true;
  }
  
  public void checkServerTrusted(
          java.security.cert.X509Certificate[] certs, String authType)
          throws java.security.cert.CertificateException {
      return;
  }
  
  public void checkClientTrusted(
          java.security.cert.X509Certificate[] certs, String authType)
          throws java.security.cert.CertificateException {
      return;
  }
}

public static SSLConnectionSocketFactory getSSLSF(){
  try {
      SSLContext sslcontext = SSLContext.getInstance("SSLv3");  //建立证书实体
      javax.net.ssl.TrustManager[] trustAllCerts = new javax.net.ssl.TrustManager[1];
      javax.net.ssl.TrustManager tm = new miTM();
      trustAllCerts[0] = tm;
      sslcontext.init(null, trustAllCerts, null);
      SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslcontext, SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
      return sslsf;
  }catch (Exception e){
      e.printStackTrace();
  }
  return null;
}

然后调用http的时候,进行以下操作:

/**
 * get请求
 * @param url
 * @return
 * @throws ParseException
 * @throws IOException
 */
public static String doGet(String url) {
    CloseableHttpResponse response = null;
    String result = null;
    CloseableHttpClient httpclient = null;
    try {
        if(url.startsWith("https")){
            httpclient = HttpClients.custom().setSSLSocketFactory(getSSLSF()).build();
        }else{
            httpclient = HttpClients.createDefault();
        }
        HttpGet httpGet = new HttpGet(url);
        response = httpclient.execute(httpGet);
        HttpEntity entity = response.getEntity();
        if (entity != null) {
            result = EntityUtils.toString(entity, "UTF-8");
        }
        EntityUtils.consume(entity);
    }catch (Exception e){
        e.printStackTrace();
    } finally {
        try {
            response.close();
        }catch (Exception e2){
            e2.printStackTrace();
        }
    }
    return result;
}

关键的点:

httpclient = HttpClients.custom().setSSLSocketFactory(getSSLSF()).build();

还有一些其余的问题,比如http请求在多线程下,不应该每次重新建立连接,而应该有类似于线程池的东西,不要反建立连接和断开连接。

最后,在下载的时候,cpu和内存会飙升,反倒是磁盘没怎么变动,代码中应该有不少可以优化的内容。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 最近在浏览学习网站的时候,遇到了经过blob加密的文件url,一下子就懵了。也是迫于对学习资料的渴望,草草翻看了2...
    小明阿婆阅读 4,311评论 0 1
  • 本文讲述的是iOS相关的下载实现方式。 大纲 1.前言 M3U8简介 2. M3U8文件格式 扩展M3U指令 顶级...
    尼古拉斯_小巍阅读 1,702评论 0 2
  • 最近在做视频播放器,发现目前主流的视频播放都是流媒体,以前的MP4 大文件播放时代已经过去了。之前做的一个播放器:...
    NicooYang阅读 6,354评论 1 9
  • 日常中我们在一些网站上看到有意思的电影或者视频,想保存下来,点击下载却发现这是一个以 .m3u8结尾的视频链接。就...
    依旧丶森阅读 12,935评论 1 8
  • 1、安装apt install ffmpeg2、检查是否安装成功ffmpeg -version 3、简单使用方法截...
    Firstmeet初见阅读 1,516评论 0 1