如何使用js在浏览器下载m3u8并转码为mp4

摘要:

M3U(Moving Picture Experts Group Audio Layer 3 Uniform Resource Locator)这种文件格式是音视频文件的列表文件,是纯文本文件。你下载下来打开它,播放软件并不是播放它,而是根据它的记录找到网络地址进行在线播放。m3u8就是以utf-8编码的m3u8文件。

m3u8文件介绍

M3U(Moving Picture Experts Group Audio Layer 3 Uniform Resource Locator)这种文件格式是音视频文件的列表文件,是纯文本文件。你下载下来打开它,播放软件并不是播放它,而是根据它的记录找到网络地址进行在线播放。m3u8就是以utf-8编码的m3u文件

打开m3u8文件可以看到像下面这样

bash 复制代码
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:17
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:NO
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:8080/encrypt.key",IV=0xf77471e0eab675665bd5a4276ec2f035
#EXTINF:16.683333,
demo0.ts
#EXTINF:8.341667,
demo1.ts
#EXTINF:8.341667,
demo2.ts

也有一些包含各种分辨率下的m3u8文件,如下文件:包含240-1080不同分辨率的m3u8文件。(在播放时,播放器会根据当前网络进行调整。)

bash 复制代码
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2149280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
url_0/193039199_mp4_h264_aac_hd_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=246440,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="240"
url_2/193039199_mp4_h264_aac_ld_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=460560,CODECS="mp4a.40.5,avc1.420016",RESOLUTION=512x288,NAME="380"
url_4/193039199_mp4_h264_aac_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x480,NAME="480"
url_6/193039199_mp4_h264_aac_hq_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6221600,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,NAME="1080"
url_8/193039199_mp4_h264_aac_fhd_7.m3u8

每个字段含义如下:

bash 复制代码
#EXTM3U //必需,表示一个扩展的m3u文件
#EXT-X-VERSION:3 //hls的协议版本号,暗示媒体流的兼容性
#EXT-X-MEDIA-SEQUENCE:xx //首个分段的sequence number
#EXT-X-ALLOW-CACHE:NO //是否缓存
#EXT-X-TARGETDURATION:17 //每个视频分段最大的时长(单位秒)
#EXT-X-DISCONTINUITY //表示换编码
#EXT-X-KEY: 加密信息,没加密的就没有,也有些没有iv的
#EXTINF:<duration> //每个切片的时长
#EXT-X-STREAM-INF: 扩展流

解析m3u8文件

使用m3u8-parser库对文件进行解析成json,方便使用。

bash 复制代码
npm i m3u8Parser
ts 复制代码
import * as m3u8Parser from 'm3u8-parser'

const response = await fetch(this.url)
const content = await response.text()
const parser = new m3u8Parser.Parser()
parser.push(content)
parser.end()
console.log(parser.manifest.segments)

计算视频总时长

ts 复制代码
const duration = parser.manifest.segments.reduce((prev, cur) => prev + cur.duration, 0)

下载ts流

ts 复制代码
const response = await fetch(segment.uri)
const data = new Uint8Array(await response.arrayBuffer())

对加密的流进行解密

使用aes-decrypter库对流进行解密。

bash 复制代码
npm i aes-decrypter
ts 复制代码
import * as aesDecrypter from 'aes-decrypter'

const iv = segment.key.iv || new Uint32Array([0, 0, 0, 0])
const res = await fetch(segment.key.uri)
const buffer = await res.arrayBuffer()
const view = new DataView(buffer)
const key = new Uint32Array([
  view.getUint32(0),
  view.getUint32(4),
  view.getUint32(8),
  view.getUint32(12)
])
const data = new aesDecrypter.decrypt(data, key, iv)

合并视频流

ts 复制代码
// 合并视频流,如果不需要转换mp4,则直接将此结果使用Blob进行下载即可
const datas = segments.filter((segment) => segment.data).map((segment) => segment.data)

转码mp4

使用mux.js对视频进行转码,由于官方版本存在问题(转换后的视频时长不对),且一直未修复,所以使用大佬修复版,地址:https://github.com/Momo707577045/mux.js/tree/main

