您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
展示哔哩哔哩直播“预估视频片段码率/服务端回报视频码率”信息。查看方法:右键播放区域,点击“视频统计信息”。
当前为
// ==UserScript== // @name 哔哩哔哩直播 码率 // @namespace https://github.com/PaperStrike // @version 2024-01-07 // @description 展示哔哩哔哩直播“预估视频片段码率/服务端回报视频码率”信息。查看方法:右键播放区域,点击“视频统计信息”。 // @author sliphua // @match https://live.bilibili.com/* // @icon https://live.bilibili.com/favicon.ico // @run-at document-start // @sandbox JavaScript // @grant unsafeWindow // @grant GM.notification // ==/UserScript== (() => { class SpyRates { // 最新 m3u8 片段的码率 frameKbps = 0; // 最新 flv 视频片段的码率 packetKbps = 0; // 服务端回报的音频码率 audioKbps = 0; // 服务端回报的视频码率 videoKbps = 0; /** * 当前直播流解析模式 * @type {'m3u8'|'flv'|null} */ mode = null; // 当前直播流是否被正确解析 supported = true; /** * @param {Response} response */ async parseM3u8(response) { const content = await response.clone().text(); // Example: // #EXT-BILI-AUX:be4a52|K|106967|eca94dad // #EXTINF:1.00,106967|eca94dad const durationSizeRegex = /#EXTINF:([0-9.]+),([0-9a-f]+)/g; let size = 0; let duration = 0; // usually ~5s for (const [, durationStr, sizeStr] of content.matchAll(durationSizeRegex)) { size += Number.parseInt(sizeStr, 16); duration += Number(durationStr); } if (size && duration) { this.supported = true; this.frameKbps = 8 * size / duration / 1e3; } else { this.supported = false; this.frameKbps = 0; } } /** * @param {Response} response */ async parseFlv(response) { if (!response.body) { this.supported = false; return; } const read = this.spyResponseBodyReader(response); let buffer = new Uint8Array(0); let size = 0; let lastTimestamp = 0; while (true) { const [nextBuffer, packetSize, packetTimestamp] = await this.parseFlvPacket(read, buffer); if (!nextBuffer) break; buffer = nextBuffer; size += packetSize; const durationMs = packetTimestamp - lastTimestamp; if (size && durationMs >= 5000) { this.supported = true; this.packetKbps = 8 * size / durationMs; size = 0; lastTimestamp = packetTimestamp; } } this.supported = false; } /** * @param {() => Promise<BodyReadResult>} read * @param {Uint8Array} buffer * @returns {Promise<[Uint8Array|null, size: number, timestamp: number]>} */ async parseFlvPacket(read, buffer) { while (buffer.byteLength < 11) { const { value, done } = await read(); if (done) { return [null, 0, 0]; } buffer = new Uint8Array([...buffer, ...value]); } const [flags, size0, size1, size2, t0, t1, t2, tE, id0] = buffer; const dataSize = (size0 << 16) | (size1 << 8) | size2; // FLV header if (dataSize === 0x4c5601 && flags === 0x46) { const size = (t1 << 24) | (t2 << 16) | (tE << 8) | id0; const totalSize = size + 4; const nextBuffer = await this.skipBytes(read, buffer, totalSize); return [nextBuffer, 0, 0]; } if (dataSize > 0xc00000) { throw new Error(`FLV packet data too large: 0x${dataSize.toString(16)}`); } const totalSize = 11 + dataSize + 4; const nextBuffer = await this.skipBytes(read, buffer, totalSize); const type = flags & 0b11111; if (type === 0x09) { const timestamp = (t0 << 16) | (t1 << 8) | t2 | (tE << 24); // in ms return [nextBuffer, dataSize, timestamp]; } return [nextBuffer, 0, 0]; } /** * @param {() => Promise<BodyReadResult>} read * @param {Uint8Array} buffer * @param {number} count * @returns {Promise<Uint8Array|null>} */ async skipBytes(read, buffer, count) { let size = buffer.byteLength; if (size >= count) return buffer.subarray(count); while (true) { const { value, done } = await read(); if (done) return null; const nextSize = size + value.byteLength; if (nextSize >= count) { return value.subarray(count - size); } size = nextSize; } } /** * Spy the response body reader instead of cloning the response. * There's no API to sync the lifecycle with the cloned stream. * Also, spying uses less memory. * @param {Response} response * @returns {() => Promise<BodyReadResult>} */ spyResponseBodyReader(response) { if (!response.body) { throw new Error('Response body is null'); } /** @type {BodyReadResult[]} */ const pendingResults = []; /** @type {((value: BodyReadResult) => void)|null} */ let pendingResolve = null; response.body.getReader = new Proxy(response.body.getReader, { apply(target, thisArg, args) { /** @type {BodyReader} */ const reader = Reflect.apply(target, thisArg, args); reader.read = new Proxy(reader.read, { async apply(target, thisArg, args) { /** @type {BodyReadResult} */ const result = await Reflect.apply(target, thisArg, args); if (pendingResolve) { pendingResolve(result); pendingResolve = null; } else { pendingResults.push(result); } return result; }, }); return reader; }, }); return () => new Promise((resolve) => { const result = pendingResults.shift(); if (result) { resolve(result); } else { pendingResolve = resolve; } }); } /** @type {VideoPanel|null} */ panel = null; // for debugging /** @type {StreamInfo|null} */ streamInfo = null; // for debugging /** @type {unknown} */ lastError = null; /** * @param {VideoPanel} panel */ constructor(panel) { this.panel = panel; // for debugging // Spy frameKbps unsafeWindow.fetch = new Proxy(unsafeWindow.fetch, { apply: (target, thisArg, args) => { /** @type {Promise<Response>} */ const result = Reflect.apply(target, thisArg, args); // */live-bvc/{num}/live_{word}(/{word})?.(m3u8|flv)* // Examples: // https://xy221x11x101x198xy.mcdn.bilivideo.cn:486/live-bvc/645043/live_7263131_8977223_minihevc/index.m3u8 // https://xy221x11x101x198xy.mcdn.bilivideo.cn:486/live-bvc/645043/live_7263131_8977223_prohevc/index.m3u8 // https://xy221x11x101x198xy.mcdn.bilivideo.cn:486/live-bvc/645043/live_7263131_8977223/index.m3u8 // https://xy221x11x101x197xy.mcdn.bilivideo.cn:486/live-bvc/238242/live_22259479_ab_745908762254479374_bluray/index.m3u8 // https://xy221x11x101x198xy.mcdn.bilivideo.cn:486/live-bvc/645043/live_7263131_8977223.flv const streamUrlRegex = /\/live-bvc\/\d+\/live_\w+(?:\/\w+)?\.(m3u8|flv)/; const match = streamUrlRegex.exec(args[0]); if (match) { const [, ext] = match; result .then((response) => { if (!response.ok) return; if (ext === 'm3u8') { return this.parseM3u8(response); } else if (ext === 'flv') { return this.parseFlv(response); } }) .catch((e) => { this.supported = false; this.lastError = e; }); } return result; } }); // Spy audioKbps / videoKbps panel.updateVideoTemplate = new Proxy(panel.updateVideoTemplate, { apply: (target, thisArg, args) => { /** @type {StreamInfo} */ const streamInfo = args[0]; this.streamInfo = streamInfo; // for debugging if (streamInfo?.mediaInfo) { const { audioDataRate, videoDataRate, videoSrc } = streamInfo.mediaInfo; this.audioKbps = audioDataRate; this.videoKbps = videoDataRate; if (videoSrc.includes('.m3u8')) { this.mode = 'm3u8'; } else if (videoSrc.includes('.flv')) { this.mode = 'flv'; } else { this.mode = null; } } return Reflect.apply(target, thisArg, args); }, }); // Print to video panel panel.createTemplateProxy = new Proxy(panel.createTemplateProxy, { /** * @param {VideoPanel} thisArg */ apply: (target, thisArg, args) => { /** @type {VideoTemplate} */ const result = Reflect.apply(target, thisArg, args); return new Proxy(result, { set: (propTarget, property, value, receiver) => { if (property === 'videoInfo' && value) { const reported = thisArg.computeBps(this.videoKbps); if (this.mode && this.supported) { // 预估视频片段码率 let estimated; if (this.mode === 'm3u8') { // m3u8:最新片段码率 - 服务端回报音频码率 estimated = thisArg.computeBps(this.frameKbps - this.audioKbps); } else { // flv:最新视频片段码率 estimated = thisArg.computeBps(this.packetKbps); } value = `${value}, ${estimated}/${reported}`; } else { value = `${value}, ${reported}`; } } return Reflect.set(propTarget, property, value, receiver); }, }); }, }); } } // Hunt WeakMap for the video panel const originalWeakMapSet = WeakMap.prototype.set; WeakMap.prototype.set = new Proxy(originalWeakMapSet, { apply(target, thisArg, args) { const [candidate] = args; if (candidate !== null && typeof candidate === 'object' && 'updateVideoTemplate' in candidate && 'createTemplateProxy' in candidate) { // Restore WeakMap hack WeakMap.prototype.set = originalWeakMapSet; // Expose the spy to window for debug info unsafeWindow.debugSpyRates = new SpyRates(candidate); } return Reflect.apply(target, thisArg, args); }, }); })();