怎么使用js下载m3u8视频

使用js分析m3u8并下载合并后的ts流

阅读量:2195

点赞量:32

评论量:1
Skill
标签
标签: Javascript
摘要
使用js分析m3u8并下载合并后的ts流
正文

前言

最近在看一些视频的时候,感觉还不错就想着下载下来,结果以前用的IDM不能使用了,一直提示涉及版权问题不支持下载,于是就想着自己写一个下载器,能实现简单的下载就行。


第一步:拿到m3u8地址,并进行解析

将m3u8地址放到浏览器下载下来,然后用记事本打卡可以看到视频是由很多ts流组成的。

image.png

通过上面的图片可以看出,m3u8文件已经包含了视频的全部信息,其中比较重要的就是红线圈起来的视频流,以及蓝线圈起来的加密方式。

先说明一下地址:流和秘钥的地址也大致分为三种,假设m3u8地址为:http://www.xxx.com/videos/234234/index.m3u8

那么

// 第一种,相当于http://www.xxx.com/videos/234234/rzko23a7.ts
#EXTINF:5.005,
rzko23a7.ts

// 第二种,相当于http://www.xxx.com/video/ts/67867/rzko23a7.ts
#EXTINF:5.005,
/video/ts/67867/rzko23a7.ts

// 第三种,就是本身
#EXTINF:10.000,
https://ccp-bj29-video-preview.oss-enet.aliyuncs.com/lt/A5FFCC02E1C25C0D3E30023510AF63B8E63DF6AA_551635824__sha1_bj29/FHD/media-0.ts?security-token=CAIS%2BgF1q6Ft5B2yf

以上了解完m3u8之后就可以开始下载了


下载视频流

1、先对m3u8解析格式化为json文件,方便后期使用

<script src="https://unpkg.com/m3u8-parser@6.0.0/dist/m3u8-parser.min.js"></script>
const url = 'http://www.xxx.com/videos/234234/index.m3u8';
const content = await (await fetch(url)).text()
const parser = new m3u8Parser.Parser()
parser.push(content)
parser.end()
const parsedManifest = parser.manifest
// 计算视频总时长
if (parsedManifest.segments) {
  let duration = 0
  parsedManifest.segments.forEach((segment) => {
    duration += segment.duration
  })
  parsedManifest.duration = duration
}
console.log(parsedManifest)

2、下载视频流,并转换成Uint8Array

new Uint8Array(await (await fetch(url)).arrayBuffer())

3、如果加密就进行解密

const iv = segment.iv || 0
const key = await this.getAESKey(segment.key)
const result = new aesDecrypter.decrypt(res, key, iv)

4、将所有的ts流进行合并,并转换为blob

new Blob(this.segments.map((item) => item.byte),
    { type: ''video/MP2T }
)

5、进行下载

const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.target = '_blank'
a.style.display = 'none'
document.body.appendChild(a)
a.download = Date.now() + ‘.ts’
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)

大致流程就是上面的五步,下载下来的最终为一个ts视频文件。

完整代码会有一个转换为MP4的选项,但因为转换之后视频时长会出问题,不建议使用。效果如下:

image.png

完整代码