文件地址:https://github.com/Momo707577045/mux.js/blob/main/dist/mux-mp4.min.js

ts 复制代码
if (!window.muxjs) await import('mux-mp4.min.js')
const buffer = new Uint8Array(await new Blob(datas).arrayBuffer())
const transmuxer = new window.muxjs.Transmuxer({
  keepOriginalTimestamps: true,
  duration
})
transmuxer.on(
  'data',
  (segment: { initSegment: Uint8Array; data: Uint8Array }) => {
    // 转换后的数据,后面使用Blob进行下载
    datas = [segment.initSegment, segment.data]
  }
)
transmuxer.push(buffer)
transmuxer.flush()

执行下载

ts 复制代码
// 下载MP4
const url = new Blob(mp4Segments, { type: 'video/mp4' })
// 下载ts
// const url = new Blob(validSegments, { type: 'video/mp2t' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${Date.now()}.mp4`
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(a.href)
document.body.removeChild(a)

完整代码

ts 复制代码
import * as m3u8Parser from 'm3u8-parser'
import * as aesDecrypter from 'aes-decrypter'

export interface M3U8DownloaderOptions {
  // m3u8地址
  url?: string
  // 所有分片全部下载成功后自动下载(默认true)
  autoDownload?: boolean
  // 下载时的文件名称
  filename?: string
  // 失败重试次数
  retryNum?: number
  // 排除流,字符串或正则 数组
  excludes?: (string | RegExp)[]
  // 输出MP4格式(默认true)
  outputMp4?: boolean
  // 最大请求并行数
  maxParallelNum?: number
  // 打印日志
  log?: boolean
  // 解析完成回调
  onParsed?: (segments: TSegmentType[]) => void
  // 数据更新回调
  onUpdated?: (
    item: TSegmentType,
    index: number,
    segments: TSegmentType[]
  ) => void
  // 下载完成回调
  onDownloaded?: (blob: Blob) => void
}

export type TSegmentStatusType = 'waiting' | 'pending' | 'success' | 'error'

export type TSegmentKeyType = {
  method: string
  uri: string
  iv: string
}

export type TSegmentType = {
  uri: string
  duration: number
  title: string
  timeline: number
  key?: TSegmentKeyType
  data: Uint8Array
  status: TSegmentStatusType
}

// 常量配置
const DEFAULT_CONFIG = {
  AUTO_DOWNLOAD: true,
  RETRY_NUM: 3,
  OUTPUT_MP4: true,
  FILENAME: Date.now().toString(),
  MAX_PARALLEL_NUM: 5,
  LOG: false
} as const

// 错误类型
type TErrorCode =
  | 'INVALID_URL'
  | 'FETCH_ERROR'
  | 'PARSE_ERROR'
  | 'DOWNLOAD_ERROR'
  | 'MERGE_ERROR'

class M3U8DownloaderError extends Error {
  constructor(message: string, public readonly code: TErrorCode) {
    super(message)
    this.name = 'M3U8DownloaderError'
  }
}

export class M3U8Downloader {
  private url: string
  private readonly autoDownload: boolean
  private readonly filename: string
  private readonly retryNum: number
  private readonly excludes?: (string | RegExp)[]
  private readonly outputMp4: boolean
  private readonly maxParallelNum: number
  private readonly log: boolean
  private readonly allAesKeys: Map<string, Promise<Uint32Array>> = new Map()
  private readonly onUpdated?: M3U8DownloaderOptions['onUpdated']
  private readonly onParsed?: M3U8DownloaderOptions['onParsed']
  private readonly onDownloaded?: M3U8DownloaderOptions['onDownloaded']
  private segments: TSegmentType[] = []
  private duration: number = 0
  public status: TSegmentStatusType = 'waiting'
  private readonly controller: AbortController

