// ==UserScript== // @name B 站并发取流 // @namespace https://github.com/local/bilibili-multithread // @version 2.0.0 // @description 自己向 B 站请求播放地址并锁定 CDN host,把播放器每次 Range 请求按 256KB 切片并发取回 // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/bangumi/play/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // ==/UserScript== /* 模型(每个事实只有一个家,依赖单向:上层可以叫下层,下层不许回头叫上层) * * 1 常量 切片/尾片尺寸、地址表的键、参考实现的两条 CDN 正则、CDN 候选表、存储键 * 2 settings 三项配置(开关 / 最大并发数 / CDN 地址)的唯一持有处 * 3 report 单一前缀日志 + 汇总读数(页面零常驻 UI) * 4 context 播放器上下文:window.player.getManifest() * 5 cdnUrl 地址锁定:候选列表进、最终地址出 * 6 addr 自请求 playurl → 地址表 path → 候选地址列表 + 签名 deadline;顺带留一条镜像地址当测速基准 * (表里存候选列表、锁定放到取用时做,所以改 CDN 设置下一个请求就换 host; * 一个 cid|清晰度 只取一次址、失败也落空表,过期靠 deadline 判 —— 都不靠「请求失败了再猜」) * 7 grab 切片规划 + 全局信号量并发取流 * 7b probe 候选 CDN 串行测速:一次请求出吞吐 + 首包延迟 * 8 player 包装页面 XMLHttpRequest 原型,接管播放器的媒体请求 * 8b picker 候选列表 + 测速结果的 Shadow DOM 浮层(点菜单才建,选完就摘) * 8c view 播放器自己的「当前地址」视图(右键 →「视频统计信息」里的 Video/Audio Host) * 9 boot 三项菜单 + 安装接管 * * 三条不变量 * · 地址只有一个出处:addr;锁定只有一个函数:cdnUrl。别处不许拼地址、不许换 host。 * · CDN host 与并发数每次请求现读配置对象 —— 改完设置下一个切片就生效,不用刷新或重播。 * · 一次 Range 的切片互不重叠、并集恰好等于该 Range。 * * 实测依据(Chrome 152 真页面实测,不是读源码推断) * · 播放器的媒体请求走 XMLHttpRequest、responseType=arraybuffer,只设一个 Range 头、闭区间 bytes=a-b, * 单次 0.9KB~3.9MB;媒体请求不走 fetch。播放器读的成员只有 readyState / status / statusText / * response / responseURL / getAllResponseHeaders()(不读 getResponseHeader),事件走 on* 属性 + * addEventListener('load'|'error'|'progress'|'abort')。 * · 跨源响应的 getAllResponseHeaders() 只暴露 content-length / content-type(mcdn 连 content-range 都不暴露)。 * · 伪造响应必须走完 readyState 2 → 3 → progress → 4:播放器的吞吐估算器只在 readyState=2 时打 * headersReceivedDate,跳过去它就会去读 p2pLoadInfo(镜像地址不是 PCDN、没这个字段)并抛错。 * · 换 host 有效、换 path 无效:同一 path+query 在 upos-sz-mirror{coso1,ali,hwo1} 上都是 206 且字节一致; * 而把 mcdn 的 /v1/resource/upgcxcode/… 改成 upos 主机的 /upgcxcode/… 会失败 —— 签名覆盖 path、不覆盖 host。 * · 测速必须 cache:'no-store':同一段 1.5MB 首次 125ms、之后 16ms,那是 HTTP 缓存命中,不是 CDN 变快。 * · 测速的吞吐按端到端算:小样本下「减掉首包」的稳态吞吐是噪声(同一 host 三次 250 / 113 / 83 MB/s), * 端到端吞吐稳定(9.8 / 10.4 / 10.5)。 * · 自请求取址:普通视频 GET /x/player/wbi/playurl(结果在 data.dash),番剧 GET /pgc/player/web/playurl * (结果在 result.dash,且只认 GET —— POST 形态在网页源上是 405/预检不过),都要带 cookie; * 返回的 dash 含全部清晰度(12~16 条视频轨 + 3 条音频轨),所以 qn 传播放器当前那个即可、不必自己挑。 * **实测不签名(无 w_rid/wts)也能 code 0**,所以这里不实现 WBI 的 MD5+mixin —— 真被风控挡了就 * 取址失败、退回放行原请求,播放照常,不会把播放拖死。 * · 播放器换清晰度不会再打 playurl(首屏内联数据已含全部清晰度),只是换一条轨,manifest().quality 会跟着变。 * · 右键 →「视频统计信息」面板里的 Video/Audio Host 是播放器**自己的视图**:它取 DashPlayer 的 * getCurrentSegmentInfoFor(轨).url(同一个类的 getCurrentPlayURLFor 是这份视图的另一个出口)。 * 面板与它读的都是播放器手里那份原地址,所以只锁请求的话这里会一直是旧 host。 * · 面板不读 window.player.getMediaInfo():包了 getMediaInfo 返回改过的 playUrl,面板纹丝不动(实测)。 * · 视图钩子必须挂在 DashPlayer 类的原型上,不能挂实例:getCurrentSegmentInfoFor 是实例自有属性 * (Object.getPrototypeOf(core.player) 上并没有它),而实例在换源时会重建。 */ (() => { 'use strict'; /* 页面世界的 window:脚本猫等管理器会把脚本放进沙箱,那里的 window 不是页面的 window。 * unsafeWindow 常是管理器包装函数的参数而不是全局属性,所以只有 typeof 能安全探它。 */ const pageWin = typeof unsafeWindow === 'undefined' ? window : unsafeWindow; if (pageWin.__bbmt) { return; } const api = pageWin.__bbmt = {}; /* ===================================================================== * 1. 常量 * ===================================================================== */ const K = { enabled: 'bbmt.enabled', cdn: 'bbmt.cdn', max: 'bbmt.max' }; const SLICE = 262144; // 切片单位 256KB const TAIL_MIN = 131072; // 尾片下限 128KB:不足就并进前一片 const MAX_CEIL = 64; // 并发数的合理上界(挡手滑) const KEY_MARK = '/upgcxcode/'; // 地址表的键从这里取:同一条轨的 mcdn / 镜像 / PCDN 地址只有前缀不同 const API = 'https://api.bilibili.com'; const PROXY_TF = 'proxy-tf-all-ws.bilivideo.com'; /* 两条正则照搬 PiliPlus lib/utils/video_utils.dart 的 VideoUtils(锁定 CDN 时用它挑地址)。 */ const MIRROR_RE = /^https?:\/\/(?:upos-\w+-(?!302)\w+|(?:upos|proxy)-tf-[^/]+)\.(?:bilivideo|akamaized)\.(?:com|net)\/upgcxcode/; const MCDN_TF_RE = /^https?:\/\/(?:(?:(?:\d{1,3}\.){3}\d{1,3}|[^/]+\.mcdn\.bilivideo\.(?:com|cn|net))(?::\d{1,5})?\/v\d\/resource)/; /* CDN 候选表:host + 标注,逐条照抄 PiliPlus lib/models/common/video/cdn_type.dart 的 CDNService * (它 19 个有 host 的成员;那两个 host 为 null 的档位 baseUrl / backupUrl 在本方案里就是「不换 host」一项)。 * 目标 host 不必自己匹配 MIRROR_RE —— 正则判的是被替换的源地址,目标是哪台都行,能不能服务 * /upgcxcode/… 由测速结果说话。 */ const CDN_LIST = [ ['upos-sz-mirrorali.bilivideo.com', 'ali(阿里云)'], ['upos-sz-mirroralib.bilivideo.com', 'alib(阿里云)'], ['upos-sz-mirroralio1.bilivideo.com', 'alio1(阿里云)'], ['upos-sz-mirrorcos.bilivideo.com', 'cos(腾讯云)'], ['upos-sz-mirrorcosb.bilivideo.com', 'cosb(腾讯云·VOD加速)'], ['upos-sz-mirrorcoso1.bilivideo.com', 'coso1(腾讯云)'], ['upos-sz-mirrorhw.bilivideo.com', 'hw(华为云·融合CDN)'], ['upos-sz-mirrorhwb.bilivideo.com', 'hwb(华为云·融合CDN)'], ['upos-sz-mirrorhwo1.bilivideo.com', 'hwo1(华为云·融合CDN)'], ['upos-sz-mirror08c.bilivideo.com', '08c(华为云·融合CDN)'], ['upos-sz-mirror08h.bilivideo.com', '08h(华为云·融合CDN)'], ['upos-sz-mirror08ct.bilivideo.com', '08ct(华为云·融合CDN)'], ['upos-tf-all-hw.bilivideo.com', 'tf_hw(华为云)'], ['upos-tf-all-tx.bilivideo.com', 'tf_tx(腾讯云)'], ['upos-hz-mirrorakam.akamaized.net', 'akamai(Akamai海外)'], ['upos-sz-mirroraliov.bilivideo.com', 'aliov(阿里云海外)'], ['upos-sz-mirrorcosov.bilivideo.com', 'cosov(腾讯云海外)'], ['upos-sz-mirrorhwov.bilivideo.com', 'hwov(华为云海外)'], ['cn-hk-eq-bcache-01.bilivideo.com', 'hk_bcache(Bilibili海外)'] ]; /* 测速取样 1MB(4 个切片单位)、单个 host 的时限 1s。 * 为什么不用 256KB:那样「传输耗时」只剩 1~3ms,被计时噪声吃掉 —— 实测同一台 host 连测三次得到 * 250 / 113 / 83 MB/s;同样三次的端到端吞吐是 9.8 / 10.4 / 10.5。所以吞吐按端到端算(字节 ÷ 总耗时), * 它同时就是「拉一个典型分片要多久」的直接代理。 * 时限 1s:这是每个 host 在本会话里的第一次请求(含 DNS + 建连),1MB 都拉不下来就没有选它的理由。 */ const PROBE_BYTES = SLICE * 4; const PROBE_TIMEOUT = 1000; /* ===================================================================== * 2. settings —— 三项配置的唯一持有处 * ===================================================================== */ const cfg = { enabled: GM_getValue(K.enabled, true), cdn: GM_getValue(K.cdn, ''), max: GM_getValue(K.max, 8) }; function setCfg(key, value) { if (cfg[key] === value) { return; } cfg[key] = value; GM_setValue(K[key], value); } /* ===================================================================== * 3. report —— 日志与读数(页面零常驻 UI,一切读数只在这里) * ===================================================================== */ const TAG = '[bbmt]'; const log = (...a) => console.info(TAG, ...a); const warn = (...a) => console.warn(TAG, ...a); const rep = { taken: 0, slices: 0, live: 0, peak: 0, bytes: 0, ms: 0, fail: 0, recent: [] }; const note = (r) => { rep.recent.push(r); if (rep.recent.length > 20) { rep.recent.shift(); } }; api.reqs = () => rep.recent; /* ===================================================================== * 4. context —— 播放器上下文 * ===================================================================== */ /* 取址要的 cid / 清晰度 / 番剧标识只有这一个出处:播放器自己。播放器还没初始化就返回 null, * 调用方据此放行原请求(媒体请求一定发生在播放器初始化之后,所以这不是常态路径)。 */ const manifest = () => { const p = pageWin.player; if (!p || typeof p.getManifest !== 'function') { return null; } try { return p.getManifest(); } catch { return null; } }; /* ===================================================================== * 5. cdnUrl —— 地址锁定(照搬 PiliPlus VideoUtils.getCdnUrl 的分支语义) * ===================================================================== */ /* 只换 host,path 与 query 一个字节都不动 —— 签名覆盖 path、不覆盖 host,所以换完仍 206 且字节一致。 * 不走 new URL 再序列化:query 里是签名(upsig 按 uparams 列出的字段算),重新编码会把它弄坏。 */ function swapHost(url, host) { const i = url.indexOf('://') + 3; const j = url.indexOf('/', i); return url.slice(0, i) + host + (j < 0 ? '' : url.slice(j)); } /* 候选列表(baseUrl 在前、backupUrl 依次在后)→ 最终地址。 * 分支顺序与参考项目逐条对应;不同之处只有两处,都由「设置只有三项」决定: * · 参考项目另有 backupUrl 档与「音频禁用 CDN」开关,本方案没有这两个设置项 —— 地址留空即等价(提前返回、不换 host)。 * · 因此 mcdnUpgcxcode 分支里取不到空 host,不必再退到 CDNService.ali.host。 */ function cdnUrl(urls) { const host = cfg.cdn; if (!host) { return urls[0]; } let mcdnTf = null; let mcdnUpgcxcode = null; let last = ''; for (const url of urls) { last = url; if (MIRROR_RE.test(url)) { if (new URL(url).searchParams.get('os') === 'mcdn') { mcdnUpgcxcode = url; continue; } return swapHost(url, host); } if (MCDN_TF_RE.test(url)) { mcdnTf = url; continue; } if (url.includes(KEY_MARK)) { mcdnUpgcxcode = url; continue; } if (url.includes('szbdyd.com')) { const from = new URL(url).searchParams.get('xy_usource'); return swapHost(url.replace(/^http:/, 'https:'), from || host); } warn('未知的地址类型,按原样使用:', url.slice(0, 80)); } if (mcdnUpgcxcode) { return swapHost(mcdnUpgcxcode, host); } if (mcdnTf) { return `https://${PROXY_TF}?url=${encodeURIComponent(mcdnTf)}`; } return last; } /* ===================================================================== * 6. addr —— 自请求取址 + 地址表 * ===================================================================== */ /* 地址表的键:/upgcxcode/<…>/-1-<轨>.m4s。同一轨在 mcdn(/v1/resource 前缀)、第三方 PCDN、 * 镜像主机上的地址只有前缀不同,从这个标记往后取就能全部对上。 */ function keyOf(url) { const i = url.indexOf(KEY_MARK); if (i < 0) { return null; } const q = url.indexOf('?', i); return q < 0 ? url.slice(i) : url.slice(i, q); } let table = null; // { id: 'cid|quality', map: Map<键, 锁定后的地址> } let base = ''; // 最近一次取址响应里第一条镜像地址:测速换 host 的基准 let pending = null; // { id, p } 正在取址的那一次(同一个 id 只发一遍) api.table = () => (table ? { id: table.id, expire: table.expire, entries: [...table.map] } : null); api.base = () => base; /* 自己发 playurl。普通视频与番剧是两条接口、响应根也不同,但都在这一处决定。 * 走页面自己的 fetch:和页面发的请求同源同 cookie,行为最接近原生(已验证 code 0)。 */ async function playurl(m) { const pgc = !!m.episodeId; const params = new URLSearchParams({ cid: m.cid, qn: m.quality, fnval: 4048, fnver: 0, fourk: 1 }); if (pgc) { params.set('ep_id', m.episodeId); if (m.seasonId) { params.set('season_id', m.seasonId); } } else { params.set('bvid', m.bvid); } const path = pgc ? '/pgc/player/web/playurl' : '/x/player/wbi/playurl'; const res = await pageWin.fetch(`${API}${path}?${params}`, { credentials: 'include' }); const json = await res.json(); const body = pgc ? json.result : json.data; if (json.code !== 0 || !body?.dash) { throw new Error(`playurl code=${json.code} ${json.message}`); } const map = new Map(); let mirror = ''; let expire = 0; for (const track of [...body.dash.video, ...body.dash.audio]) { const key = keyOf(track.baseUrl); if (!key) { continue; } const urls = [track.baseUrl, ...(track.backupUrl || [])]; /* 表里存的是**候选列表**,不是锁好的地址 —— 锁定放到取用时做,这样改完 CDN 设置 * 下一个请求就换 host,不必等表重建(这是「现读配置」那条不变量的落点)。 */ map.set(key, urls); /* 测速基准要的是「真镜像地址」:只有它换 host 后仍是 /upgcxcode/… 且签名照旧有效。 * 顺手从同一份响应里取,不额外发请求。 */ if (!mirror) { mirror = urls.find((u) => MIRROR_RE.test(u)) || ''; } /* 地址自带 deadline(秒),同一份响应里每条轨都一样。取它的唯一用处是知道什么时候该重取 —— * 比「等请求失败了再猜」准,也不会把取址打成风暴。 */ if (!expire) { expire = Number(new URL(track.baseUrl).searchParams.get('deadline')) * 1000; } } return { map, mirror, expire }; } /* 地址表的生命周期=「哪个 cid、哪个清晰度、签名什么时候到点」。 * 表没就绪(或已过期)时返回 null,调用方放行原请求 —— 不等待:播放器头几发都是 1~10KB 的初始化片段, * 等它反而是白付起播延迟。 */ function addrFor(key) { const m = manifest(); if (!m) { return null; } const id = `${m.cid}|${m.quality}`; const stale = table && table.expire > 0 && Date.now() > table.expire - 60000; if (!table || table.id !== id || stale) { refresh(id, m); return null; } const urls = table.map.get(key); return urls ? cdnUrl(urls) : null; } /* 返回这一次取址的 promise(同一个 id 复用同一个),好让需要「等地址就绪」的调用方 await。 * 失败不抛:取址失败本来就是放行原请求的错误路径。 * 失败也照样落一张**空表**:一个 (cid, 清晰度) 只尝试一次取址,否则每个媒体请求都会重发一次 * (实测:取址持续失败时 28 次媒体请求打了 28 次 playurl)。要再试只有两条路 —— id 变了(换清晰度 / * 换 P),或者刷新页面。 */ function refresh(id, m) { if (pending && pending.id === id) { return pending.p; } const p = playurl(m).then(({ map, mirror, expire }) => { table = { id, map, expire }; base = mirror || base; log(`取址完成:cid=${m.cid} qn=${m.quality} ${map.size} 条轨 → ${cfg.cdn || '(不换 host)'}`); }, (err) => { table = { id, map: new Map(), expire: 0 }; warn('取址失败,本次放行原请求:', err.message); }).finally(() => { if (pending && pending.p === p) { pending = null; } }); pending = { id, p }; return p; } /* ===================================================================== * 7. grab —— 切片规划 + 并发取流 * ===================================================================== */ /* 播放器请求的闭区间 [start, end] → 闭区间列表。 * 不足 256KB 就保持它请求的大小;超过就按 256KB 切,尾片不足 128KB 时并进前一片(该片变成 256KB+剩余)。 */ function planSlices(start, end) { const len = end - start + 1; if (len <= SLICE) { return [[start, end]]; } const full = Math.floor(len / SLICE); const tail = len - full * SLICE; const out = []; const n = tail < TAIL_MIN ? full - 1 : full; for (let i = 0; i < n; i++) { out.push([start + i * SLICE, start + (i + 1) * SLICE - 1]); } out.push([out.length ? out[out.length - 1][1] + 1 : start, end]); return out; } /* 全局在途切片数上限:并发数是「同时在途几片」,实际并发 = min(上限, 这次请求的切片数)。 * 每次取槽现读配置,所以改完并发数下一个切片就生效。 */ const waiters = []; function slot() { if (rep.live < cfg.max) { rep.live++; if (rep.live > rep.peak) { rep.peak = rep.live; } return Promise.resolve(); } return new Promise((resolve) => waiters.push(resolve)); } function free() { const next = waiters.shift(); if (next) { next(); } else { rep.live--; } } const totalOf = (res) => Number((res.headers.get('content-range') || '').split('/')[1]) || 0; async function grab(addr, slices, ctl) { let total = 0; let parts; try { parts = await Promise.all(slices.map(async ([a, b]) => { await slot(); try { if (ctl.signal.aborted) { throw new DOMException('aborted', 'AbortError'); } const res = await pageWin.fetch(addr, { headers: { Range: `bytes=${a}-${b}` }, signal: ctl.signal }); if (res.status !== 206 && res.status !== 200) { throw new Error(`HTTP ${res.status}`); } const buf = await res.arrayBuffer(); if (buf.byteLength !== b - a + 1) { throw new Error(`切片长度不符 ${buf.byteLength} != ${b - a + 1}`); } if (!total) { total = totalOf(res); } rep.slices++; rep.bytes += buf.byteLength; return [a, new Uint8Array(buf)]; } finally { free(); } })); } catch (err) { ctl.abort(); throw err; } const from = slices[0][0]; const to = slices[slices.length - 1][1]; const out = new Uint8Array(to - from + 1); for (const [a, bytes] of parts) { out.set(bytes, a - from); } return { buf: out.buffer, total }; } /* ===================================================================== * 7b. probe —— 候选 CDN 测速 * ===================================================================== * 一次请求出两个数:首包延迟(到响应头)与端到端吞吐(PROBE_BYTES ÷ 总耗时)。 * cache:'no-store' 是必须的 —— 缓存命中会给出假高值(实测:同一段 1.5MB 首次 125ms、 * 之后 16ms,那就是缓存,不是 CDN 变快了)。 * 串行测(调用方一次只 await 一个):并发会互相抢带宽,量出来的排序没有意义。 */ async function speedOf(host) { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), PROBE_TIMEOUT); const t0 = performance.now(); try { const res = await pageWin.fetch(swapHost(base, host), { headers: { Range: `bytes=0-${PROBE_BYTES - 1}` }, cache: 'no-store', signal: ctl.signal }); const ttfb = performance.now() - t0; if (res.status !== 206 && res.status !== 200) { throw new Error(`HTTP ${res.status}`); } const buf = await res.arrayBuffer(); const ms = performance.now() - t0; return { ttfb: Math.round(ttfb), mbps: buf.byteLength / 1048576 / (ms / 1000) }; } finally { clearTimeout(timer); } } /* ===================================================================== * 8. player —— 接管播放器的媒体请求 * ===================================================================== */ const RANGE_RE = /^bytes=(\d+)-(\d+)$/; const XHR = pageWin.XMLHttpRequest; const proto = XHR.prototype; const orig = { open: proto.open, send: proto.send, srh: proto.setRequestHeader, abort: proto.abort }; const state = new WeakMap(); const resolveUrl = (u) => { try { return new URL(u, pageWin.location.href).href; } catch { return null; } }; const fire = (xhr, type) => { try { xhr.dispatchEvent(new pageWin.Event(type)); } catch { /* 页面已经拆掉这个实例 */ } }; const fireProgress = (xhr, loaded, total) => { try { xhr.dispatchEvent(new pageWin.ProgressEvent('progress', { lengthComputable: true, loaded, total })); } catch { /* 同上 */ } }; /* 接管成功后要伪造的响应面:只遮住与「真发了这个请求」不同的那几个成员。 * responseText / responseType / withCredentials / on* / 事件监听全都不动 —— 原生行为本来就对。 * 值全部从 s 上现读,这样状态能一格格推进(2 → 3 → 4),不是一次写死。 */ function fabricate(xhr, s) { const get = (name, read) => Object.defineProperty(xhr, name, { configurable: true, get: read }); get('readyState', () => s.rs); get('status', () => s.status); get('statusText', () => s.statusText); get('responseURL', () => s.full); /* responseType=arraybuffer 时 response 在 DONE 之前必须是 null(规范如此,播放器也照这个判)。 */ get('response', () => (s.rs === 4 ? s.buf : null)); Object.defineProperty(xhr, 'getAllResponseHeaders', { configurable: true, value: () => s.headers }); Object.defineProperty(xhr, 'getResponseHeader', { configurable: true, value: (name) => { const want = `${String(name).toLowerCase()}:`; const line = (s.headers || '').split('\r\n').find((l) => l.toLowerCase().startsWith(want)); return line ? line.slice(want.length).trim() : null; } }); } /* 成功路径的状态推进必须按 2 → 3 → progress → 4 走。播放器的吞吐估算器在 readyState=2 时给请求 * 打 headersReceivedDate;从 1 直接跳到 4 会让它退到 p2pLoadInfo(镜像地址不是 PCDN,没这个字段) * → 页面里刷 Uncaught TypeError: reading 'requestStartTime' of undefined。 */ function settle(xhr, s) { s.rs = 2; fire(xhr, 'readystatechange'); s.rs = 3; fire(xhr, 'readystatechange'); fireProgress(xhr, s.buf.byteLength, s.total || s.buf.byteLength); s.rs = 4; fire(xhr, 'readystatechange'); fire(xhr, 'load'); fire(xhr, 'loadend'); } proto.open = function (method, url) { state.set(this, { url: String(url), range: null, ctl: null, taken: false, rs: 0, full: '' }); return orig.open.apply(this, arguments); }; proto.setRequestHeader = function (name, value) { const s = state.get(this); if (s && String(name).toLowerCase() === 'range') { s.range = String(value); } return orig.srh.apply(this, arguments); }; proto.abort = function () { const s = state.get(this); if (!s || !s.taken || !s.ctl) { return orig.abort.apply(this, arguments); } const ctl = s.ctl; s.ctl = null; s.rs = 0; s.status = 0; s.statusText = ''; s.buf = null; s.headers = ''; ctl.abort(); fabricate(this, s); orig.abort.call(this); // 原生自己会补一发 readystatechange fire(this, 'abort'); fire(this, 'loadend'); }; proto.send = function () { const s = state.get(this); const range = s && RANGE_RE.exec(s.range || ''); const url = s && resolveUrl(s.url); const key = range && url && keyOf(url); const addr = cfg.enabled && key ? addrFor(key) : null; if (!addr) { return orig.send.apply(this, arguments); } installViewLock(); const a = Number(range[1]); const b = Number(range[2]); const slices = planSlices(a, b); const ctl = new AbortController(); s.full = url; s.taken = true; s.ctl = ctl; const t0 = performance.now(); grab(addr, slices, ctl).then((res) => { if (s.ctl !== ctl) { return; } s.ctl = null; s.status = 206; s.statusText = 'Partial Content'; s.buf = res.buf; s.total = res.total; s.headers = `content-length: ${res.buf.byteLength}\r\ncontent-type: application/octet-stream\r\n` + (res.total ? `content-range: bytes ${a}-${b}/${res.total}\r\n` : ''); const ms = performance.now() - t0; rep.taken++; rep.ms += ms; note({ track: key.slice(-26), range: `${a}-${b}`, slices: slices.length, ms: Math.round(ms), host: new URL(addr).host }); log(`接管 ${key.slice(-26)} ${a}-${b} → ${slices.length} 片 / ${Math.round(ms)}ms / ${new URL(addr).host}`); fabricate(this, s); settle(this, s); }, (err) => { if (s.ctl !== ctl) { return; } s.ctl = null; s.status = 0; s.statusText = ''; s.buf = null; s.headers = ''; /* 这里**不**清表:地址失效交给 deadline 判(addrFor 里现算),在这里清会让每个后续媒体请求 * 都重发一次取址;而真正的坏 host(被服务端拒绝)重取也没用 —— 那是用户在「选择 CDN」里换一台的事。 */ rep.fail++; warn(`取流失败(${key.slice(-26)} ${a}-${b}):${err.message}`); fabricate(this, s); s.rs = 4; fire(this, 'readystatechange'); fire(this, 'error'); fire(this, 'loadend'); }); }; /* ===================================================================== * 8b. picker —— 候选列表 + 测速结果的浮层 * ===================================================================== * 页面零常驻:只有点菜单才建,选完或关掉连影子根一起摘掉。 * 一行一个候选:host / 标注 / 吞吐 / 首包;边测边填,全部测完按吞吐降序重排(失败排最后)。 * 只碰自己的 Shadow DOM,不读也不写宿主的任何样式。 */ const PICKER_CSS = ` :host { all: initial } .mask { position: absolute; inset: 0; background: #0009 } .box { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-width: 540px; max-width: 92vw; max-height: 78vh; overflow: auto; background: #1c1c1e; color: #e8e8e8; border-radius: 10px; font: 13px/1.7 system-ui, -apple-system, sans-serif; box-shadow: 0 14px 44px #000b } .hd { position: sticky; top: 0; padding: 10px 14px; background: #1c1c1e; font-weight: 600; border-bottom: 1px solid #ffffff1a } .hd b { color: #6ea8ff } .hd i { color: #98989d; font-style: normal; font-weight: 400 } .row { display: grid; grid-template-columns: 1fr 150px 84px 74px; gap: 10px; align-items: center; padding: 7px 14px; cursor: pointer; border-bottom: 1px solid #ffffff0f } .row:hover { background: #ffffff14 } .row[data-on="1"] { background: #2b6cff2e; box-shadow: inset 3px 0 0 #6ea8ff } .host, .tag { overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .tag { color: #98989d } .num { text-align: right; color: #98989d; font-variant-numeric: tabular-nums } .num.ok { color: #7ee787 } .num.bad { color: #ff7b72 } `; let picker = null; // { host, token, onKey } —— 同一时刻只可能有一个 function closePicker() { if (!picker) { return; } pageWin.removeEventListener('keydown', picker.onKey, true); picker.host.remove(); picker = null; } function openPicker() { /* 按「节点还在不在」判断,而不是「picker 变量在不在」:页面是 SPA,body 被换掉、节点被拆掉时 * 变量还留着,那时再点菜单会变成空操作。closePicker 对已摘掉的节点也安全。 */ const wasOpen = picker?.host.isConnected; closePicker(); if (wasOpen) { return; } // 菜单再点一次 = 收起 const token = {}; const onKey = (e) => { if (e.key === 'Escape') { closePicker(); } }; const host = document.createElement('div'); host.style.cssText = 'position:fixed;inset:0;z-index:2147483647'; const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `
` + '
'; picker = { host, token, onKey }; (document.body || document.documentElement).appendChild(host); pageWin.addEventListener('keydown', onKey, true); const hd = root.querySelector('.hd'); const list = root.querySelector('.list'); root.querySelector('.mask').onclick = closePicker; /* 建一行。value 是写进配置的那个值('' = 不换 host),name/tag 是显示用。 */ const mk = (value, name, tag) => { const row = document.createElement('div'); row.className = 'row'; row.innerHTML = '
' + '
'; row.children[0].textContent = name; row.children[1].textContent = tag; row.dataset.on = cfg.cdn === value ? '1' : '0'; row.onclick = () => { setCfg('cdn', value); closePicker(); }; list.appendChild(row); return { value, row, speed: row.children[2], ping: row.children[3], mbps: -1 }; }; const rows = CDN_LIST.map(([h, tag]) => mk(h, h, tag)); const none = mk('', '不换 host', '用接口给的地址'); none.speed.textContent = '—'; none.ping.textContent = '—'; const title = () => { hd.innerHTML = `选择 CDN —— 点一下即选定(当前:)`; hd.querySelector('b').textContent = cfg.cdn || '不换 host'; return hd.querySelector('i'); }; (async () => { const note = title(); if (!base) { note.textContent = ' · 正在取址…'; const m = manifest(); if (m) { await refresh(`${m.cid}|${m.quality}`, m); } if (!base) { note.textContent = ' · 拿不到可测速的分片地址:先在视频页开始播放,再点这个菜单'; return; } } let done = 0; for (const it of rows) { if (picker?.token !== token) { return; } note.textContent = ` · 测速中 ${++done}/${rows.length}`; it.speed.textContent = '…'; try { const r = await speedOf(it.value); it.mbps = r.mbps; it.speed.textContent = `${r.mbps.toFixed(1)} MB/s`; it.speed.className = 'num ok'; it.ping.textContent = `${r.ttfb} ms`; } catch (err) { const slow = err.name === 'AbortError'; it.speed.textContent = slow ? '超时' : '失败'; it.speed.className = 'num bad'; it.ping.textContent = slow ? `>${PROBE_TIMEOUT / 1000}s` : ''; } } if (picker?.token !== token) { return; } note.textContent = ''; /* 测完按吞吐降序重排,失败的排最后;「不换 host」始终吊在末尾(它不是一个候选)。 */ for (const it of [...rows].sort((x, y) => y.mbps - x.mbps)) { list.appendChild(it.row); } list.appendChild(none.row); })(); } /* ===================================================================== * 8c. view —— 播放器自己的「当前地址」视图 * ===================================================================== */ /* 右键 →「视频统计信息」面板里的 Video/Audio Host 是播放器自己的视图:它读 DashPlayer 的 * getCurrentSegmentInfoFor(轨).url。播放器手里那份是**原地址**(我们只在请求时锁 host,它的视图不跟着变), * 所以面板一直显示旧 host。这里把同一个 key → addrFor 的结果回写到视图上 —— 面板显示的地址与真正在拉的 * 地址出自同一个 addrFor,因此改完 CDN 设置它也跟着换(每次调用现算,不缓存)。 * * 挂的是 DashPlayer 类的原型,不是实例:getCurrentSegmentInfoFor 是实例自有属性(原型上没有), * 而实例在换源时会重建 —— 挂实例会在换清晰度后失效。类原型只此一份。 * 调用点只有取到锁定地址的那条成功路径(第一次真正接管媒体请求时装),不需要轮询等播放器。 */ let viewLocked = false; function installViewLock() { if (viewLocked) { return; } const core = pageWin.player?.__core?.(); const proto = core && Object.getPrototypeOf(core); const seg = proto?.getCurrentSegmentInfoFor; if (typeof seg !== 'function') { return; } viewLocked = true; /* 取不到锁定地址(表没就绪 / 这个 key 不在表里)就原样返回,与请求路径的放行一致。 */ const lock = (url) => (typeof url === 'string' && url ? addrFor(keyOf(url)) : null) || url; proto.getCurrentSegmentInfoFor = function () { const info = seg.apply(this, arguments); if (info) { info.url = lock(info.url); } return info; }; /* 同一份视图的另一个出口(页面拿「当前播放地址」用),一并锁上,免得两处说的不一样。 */ const play = proto.getCurrentPlayURLFor; if (typeof play === 'function') { proto.getCurrentPlayURLFor = function () { return lock(play.apply(this, arguments)); }; } } /* ===================================================================== * 9. boot * ===================================================================== */ let menuIds = []; function menu() { for (const id of menuIds) { GM_unregisterMenuCommand(id); } const c = cfg; menuIds = [ GM_registerMenuCommand(`开关:${c.enabled ? '开' : '关'}`, () => { setCfg('enabled', !c.enabled); menu(); }), GM_registerMenuCommand(`最大并发数:${c.max}`, () => { const v = Number(prompt(`最大并发数(1~${MAX_CEIL},同时在途的切片数上限)`, String(c.max))); if (Number.isInteger(v) && v >= 1 && v <= MAX_CEIL) { setCfg('max', v); menu(); } }), GM_registerMenuCommand('选择 CDN…', openPicker) ]; } api.stats = () => ({ ...rep, recent: undefined, enabled: cfg.enabled, cdn: cfg.cdn, max: cfg.max, table: table && { id: table.id, tracks: table.map.size } }); menu(); })();