如何使用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
效果

不足
- 下载的文件偏大
- 使用window自带的播放器,拖动进度时卡顿。(文件越大,卡顿时间越长)
- 因下载的流都存在内存中,大文件下载可能失败。
推荐
还是直接使用ffmpeg进行下载吧,简单好用。
bash
ffmpeg -i https://a.test.com/test.m3u8 output.mp4
评论
0条评论
暂无内容,去看看其他的吧~