  constructor(options: M3U8DownloaderOptions) {
    const {
      url = '',
      autoDownload = DEFAULT_CONFIG.AUTO_DOWNLOAD,
      filename = DEFAULT_CONFIG.FILENAME,
      retryNum = DEFAULT_CONFIG.RETRY_NUM,
      excludes,
      outputMp4 = DEFAULT_CONFIG.OUTPUT_MP4,
      maxParallelNum = DEFAULT_CONFIG.MAX_PARALLEL_NUM,
      log = DEFAULT_CONFIG.LOG,
      onUpdated,
      onParsed,
      onDownloaded
    } = options

    this.url = url
    this.autoDownload = autoDownload
    this.filename = filename
    this.retryNum = retryNum
    this.excludes = excludes
    this.outputMp4 = outputMp4
    this.maxParallelNum = maxParallelNum
    this.log = log
    this.onUpdated = onUpdated
    this.onParsed = onParsed
    this.onDownloaded = onDownloaded
    this.controller = new AbortController()
  }

  // 开始下载
  async start() {
    this.status = 'pending'
    this.segments = []
    await this.parserM3u8()
    await this.startDownloadAllTs()
  }

  // 设置m3u8地址
  setUrl(url: string) {
    this.url = url
  }

  // 解析m3u8文件
  async parserM3u8() {
    if (!this.url?.trim()?.endsWith('.m3u8')) {
      throw new M3U8DownloaderError('无效的m3u8地址!', 'INVALID_URL')
    }
    if (this.log) console.log('开始请求并解析m3u8内容...')
    try {
      const response = await fetch(this.url)
      if (!response.ok) {
        throw new M3U8DownloaderError(
          `请求未能获取m3u8内容: ${response.status}`,
          'FETCH_ERROR'
        )
      }
      const content = await response.text()
      const parser = new m3u8Parser.Parser()
      parser.push(content)
      parser.end()
      this.segments = this.filterSegments(parser.manifest.segments)
      this.onParsed?.(this.segments)
      if (this.log) console.log('M3U8解析完成!', this.segments)
    } catch (error) {
      this.status = 'error'
      throw new M3U8DownloaderError(
        `请求并解析m3u8失败: ${(error as M3U8DownloaderError).message}`,
        'PARSE_ERROR'
      )
    }
  }

  // 过滤排除的segment
  filterSegments(segments: TSegmentType[]) {
    if (!segments.length) return segments
    return segments.flatMap((item) => {
      const url = new URL(item.uri, this.url).href
      if (this.excludes?.length) {
        for (let i = 0; i < this.excludes.length; i++) {
          const item = this.excludes[i]
          if (typeof item === 'string' && url.includes(item)) {
            return []
          } else if (item instanceof RegExp && item.test(url)) {
            return []
          }
        }
      }
      return {
        ...item,
        uri: url,
        status: 'waiting' as TSegmentStatusType
      }
    })
  }

  // 计算视频总时长
  calculateDuration() {
    let duration = 0
    if (this.segments?.length) {
      duration = this.segments.reduce((prev, cur) => prev + cur.duration, 0)
    }
    this.duration = duration
    return this.duration
  }

  // 获取aesKey
  async getAESKey(uri: string) {
    const url = new URL(uri, this.url).href
    let promise = this.allAesKeys.get(url)
    if (!promise) {
      promise = (async () => {
        const response = await fetch(url)
        const buffer = await response.arrayBuffer()
        const view = new DataView(buffer)
        return new Uint32Array([
          view.getUint32(0),
          view.getUint32(4),
          view.getUint32(8),
          view.getUint32(12)
        ])
      })()
      this.allAesKeys.set(url, promise)
    }
    return promise
  }

  // 解密Ts
  async decryptTs(
    data: Uint8Array,
    segmentKey: TSegmentKeyType
  ): Promise<Uint8Array> {
    const iv = segmentKey.iv || new Uint32Array([0, 0, 0, 0])
    const key = await this.getAESKey(segmentKey.uri)
    return new aesDecrypter.decrypt(data, key, iv)
  }

  // 下载指定下标的ts文件段
  async downloadTsByIndex(index: number) {
    const segment = this.segments[index]
    return this.downloadTs(segment, index)
  }

  /**
   * 下载ts文件
   * @param segment segment
   * @returns
   */
  async downloadTs(segment: TSegmentType, index: number) {
    const progress = `${index + 1}/${this.segments.length}`
    if (this.log) console.log(`${progress}:开始下载片段 ${segment.uri}`)
    const data = await this.downloadTsAndErrorRetry(
      segment,
      index,
      this.retryNum
    )
    if (this.log) {
      console.log(`%c${progress}:片段下载完成 ${segment.uri}`, 'color:green')
    }
    return data
  }

