拿到一个 m3u8
文件后,该怎么下载内容?反正我第一时间想到的是 FFmpeg
……
最近在拆分 Twitter Monitor 的媒体下载页面,这里面其中一个功能就是下载 Spaces 的音频,我并不想用 FFmpeg 这种重量级工具,并且 ffmpeg.js 还是有不少限制的,于是我开始在网上寻找有没有纯 JavaScript
的方案,结果还真找到了:HLS中的AAC如何合并
这个AAC是由ID3标签和ADTS组成的格式。
ADTS是AAC的一种编码方式,与另一种编码方式ADIF不同,ADTS可以在任意帧解码。因此我们只需要把AAC中的ID3标签去掉,然后在拼接起来就能得到正常的AAC文件了。
实际上 FFmpeg 就是这样干的,直接删掉所有的 ID3 标签然后合并所有的块,那么就很容易得到第一种方案:
删掉全部 ID3 标签
简单粗暴,不用管内容有什么,直接全删了。
适配 ArrayBuffer
写原型时我用了 Node.js
,自然习惯性地用 Buffer
,但浏览器没这玩意,所以得想办法处理:
// Like Buffer.indexOf('494433', cursor, "hex") // ID3
const arrayBufferUint8HexIndexOf = (content, find = [], cursor = 0) => {
if (find.length < 0 || find.length > content.byteLength || cursor > content.byteLength) {
return -1
}
while (true) {
const firstWord = content.indexOf(find[0], cursor)
if (firstWord === -1) { return -1 }
else if (!find.slice(1).some((findItem, index) => content.indexOf(findItem, cursor) !== (firstWord + index + 1))) {
return firstWord
} else if (cursor > content.byteLength) {
return -1
} else {
cursor++
}
}
}
function toDataView(buffer) {
const arrayBuffer = new ArrayBuffer(buffer.length);
const view = new Uint8Array(arrayBuffer);
for (let i = 0; i < buffer.length; ++i) {
view[i] = buffer[i];
}
return new DataView(arrayBuffer);
}
Buffer
一行解决的事情到这边就要写一大串,特麻烦。然后就是挨个处理。
const bufferList = []
for (const index in fileList) { // fileList: ArrayBuffer[] | Buffer[]
const file = fileList[index]
let tmpBuffer = toDataView(file)
let cursor = 0
let offset = 0
while (true) {
cursor = arrayBufferUint8HexIndexOf(new Uint8Array(tmpBuffer.buffer), [0x49, 0x44, 0x33], cursor)//tmpBuffer.indexOf('494433', cursor, "hex")
if (cursor === -1) {
bufferList.push(tmpBuffer.buffer.slice(offset))
break
} else if (cursor > offset) {
bufferList.push(tmpBuffer.buffer.slice(offset, cursor))
}
let size = 0
for (let sizeOffset = 0; sizeOffset < 4; sizeOffset++) {
size = (size << 7) + new Uint8Array(tmpBuffer.buffer.slice(cursor + 6 + sizeOffset, cursor + 7 + sizeOffset))[0]
}
// wrong id3
if (size < 0 || size > tmpBuffer.byteLength) {
offset = cursor
cursor++
continue
}
size += 10
offset = cursor += size
//--> 只删第一个 ID3 标签就取消注释这两行 <--
//bufferList.push(tmpBuffer.buffer.slice(offset))
//break
}
}
const bufferByteLength = bufferList.reduce((a, b) => a + b.byteLength, 0)
const aacArrayBuffer = bufferList.reduce((a, b) => {
a[0].set(new Uint8Array(b), a[1])
return [a[0], a[1] + b.byteLength || 0]
}, [new Uint8Array(bufferByteLength), 0])[0]
aacArrayBuffer
就是最终的内容了,跟 FFmpeg
生成的一模一样
名称: test_pure_js.aac
大小: 51613989 字节 (49 MiB)
SHA1: 5ee78601527b263cfdf79dfe32e1abf76cb81b49
名称: test_ffmpeg.aac
大小: 51613989 字节 (49 MiB)
SHA1: 5ee78601527b263cfdf79dfe32e1abf76cb81b49
只删第一个 ID3 标签
实际上只有第一个 ID3
标签因为记录了时间信息(准确点来说应该是帧信息?com.apple.streaming.transportStreamTimestamp
)必须删掉,其他标签都是不是必须删掉的,所以只需要删掉第一个 ID3
标签就能在正常播放,这样还能同时节省时间和资源,做法也很简单,就是把上面的脚本有标记的那处注释去掉。
取得帧内容
Spaces 会在往帧里塞音频信息和发言者的信息,所以还需要提取内容。
经过前期的研究,Spaces的文件只会有三种帧类型:PRIV
、TIT3
、TXXX
,而 TXXX
类型只会是 UTF-8
, 因此只需要处理这三种标签类型。如果要提取其他音频流的 ID3
标签可能还需要处理其他类型的标签
const id3FrameParser = (content) => {
if (content.byteLength <= 0) { return [] }
const realContent = content.slice(10)
let offset = 0
const frameList = []
while (offset <= realContent.byteLength) {
const type = [...(new Uint8Array(realContent.slice(offset, offset + 4)))].map(x => String.fromCharCode(x)).join('')
const size = [...(new Uint8Array(realContent.slice(offset + 4, offset + 8)))].reduce((a, b) => (a << 7) + b, 0)
const flags = realContent.slice(offset + 8, offset + 10)
const encodeType = new Uint8Array(realContent)[10]
const isText = ['TXXX', 'TIT3'].includes(type)
const tmpFrameContent = realContent.slice(offset + 10 + (isText !== false ? 1 : 0), offset + 10 + size)
//PRIV->ArrayBuffer, TIT3->String, TXXX->String|Object
const frameContent = isText ? [...(new Uint8Array(tmpFrameContent))].map(x => String.fromCharCode(x)).join('').slice(0, -1) : tmpFrameContent
if (type) {
if (['TXXX'].includes(type)) {
let tmpFrameContent = frameContent.split("\x00")
try {
tmpFrameContent[1] = JSON.parse(tmpFrameContent[1])
} catch (e) { }
frameList.push([type, size, flags, tmpFrameContent])
} else {
frameList.push([type, size, flags, frameContent])
}
}
offset += (10 + size)
}
return frameList
}
其他
最初的时候我合并了半天都没成功,最后检查发现几个月前研究时我是以字符串写入文件的,于是所有二进制内容都受到了破坏……看到一串的 EF BF BD
时属实是无语了……
你问我下载页面去哪了?不好意思目前进度还没新建文件夹……
体验地址:https://twmedia.nest.moe/,输入框填 Space 链接或者 ID 即可
写文章时精神我的状态极差……以至于用着支离破碎的语言在写不知道什么玩意