節點固定之後,壞掉的時候就沒人幫你換了。這篇講擴充怎麼判斷「這個節點真的壞了」、先自己默默換一次,換了還不行才來問你。
我的擴充(嗶速)做的事很單純:B 站給完影片網址,擴充把裡面的 CDN 節點換成你選的那台,固定住。
固定住是優點,也是問題。
B 站播放器本來有自己的備援:playurl 回應裡每一軌除了 baseUrl,還附一串 backupUrl,主節點抓不到就換下一個。但擴充在分段層會把每一個分段請求的 host 改成你選的節點(hook 怎麼攔 fetch/XHR 另一篇有寫),播放器換到 backupUrl 也會被改回來。等於 B 站原生的備援被我關掉了。
所以節點壞了,擴充得自己發現、自己換。
難的不是換,是判斷什麼時候該換。
怎樣算「節點壞了」
擴充看三種訊號:
- 分段請求失敗:HTTP 狀態碼是 403、404、451 或 5xx,或是請求直接噴網路錯誤(連不上、被重設)。
- video 元素報錯:主播放器的
<video>觸發error(解碼錯誤、來源錯誤)。頁面上 hover 預覽那些小 video 的錯誤不理。 - 畫面停住:時間不走、前方緩衝也見底了。這個最麻煩,下面獨立一節講。
function isFailureStatus(status) {
return status === 403 || status === 404 || status === 451 || (status >= 500 && status < 600);
}
那逾時呢? 我沒有替每個分段請求另外設一個逾時。請求一直送、卻 5 秒以上收不到任何資料,這種「線路死了但沒報錯」的情況會被卡頓偵測抓到,效果一樣,還少一套計時器要維護。
哪些錯不是節點的錯
我一開始也覺得,403 就是節點不給你,換就對了。實際上有好幾種錯跟節點無關,看到錯就切只會把好好的播放打斷。
下面三條過濾的是「分段請求失敗」這種訊號,因為只有它帶著狀態碼和網址可以檢查。
1. 網址過期的 403 不是節點的錯
B 站的分段網址帶簽名,query 裡有一個 deadline(unix 秒)。影片暫停放著,回來時網址已經過期,任何節點都回 403。這時換節點沒用,該讓播放器自己重拿一份 playurl。
function isExpiredUrl(url) {
var dl = new URL(url, location.href).searchParams.get("deadline");
return !!dl && parseInt(dl, 10) * 1000 < Date.now();
}
新的 playurl 經過 hook 改寫時會呼叫 resetFallbackState(),卡頓計時和切換額度一起歸零,等於重新開始。
老實講這條只擋住了「狀態碼」那條路。卡頓偵測不看原因,如果播放器 8 秒內沒有重拿到新網址、畫面一直停著,卡頓那條照樣會判定、照樣換節點。而換節點用的 player.reload() 沿用原本那份 playurl(後面會講),所以這次切換救不了過期,只是白白用掉一次自動額度。我接受這個代價:卡頓偵測只看得到「畫面停了、沒資料進來」,分不出是過期還是節點壞,寧可多換一次,也不要讓真的壞掉的節點一直卡著。
2. 不是打到目前節點的分段不算
你選了一台特定節點的時候(程式叫「具名節點」模式),只有打到那台的分段失敗才算數。最常見的例子是剛換完節點:舊節點上還在飛的請求失敗了,這不該算到新節點頭上。另外兩種模式整套自動切換都不跑:「原始」是不改 host、照 B 站給的播;「備用URL」是優先用 B 站給的備援網址,本來就是後面要講的最後一步。
順帶一提,「這是不是分段」本身也踩過坑。B 站的打點上報(data.bilibili.com)會把分段網址塞在 query 裡,以前只看路徑判斷,結果把上報請求也當成分段,污染了失敗判斷。現在一定要同時驗 host 是 CDN 網域。
3. 播放器自己中止的請求不算
你拖進度條、切畫質,播放器會主動 abort 還在飛的分段請求,fetch 會丟 AbortError。這是正常行為,不是線路問題。
三條合起來就是分段請求失敗進入切換流程的門檻(網路錯誤那條路少了過期檢查,因為沒有狀態碼,但一樣只認目前節點、一樣跳過 AbortError):
function shouldFallbackOnStatus(status, url) {
if (!isFailureStatus(status)) return false;
if (status === 403 && isExpiredUrl(url)) return false; // 簽名過期,換節點也沒用
return isCurrentSegHost(hostOf(url)); // 只認目前節點
}
Mobile01 上也有網友問過「為什麼影片看到一半會一直切成備用URL」(1.4.0 修掉的)。亂切的代價很實際:畫面黑一下、已經抓好的緩衝丟掉、還可能被換到比較慢的節點。
卡頓怎麼判斷
請求失敗至少有個狀態碼可以看。卡頓沒有,只能自己從播放器的狀態推。
擴充每秒檢查一次主播放器的 currentTime:
- 時間有在走:計時歸零,沒事。
- 時間停住了:開始計時。要等多久才算卡,看線路上最近 5 秒有沒有資料進來:
- 沒有資料在動:停住 8 秒就判定卡頓。
- 還有資料進來(只是慢):給到 20 秒。音軌還活著、視軌已經死掉的情況,靠這個寬限才判得到,不會因為「還有東西在下載」就永遠不算卡。
聽起來很直覺,但每一條例外都是被誤判逼出來的。
「一段時間沒流量」不能當卡頓
以前的版本是這樣判斷的,結果是災難。播放器緩衝抓滿就會停止抓分段,沒流量是正常狀態。舊版在緩衝抓滿大約 18 秒後就誤判卡住,把正在好好播放的頁面整頁重載。(看得好好的被自己的擴充重整,你敢信?)
所以現在的主訊號是 currentTime 不動,流量只用來決定要等 8 秒還是 20 秒。
暫停要分兩種
使用者自己按暫停,時間當然不會走,不能算卡。但實測番劇頁卡死一陣子之後,播放器會自己把 video 設回 paused。如果看到 paused 就放行,這種情況永遠判不到。
區分的方法是看前方緩衝:currentTime 後面還有超過 0.5 秒已經下載好的資料,代表隨時能繼續播,那是你自己按的暫停。緩衝見底的暫停,照樣計時。
function hasForwardBuffer(v) {
var bf = v.buffered;
for (var i = 0; i < bf.length; i++) {
if (v.currentTime >= bf.start(i) - 0.1 && v.currentTime < bf.end(i) - 0.5) return true;
}
return false;
}
還沒開始播的影片
readyState 是 0(還沒有任何媒體資料)通常是剛開始載入,或換節點後播放器重建中,不算卡。唯一的例外:分段請求最近 10 秒內一直在送,卻已經 5 秒以上沒收到任何資料,這代表一打開就遇到死線路,照樣計時。
影片播完(ended)當然也不算。
先靜默切換,不行才問你
分段請求失敗要先過上一節的三條檢查;卡頓和 video 報錯沒有網址可以查,判定之後直接進下一步。接下來先看 30 秒內是不是剛換過(剛換過就先等,原因下一節講),然後分兩段處理。
第一次:自動換到 B 站給的備援節點
擴充在改寫 playurl 的時候,順手把 B 站原本給的 baseUrl 和 backupUrl 的 host 記下來(濾掉 PCDN,也就是 .mcdn. 那種品質不穩的節點)。第一次判定壞掉,就從這份清單挑第一台「不是目前節點、也還沒試過」的換過去。
怎麼換也有講究:
- 用
player.reload()在播放器裡重載。它沿用原本那份 playurl,不會重拿網址,但新的分段請求一樣會經過 hook,host 被改成新節點。不整頁重新整理,全螢幕不會掉,進度也會跳回原本的位置。 - 只改這個分頁實際用的節點(程式裡叫
autoHost),不寫進你的設定。popup 顯示的還是你選的節點,下次手動重新整理也回到你的選擇。 - 播放器上方中間跳一個 3 秒的提示:「目前 CDN 速度不佳,已自動切換至備援節點 {host}」。(文案統一寫速度不佳,實際原因也可能是 403 或解碼錯誤)
說是「靜默」,其實畫面會黑一下(播放器重載),所以還是會跟你講一聲換到哪。靜默指的是不用你做任何事、也不動你的設定。
第二次:問你
換過去的備援節點也播不動(或是清單裡根本沒有可以換的),擴充就不自己動了,改跳一個不會自己消失的提示:
「目前 CDN 與備援節點皆無法順利播放」,旁邊兩個按鈕:「重載並切換至備用URL」、「關閉」。
為什麼這一步要問? 因為「備用URL」模式不是在分段層換 host,而是在 playurl 回應裡把 B 站的 backupUrl 排到最前面。player.reload() 沿用舊的 playurl,改不到這一層,只能整頁重載讓頁面重打 playurl。整頁重載的感受跟播放器內重載差很多,整個頁面都要重來。以前這一步是自動做的,現在改成問你,按了才重載。問完這一輪的額度就用完了,同一次卡頓不會再問第二次。
播放一旦恢復走針,這個提示會自己收掉,不用你去按關閉。
怎麼避免在幾個節點之間來回彈
自動切換最怕的是 A 壞換 B、B 卡一下又換 C、C 卡一下又換回 A,畫面一直黑。我加了四道限制:
- 一輪兩次額度(
FALLBACK_MAX_ATTEMPTS = 2):第一次自動換,第二次就是問你。問完額度直接封頂,這一輪不會再自己動。 - 試過的不再試:每次切換前把目前節點記進
tried,挑備援時跳過。 - 30 秒冷卻(
FALLBACK_COOLDOWN_MS = 30000):換完 30 秒內再出錯不處理。卡頓偵測在冷卻期間照樣在跑,大約每 9 到 10 秒判一次(判到就重新計時,8 秒門檻加上每秒檢查一次的落差),只是判到了也不動作,要等冷卻結束後的下一次判定才算數。這個數字是實測出來的,4K 在player.reload()之後有時要 20 秒以上才恢復走針,冷卻太短會在新節點還沒緩衝好之前就誤判它也壞了,直接跳「皆無法播放」。 - 順播 30 秒才開新的一輪:連續順順播 30 秒,代表線路真的恢復了,額度和
tried一起清空。問過你也一樣,之後再卡會重新來一輪(先自動換,不行再問)。中間有 30 秒順播,就不算來回彈。換一支影片(拿到新的 playurl)也會重置。
另外,你手動在 popup 換節點,自動切換的覆寫就作廢,一律以你的選擇為準。
如果你的網路本身就不穩(例如 Wi-Fi 訊號很弱),斷線不是節點的錯,自動切換只會讓畫面一直黑掉重載。這種情況可以到進階設定把「失敗自動切換」關掉(預設開,1.5.0 加的開關):
直播不一樣
直播(live.bilibili.com)的線路結構跟影片完全不同,所以容錯也是另一套。
直播流的 host 長這樣:d1--{ov|cn}-gotcha{N}[b].bilivideo.com。同一個集群號 N 有四個變體:國際線路 ov、國際備用 ov-b、中國線路 cn、中國備用 cn-b(ov-b 指的是 ov-gotcha{N}b 這種結尾帶 b 的 host,cn-b 同理),簽名在這四個之間通用。換線路就是把 host 改成同號的另一個變體。操蛋的是,部分房間的 cn-b 根本不存在。
擴充的做法是不在四條之間輪流試,退路固定一條:國際線路 ov。ov 也是擴充的預設線路,程式裡探測不到其他線路時同樣退回 ov。
- 請求當場重試:改過 host 的串流請求失敗(DNS 失敗、403 之類),擴充直接用頁面原本的網址(B 站 API 分配的那條)重試一次,重試成功就讓之後的請求都改走 ov。注意不是改走剛剛重試成功的那條:程式沒有記原本是哪條,這裡跟卡頓那條共用同一個「退回 ov」的覆寫,只維護一個退路。這一步在 fetch 裡就做完,播放器根本感覺不到。
- 卡頓:一樣用上面那套
checkStall,多一條直播專用的:串流已經請求超過 8 秒,video 卻還沒拿到任何資料(readyState0),代表線路通但沒有碼流,算卡。 - 判定之後切到 ov、跳提示「目前直播線路不佳,已自動切換至國際線路(ov)」,用
livePlayer.reload()重新拉流。冷卻一樣 30 秒。
跟影片最大的差別:直播不會問你。ov 本身就是最後一條路,已經在 ov 上就沒得退,什麼都不做。而且這個覆寫只對這個直播間有效,換房間就重來,不會動到你長期選的線路。
popup 開著的時候,擴充會順便探測這個房間的四條線路哪些存在(拿目前的串流網址換 host 請求,8 秒逾時,拿到 2xx 就算存在)。只在 popup 開著時探測,刷直播的人不會每進一個房間就多打三條連線。
自己測試
要看完整流程不用等網路真的爛掉。在 B 站影片頁打開 DevTools,console 執行:
__cdnSwitcherSimulateStall() // 接下來 60 秒都當成卡住
__cdnSwitcherSimulateStall("ask") // 直接跳出詢問提示
第一行大約 9 秒後會自動換到備援節點並跳提示。冷卻期間卡頓照樣大約每 9 到 10 秒判一次但不動作,冷卻結束後的下一次判定(第 40 到 50 秒之間)跳出「皆無法播放」的詢問。要重測整個流程,最簡單是重新整理或換一支影片,不然就得等順播 30 秒、額度還回來。
2.4.0 之後,除錯疊層也看得到緩衝秒數、卡頓計時和自動切換次數,用擴充的問題回報送出時會自動附上最近 10 分鐘的卡頓與切換紀錄。


留言(0)
還沒有人留言,來當第一個吧!