  /**
   * 下载ts文件,如果失败则重试
   * @param segment segment
   * @param index 当前下标
   * @param retryCount 重试次数
   */
  async downloadTsAndErrorRetry(
    segment: TSegmentType,
    index: number,
    retryCount: number
  ): Promise<Uint8Array> {
    segment.status = 'pending'
    try {
      const response = await fetch(segment.uri, {
        signal: this.controller.signal,
        headers: {
          Accept: 'video/MP2T,video/mp2t,application/octet-stream'
        }
      })

      if (!response.ok) {
        throw new M3U8DownloaderError(
          `片段下载失败: ${response.status}`,
          'DOWNLOAD_ERROR'
        )
      }

      let data = new Uint8Array(await response.arrayBuffer())
      if (segment.key) {
        data = await this.decryptTs(data, segment.key)
      }
      segment.status = 'success'
      segment.data = data
      if (typeof this.onUpdated === 'function') {
        this.onUpdated(segment, index, this.segments)
      }
      return data
    } catch (e) {
      if (retryCount > 0) {
        return await this.downloadTsAndErrorRetry(
          segment,
          index,
          retryCount - 1
        )
      } else {
        if (this.log) {
          const progress = `${index + 1}/${this.segments.length}`
          console.log(
            `%c${progress}:片段下载失败 ${segment.uri}。`,
            'color:red'
          )
        }
        segment.status = 'error'
        if (typeof this.onUpdated === 'function') {
          this.onUpdated(segment, index, this.segments)
        }
        throw e
      }
    }
  }

  /**
   * 队列执行函数
   * @param asyncFunction 异步函数
   * @param params  参数数组
   * @param maxConcurrent  最大并发数
   * @returns 结果数组
   */
  async executeAsyncFunctionInQueue<T, K>(
    asyncFunction: (item: K, index: number) => Promise<T>,
    items: K[],
    maxConcurrent: number = 10
  ): Promise<Array<T | Error>> {
    const queue = [...items]
    const results: Array<T | Error> = new Array(items.length)
    const executing = new Set<Promise<void>>()

    const executeNext = async (): Promise<void> => {
      if (queue.length === 0) return

      const currentIndex = items.length - queue.length
      const item = queue.shift()!

      const promise = (async () => {
        try {
          const result = await asyncFunction.call(this, item, currentIndex)
          results[currentIndex] = result
        } catch (error) {
          results[currentIndex] =
            error instanceof Error ? error : new Error(String(error))
        }
      })()

      executing.add(promise)
      await promise
      executing.delete(promise)

      if (queue.length > 0) {
        await executeNext()
      }
    }

    const workers = Array.from(
      { length: Math.min(maxConcurrent, queue.length) },
      () => executeNext()
    )

    await Promise.all(workers)
    return results
  }

  // 开始下载全部ts文件
  async startDownloadAllTs() {
    if (this.log) console.log('开始下载全部ts文件')
    await this.executeAsyncFunctionInQueue(
      this.downloadTs,
      this.segments,
      this.maxParallelNum
    )
    const isError = this.segments.some((segment) => !segment.data)
    if (this.log)
      console.log(`全部ts文件下载完成,${isError ? '有错误' : '无错误'}`)
    if (!isError && this.autoDownload) {
      this.download()
    } else {
      this.status = 'error'
      return {
        isError,
        segments: this.segments
      }
    }
  }

  // 合并所有ts文件
  async mergeSegments(): Promise<Blob> {
    if (this.log) console.log('开始合并片段...', this.segments)

    const validSegments = this.segments
      .filter((segment) => segment.status === 'success' && segment.data)
      .map((segment) => segment.data)

    if (validSegments.length === 0) {
      throw new M3U8DownloaderError('没有有效的片段合并', 'MERGE_ERROR')
    }

    if (this.outputMp4) {
      const mp4Segments = await this.transcodeToMp4ByMux(validSegments)
      return new Blob(mp4Segments, { type: 'video/mp4' })
    }

    return new Blob(validSegments, { type: 'video/mp2t' })
  }