index.html

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>m3u8下载器</title>
  <style>
    #form {
      display: block;
      width: 500px;
      height: 500px;
      margin: 50px auto;
    }

    #form h2 {
      text-align: center;
    }

    label {
      display: block;
      margin-bottom: 20px;
    }

    label span {
      display: block;
      font-weight: bold;
      font-size: 20px;
      margin-bottom: 10px;
    }

    label input,
    label select {
      width: 100%;
      padding: 10px 20px;
      border: 1px solid #ccc;
      border-radius: 4px;
      border-color: orange;
    }

    label button {
      float: right;
      padding: 10px 20px;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <script src="https://unpkg.com/mux.js@6.2.0/dist/mux-mp4.min.js"></script>
  <script src="https://unpkg.com/m3u8-parser@6.0.0/dist/m3u8-parser.min.js"></script>
  <script src="https://unpkg.com/aes-decrypter@3.1.3/dist/aes-decrypter.min.js"></script>
  <script src="./download-m3u8.js"></script>
  <script>
    window.addEventListener('DOMContentLoaded', () => {
      let hasClick = true;
      document.querySelector('#startDownload').addEventListener('click', function () {
        if (!hasClick) {
          return;
        }
        const url = document.querySelector('#url').value
        const filename = document.querySelector('#filename').value
        const maxRetryCount = document.querySelector('#retry').value
        const maxThreadCount = document.querySelector('#thread').value
        if (!url || !url.endsWith('.m3u8')) {
          alert('请输入正确地址!')
          return;
        }
        hasClick = false;
        this.disabled = true;
        new DownloadM3u8({
          url,
          filename,
          maxRetryCount,
          maxThreadCount,
          exclude: ['www.guanggao.com', /guanggao/],
          complete: () => {
            hasClick = true;
            this.disabled = false;
          }
        })
      })
    })


    // 字符串转字节
    function stringToByte (str) {
      return new TextEncoder().encode(str);
    }
  </script>

  <div id="form">
    <h2>m3u8下载器</h2>
    <label>
      <span>地址</span>
      <input id="url" type="text" placeholder="m3u8地址" /><label> </label>
      <span>文件名</span>
      <input id="filename" type="text" placeholder="文件名(不需要后缀,默认地址名)" />
    </label>
    <label>
      <span>重试</span>
      <select id="retry">
        <option value="1" selected>1次(不重试)</option>
        <option value="2">2次</option>
        <option value="3">3次</option>
        <option value="4">4次</option>
        <option value="5">5次</option>
      </select>
    </label>
    <label>
      <span>线程</span>
      <input type="range" id="thread" value="10" min="1" max="20" step="1" />
    </label>
    <label>
      <button id="startDownload">开始下载</button>
    </label>
  </div>
</body>

</html>
download-m3u8.js
;(function (window) {
  // 下载m3u8构造函数
  function DownloadM3u8(options) {
    // 默认配置
    const defOptions = {
      // m3u8地址
      url: '',
      // 文件名称
      filename: Date.now(),
      // 失败重试次数
      maxRetryCount: 3,
      // 排除流,字符串或正则 数组
      exclude: [],
      // 输出MP4格式,(文件时间有问题,不建议使用)
      outputMp4: false,
      // 是否在全部文件下载完成后再进行转码
      lastTranscoding: false,
      // 最大线程
      maxThreadCount: 10,
      complete: () => {}
    }

    this.keys = {}
    this.downloadingCount = 0

    this.options = Object.assign({}, defOptions, options)
    if (!this.options.url || !this.options.url.endsWith('.m3u8')) {
      alert('请输入正确的视频地址!')
      throw '请输入正确的视频地址!'
    }
    this.origin = new URL(this.options.url).origin

    this.maxThreadCount = this.options.maxThreadCount
    this.maxRetryCount = this.options.maxRetryCount

    // 记录下载位置
    this.downloadIndex = 0
    this.init()
  }

  // 初始化
  DownloadM3u8.prototype.init = async function () {
    this.manifest = await this.parserM3u8()
    this.segments = this.manifest.segments
    this.segmentCount = this.segments.length
    console.log(`共${this.segmentCount}个切片。`)
    console.log('视频信息:', this.manifest)

    // 没有切片
    if (!this.segments || !this.segmentCount) {
      return
    }

    // 开始执行下载操作
    for (let i = 0; i < this.maxThreadCount; i++) {
      this.downloadNextTs()
    }
  }

  // 解析m3u8文件
  DownloadM3u8.prototype.parserM3u8 = async function () {
    const content = await (await fetch(this.options.url)).text()
    const parser = new m3u8Parser.Parser()
    parser.push(content)
    parser.end()
    const parsedManifest = parser.manifest
    // 计算视频总时长
    if (parsedManifest.segments) {
      let duration = 0
      parsedManifest.segments.forEach((segment) => {
        duration += segment.duration
      })
      parsedManifest.duration = duration
    }
    return parsedManifest
  }

  // 下载ts文件并解密
  DownloadM3u8.prototype.downloadTs = async function (segment, count = 0) {
    count++
    try {
      const url = this.getUrl(segment.uri)
      // 匹配排除流,不进行下载
      if (this.options.exclude && this.options.exclude.length) {
        for (let i = 0; i < this.options.exclude.length; i++) {
          const item = this.options.exclude[i]
          if (typeof item === 'string' && url.includes(item)) {
            return Promise.resolve([])
          } else if (item instanceof RegExp && item.test(url)) {
            return Promise.resolve([])
          }
        }
      }
      // 对需要下载的流下载,并以Uint8Array类型返回
      return new Uint8Array(await (await fetch(url)).arrayBuffer())
    } catch (e) {
      // 失败重试
      if (count < this.maxRetryCount) {
        return this.downloadTs(segment, count)
      } else {
        return Promise.reject()
      }
    }
  }

  // 拼接流地址
  DownloadM3u8.prototype.getUrl = function (uri) {
    let url
    if (uri.indexOf('http') === 0) {
      url = uri
    } else if (uri.indexOf('/') === 0) {
      url = this.origin + uri
    } else {
      url = this.options.url.replace(/[^\/]+$/, uri)
    }
    return url
  }

  // 按顺序下载分片ts文件
  DownloadM3u8.prototype.downloadNextTs = async function () {
    const index = this.downloadIndex
    if (index < this.segmentCount) {
      const segment = this.segments[index]
      segment.status = 'downloading'
      this.downloadingCount++
      console.log(`第${index + 1}/${this.segmentCount}开始下载:`, segment.uri)
      this.downloadTs(segment)
        .then(async (res) => {
          this.downloadNextTs()
          segment.status = 'downloaded'
          // 检查并解密ts
          if (segment.key) {
            res = await this.decryptTs(segment, res)
          }
          // 转码mp4
          if (this.options.outputMp4 && !this.options.lastTranscoding) {
            res = await this.conversionMp4(res, index)
          }
          segment.byte = res
          this.downloadingCount--
          this.checkDownloadComplete()
        })
        .catch((err) => {
          console.log('下载失败:' + segment.uri, err)
          this.downloadNextTs()
          segment.status = 'error'
          this.downloadingCount--
          this.checkDownloadComplete()
        })
      this.downloadIndex++
    }
  }

  // 检测全部下载并解密完成
  DownloadM3u8.prototype.checkDownloadComplete = function () {
    if (
      this.downloadIndex === this.segmentCount &&
      this.downloadingCount === 0
    ) {
      console.log('全部流下载完成,开始下载准备')
      this.mergeAndDownload()
    }
  }

  // 获取aesKey
  DownloadM3u8.prototype.getAESKey = async function (keys) {
    const uri = keys.uri
    if (!this.keys[uri]) {
      this.keys[uri] = new Promise((resolve, reject) => {
        const url = this.getUrl(uri)
        fetch(url)
          .then((res) => res.text())
          .then(async (key) => {
            // aes-js库解密方法,没有用
            // var aesCbc = new aesjs.ModeOfOperation.cbc(stringToByte(key), stringToByte(iv));
            // var decryptedBytes = aesCbc.decrypt(encryptedBytes);
            var view = new DataView(await new Blob([key]).arrayBuffer())
            const byteKey = new Uint32Array([
              view.getUint32(0),
              view.getUint32(4),
              view.getUint32(8),
              view.getUint32(12)
            ])
            resolve(byteKey)
          })
          .catch((err) => {
            reject(err)
          })
      })
    }
    return this.keys[uri]
  }

  // 解密Ts
  DownloadM3u8.prototype.decryptTs = async function (segment, res) {
    const iv = segment.iv || 0
    const key = await this.getAESKey(segment.key)
    return new aesDecrypter.decrypt(res, key, iv)
  }

  // 合并ts
  DownloadM3u8.prototype.mergeAndDownload = async function () {
    // { type: 'application/octet-stream' }
    const type = this.options.outputMp4
      ? 'video/mp4; codecs="mp4a.40.2,avc1.64001f"'
      : 'video/MP2T'
    const ext = this.options.outputMp4 ? 'mp4' : 'ts'
    let blob
    // 最后进行转码
    if (this.options.outputMp4 && this.options.lastTranscoding) {
      blob = new Blob(
        this.segments.map((item) => item.byte),
        {
          type: 'video/MP2T'
        }
      )
      const data = await this.conversionMp4(
        new Uint8Array(await blob.arrayBuffer()),
        0
      )
      blob = new Blob([data], { type })
      console.log('转码完成,准备下载到本地')
    } else {
      blob = new Blob(
        this.segments.map((item) => item.byte),
        {
          type
        }
      )
      console.log('合并完成,准备下载到本地')
    }

    this.downloadBlob(blob, ext)
  }

  // 转码为mp4
  DownloadM3u8.prototype.conversionMp4 = function (data, index) {
    return new Promise((resolve) => {
      const transmuxer = new muxjs.Transmuxer({
        keepOriginalTimestamps: true,
        duration: parseInt(this.manifest.duration)
      })

      transmuxer.on('data', (segment) => {
        if (index === 0) {
          let data = new Uint8Array(
            segment.initSegment.byteLength + segment.data.byteLength
          )
          data.set(segment.initSegment, 0)
          data.set(segment.data, segment.initSegment.byteLength)
          resolve(data)
        } else {
          resolve(segment.data)
        }
        transmuxer.off('data')
        transmuxer = null
      })

      transmuxer.push(data)
      transmuxer.flush()
    })
  }

  // 下载blob
  DownloadM3u8.prototype.downloadBlob = function (blob, ext) {
    const downloadUrl = window.URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = downloadUrl
    a.target = '_blank'
    a.style.display = 'none'
    document.body.appendChild(a)
    a.download = [this.options.filename || Date.now(), ext].join('.')
    a.click()
    window.URL.revokeObjectURL(downloadUrl)
    document.body.removeChild(a)
    console.log('下载完成')
    if (typeof this.options.complete === 'function') {
      this.options.complete()
    }
  }
  window.DownloadM3u8 = DownloadM3u8
})(window)
发布于: 12/12/2022, 4:01:35 PM
最后更新: 12/3/2024, 4:42:28 AM
给我留言
访客留言
  • h

    hello

    5/28/2023, 3:40:07 PM

    回复
    感谢作者,但是我 await conversionMp4() 这个一直会等待,即使是短视频,可能是什么原因