  // 使用mux.js转码为mp4
  async transcodeToMp4ByMux(segments?: Uint8Array[]): Promise<Uint8Array[]> {
    if (this.log) console.log('开始转码MP4')
    if (!window.muxjs) await import('./../libs/mux-mp4.min.js')
    if (!segments) {
      segments = this.segments
        .filter((segment) => segment.status === 'success' && segment.data)
        .map((segment) => segment.data)
    }
    const buffer = new Uint8Array(await new Blob(segments).arrayBuffer())
    return new Promise((resolve) => {
      const duration = this.calculateDuration()
      const transmuxer = new window.muxjs.Transmuxer({
        keepOriginalTimestamps: true,
        duration
      })
      transmuxer.on(
        'data',
        (segment: { initSegment: Uint8Array; data: Uint8Array }) => {
          resolve([segment.initSegment, segment.data])
        }
      )
      transmuxer.push(buffer)
      transmuxer.flush()
    })
  }

  // 下载最终文件
  async download() {
    const blob = await this.mergeSegments()
    if (typeof this.onDownloaded === 'function') this.onDownloaded(blob)
    if (this.log) console.log('数据准备完成,开始下载')
    this.saveAs(blob)
    this.status = 'success'
  }

  // 保存文件
  saveAs(blob: Blob) {
    const a = document.createElement('a')
    a.href = URL.createObjectURL(blob)
    a.download = `${this.filename}.${this.outputMp4 ? 'mp4' : 'ts'}`
    document.body.appendChild(a)
    a.click()
    URL.revokeObjectURL(a.href)
    document.body.removeChild(a)
  }

  // 取消下载
  abort(): void {
    this.controller.abort()
  }
}

完整代码使用方式

tsx 复制代码
import { useState } from 'react'
import './App.css'
import { TSegmentType, M3U8Downloader } from './utils/m3u8-downloader'

function App() {
  const [url, setUrl] = useState('')
  const [segments, setSegments] = useState<TSegmentType[]>([])

  const downloader = new M3U8Downloader({
    log: true,
    // outputMp4: false,
    onParsed(segments) {
      setSegments(segments.map(item => ({...item})))
    },
    onUpdated(item, index, segments) {
      setSegments(() => {
        return segments.map(item => ({...item}))
      })
    },
  })

  // 点击开始下载
  function startDownload() {
    if(!url.trim()) {
      return alert('请输入m3u8地址')
    }
    // 设置m3u8地址
    downloader.setUrl(url)
    // 开始下载
    downloader.start()
  }

  // 点击失败的片段进行重新下载
  function handleErrorRetry(index: number) {
    if(segments[index].status !== 'success') {
      downloader.downloadTsByIndex(index)
    }
  }

  // 手动下载,一般用于下载失败后未自动下载,将失败的片段重新下载完成后使用
  function handleDownload() {
    if(!segments.length) {
      return alert('没有可以下载的片段!')
    }
    downloader.download()
  }

  return (
    <div className='box'>
      <div className='input'>
        <input type="text" placeholder='请输入m3u8地址' value={url} onChange={(e) => setUrl(e.target.value)}/>
        <button onClick={startDownload}>下载</button>
        <button onClick={handleDownload}>强制下载</button>
      </div>
      <div className='segments'>
        {
          segments.map((item, index) => (
            <div key={item.uri} className={
              `segment ${item.status}`
            } onClick={() => handleErrorRetry(index)}>
              <span>{index+1}</span>
            </div>
          ))
        }
      </div>
    </div>
  )
}

export default App

效果

不足

  1. 下载的文件偏大
  2. 使用window自带的播放器,拖动进度时卡顿。(文件越大,卡顿时间越长)
  3. 因下载的流都存在内存中,大文件下载可能失败。

推荐

还是直接使用ffmpeg进行下载吧,简单好用。

bash 复制代码
ffmpeg -i https://a.test.com/test.m3u8 output.mp4

评论

0条评论

logo

暂无内容,去看看其他的吧~