fix: use external bg solver

This commit is contained in:
Emilien 2026-08-02 01:53:46 +02:00
parent 2cc3b61cb7
commit 2b32cdb4f3
8 changed files with 298 additions and 52 deletions

View File

@ -61,13 +61,17 @@
transition: opacity cubic-bezier(.4, 0, .6, 1) .6s;
}
/* Controls bar - match video.js style */
/* Controls bar - match video.js style.
NOTE: .shaka-controls-container is the FULL-SIZE overlay covering the whole video.
A gradient background here (combined with the forced opacity:1 above) permanently
darkened the entire video. Keep it transparent and put the control-contrast gradient
only on the bottom strip instead. */
.shaka-controls-container {
background: linear-gradient(rgba(0,0,0,0.1), rgba(0, 0, 0,0.5));
background: transparent;
}
.shaka-bottom-controls {
background: transparent;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
}
/* Buttons - match video.js colors */

View File

@ -486,6 +486,13 @@
// Workaround: https://github.com/LuanRT/googlevideo/issues/42
continue;
}
// TEST: skip DRC ("Stable Volume") audio. Our player was defaulting to the
// DRC track (drcEnabled:true in the SABR request); FreeTube uses normal audio
// (drcEnabled:false). Testing whether DRC selection is why the server returns
// 0 media on seeks.
if (format.isDrc) {
continue;
}
audioStreams.push(createAudioStream(format, currentId++, hasDrcAudio, hasVoiceBoostAudio, presentationTimeline, networkingEngine, fakeVideoFormatId));
} else if (!this._config.disableVideo) {
videoStreams.push(createVideoStream(format, currentId++, presentationTimeline, networkingEngine));

View File

@ -19,11 +19,27 @@ var SABRPlayer = (function () {
var DEFAULT_ABR_CONFIG = {
enabled: true,
restrictions: { maxHeight: 480 },
// Adapt quality to the player element size (like FreeTube), NOT a hard 480p cap.
// A maxHeight:480 cap made shaka request the 480p format, which then set
// stickyResolution/lastManualSelectedResolution=480 in the SABR request, so the
// server LOCKED playback to a blurry 480p even after the cap was lifted.
restrictToElementSize: true,
switchInterval: 4,
useNetworkInformation: false
};
// TEMP: bypass the Onesie player-response fetch and use a plain WEB getInfo()
// instead. The Onesie path yields a ustreamerConfig that does NOT opt us into
// SABR contexts (server never sends SABR_CONTEXT_UPDATE / context 5), which
// breaks seeking. A normal WEB getInfo() (like FreeTube's createInnertube)
// returns the full ustreamerConfig. Onesie to be revisited separately.
var BYPASS_ONESIE = false;
// bg-helper-server /generate endpoint. Mints an ATTESTED (StreamProtectionStatus=1)
// PO token server-side, where jsdom presents a youtube.com origin. In-browser BotGuard
// (Invidious origin) can only get status 2 (pending), so it's used only as a fallback.
var BG_HELPER_URL = 'http://127.0.0.1:4416/generate';
// State
var player = null;
var ui = null;
@ -40,10 +56,19 @@ var SABRPlayer = (function () {
var playbackWebPoTokenCreationLock = false;
var sabrStream = null; // handle returned by setupSabrScheme
// Guards so per-player-instance listeners/filters are installed once, not once
// per reload (loadVideo re-runs on every SABR reload).
var requestFiltersInstalled = false;
var loadedListenerInstalled = false;
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
var reloadCount = 0; // cap reloads to prevent infinite loop on blocked/throttled videos
var MAX_RELOADS = 3;
// YouTube walls SABR playback at ~60s of content on some videos and only lets a
// fresh player response (new server_abr_streaming_url) through. FreeTube recovers
// by reloading the SABR player several times (observed: ~4 reloads before it sticks),
// so cap high enough to actually recover instead of giving up early — matching
// FreeTube's behaviour — while still bounding a truly blocked/throttled video.
var MAX_RELOADS = 10;
function getSavedVolume() {
try {
@ -107,12 +132,12 @@ var SABRPlayer = (function () {
generate_session_locally: true
});
// Kick off BotGuard init (don't block player setup).
BotguardService.init().then(function () {
console.info('[SABRPlayer]', 'BotGuard client initialized');
}).catch(function (err) {
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
});
// NOTE: the in-browser BotguardService is NOT eagerly initialized anymore. The PO
// token is minted by the bg-helper-server (/generate), where jsdom presents a
// youtube.com origin and the token actually attests. In-browser BotGuard could only
// ever produce a StreamProtectionStatus=2 (pending) token AND fired a redundant
// GenerateIT request on every video. It remains only as an on-demand fallback
// (mintContentWebPO -> BotguardService.reinit) if the helper is unreachable.
// Preload the redirector URL.
try {
@ -173,14 +198,28 @@ var SABRPlayer = (function () {
playbackWebPoTokenCreationLock = true;
try {
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
// Prefer the bg-helper-server: it mints an ATTESTED token (youtube.com-origin jsdom).
// The token is bound to our session's visitorData, so we send that.
var sessionCtx = innertube && innertube.session && innertube.session.context;
var visitorData = sessionCtx && sessionCtx.client && sessionCtx.client.visitorData;
if (visitorData) {
var helperToken = await fetchHelperPoToken(visitorData, currentVideoId, sessionCtx);
if (helperToken) {
playbackWebPoToken = helperToken;
console.info('[SABRPlayer]', 'WebPO token from bg-helper (attested):', helperToken.substring(0, 30) + '...');
return;
}
}
// Fallback: in-browser BotGuard (only StreamProtectionStatus=2/pending, no youtube.com origin).
console.warn('[SABRPlayer]', 'bg-helper unavailable, falling back to in-browser BotGuard (unattested)');
if (!BotguardService.isInitialized()) {
await BotguardService.reinit();
await BotguardService.reinit(innertube && innertube.session && innertube.session.context);
}
if (BotguardService.isInitialized()) {
playbackWebPoToken = await BotguardService.mintWebPoToken(decodeURIComponent(playbackWebPoTokenContentBinding));
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
console.info('[SABRPlayer]', 'WebPO token created (in-browser):', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
} else {
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
}
@ -191,6 +230,63 @@ var SABRPlayer = (function () {
}
}
// Fetch an attested PO token from the bg-helper-server, bound to the video id
// (content binding, like FreeTube). The full InnerTube session context is sent so the
// helper's att/get challenge is session-bound to our visitorData.
async function fetchHelperPoToken(visitorData, videoId, context) {
try {
var resp = await fetch(BG_HELPER_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ visitorData: visitorData, videoId: videoId, context: context })
});
if (!resp.ok) {
console.warn('[SABRPlayer]', 'bg-helper /generate returned', resp.status);
return null;
}
var data = await resp.json();
return data && data.poToken ? data.poToken : null;
} catch (err) {
console.warn('[SABRPlayer]', 'bg-helper /generate fetch failed', err && err.message);
return null;
}
}
// Mint a brand new content-bound WebPO token for the current video. Used by the
// sabr: scheme when the server answers with StreamProtectionStatus 3
// (ATTESTATION_REQUIRED) and refuses to send any more media until we re-attest.
async function mintFreshPoToken() {
if (!currentVideoId) return null;
try {
// Re-mint via the bg-helper (attested), same as the initial mint. The in-browser
// BotguardService can't attest (no youtube.com origin) so it must NOT be used here —
// doing so threw BGError: PMD:Undefined and never satisfied the attestation.
var sessionCtx = innertube && innertube.session && innertube.session.context;
var visitorData = sessionCtx && sessionCtx.client && sessionCtx.client.visitorData;
var token = null;
if (visitorData) {
token = await fetchHelperPoToken(visitorData, currentVideoId, sessionCtx);
}
if (!token) {
// Fallback: in-browser (unattested) only if the helper is unreachable.
if (!BotguardService.isInitialized()) {
await BotguardService.reinit(sessionCtx);
}
if (BotguardService.isInitialized()) {
token = await BotguardService.mintWebPoToken(currentVideoId);
}
}
if (token) {
playbackWebPoToken = token;
console.info('[SABRPlayer]', 'Re-minted WebPO token after attestation request');
}
return token;
} catch (err) {
console.warn('[SABRPlayer]', 'Failed to re-mint WebPO token', err);
return null;
}
}
async function initializeShakaPlayer(containerElement, listenMode) {
if (!shaka.Player.isBrowserSupported()) {
throw new Error('Shaka Player is not supported in this browser');
@ -215,10 +311,13 @@ var SABRPlayer = (function () {
player.configure({
abr: DEFAULT_ABR_CONFIG,
streaming: {
bufferingGoal: 120,
rebufferingGoal: 0.01,
// Match FreeTube's SABR streaming config exactly (values YouTube itself uses):
// large buffering goal, tiny rebuffering goal, big bufferBehind, and a doubled
// (60s) retry timeout to tolerate the larger SABR UMP responses.
bufferingGoal: 180,
rebufferingGoal: 0.02,
bufferBehind: 300,
retryParameters: { maxAttempts: 8, fuzzFactor: 0.5, timeout: 30 * 1000 }
retryParameters: { timeout: 60 * 1000 }
},
manifest: {
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
@ -462,7 +561,19 @@ var SABRPlayer = (function () {
return;
}
console.warn('[SABRPlayer] SABR reload requested by server; re-loading video (attempt ' + reloadCount + '/' + MAX_RELOADS + ')');
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, currentLoadOptions || {});
// Match FreeTube's reloadView(): re-fetch with a FRESH innertube session
// (new visitorData) and a re-attested BotGuard, instead of reusing the same
// session identity. The 60s SABR wall is tied to the session/streaming
// context, so a fresh session is what actually lets the reload recover.
var resumeAt = (videoElement && isFinite(videoElement.currentTime)) ? videoElement.currentTime : undefined;
innertube = null;
clientConfig = null;
playbackWebPoToken = null;
coldStartToken = null;
try { BotguardService.dispose(); } catch (e) {}
var reloadOptions = Object.assign({}, currentLoadOptions || {});
if (resumeAt !== undefined && resumeAt > 1) reloadOptions.startTime = resumeAt;
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, reloadOptions);
});
}
@ -478,6 +589,8 @@ var SABRPlayer = (function () {
// Start Shaka player init (DOM + polyfills) in parallel with network init.
var shakaPromise;
if (!player) {
requestFiltersInstalled = false;
loadedListenerInstalled = false;
shakaPromise = initializeShakaPlayer(containerElement, options.listen);
} else {
player.configure('abr', DEFAULT_ABR_CONFIG);
@ -501,8 +614,10 @@ var SABRPlayer = (function () {
await Promise.all([shakaPromise, netPromise]);
var poToken = playbackWebPoToken || coldStartToken || '';
if (!requestFiltersInstalled) {
setupRequestFilters();
requestFiltersInstalled = true;
}
// Fetch the player response through Onesie (WEB client) so the SABR
// streaming URL isn't bound to the proxy's egress IP. Falls back to a
@ -510,6 +625,9 @@ var SABRPlayer = (function () {
// over SABR (sabr_scheme_plugin.js) either way.
var videoInfo;
try {
if (BYPASS_ONESIE) {
throw new Error('Onesie bypassed (BYPASS_ONESIE) - using plain WEB getInfo');
}
if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) {
throw new Error('Onesie support not loaded');
}
@ -577,17 +695,23 @@ var SABRPlayer = (function () {
if (sabrStream) {
try { sabrStream.cleanup(); } catch (e) {}
}
sabrStream = window.setupSabrScheme(SABRPlayer._lastSabrData, getPlayer, getManifest, getPlayerWidth, getPlayerHeight);
sabrStream = window.setupSabrScheme(
SABRPlayer._lastSabrData, getPlayer, getManifest, getPlayerWidth, getPlayerHeight, mintFreshPoToken
);
wireSabrStream(loadVideo);
}
// Capture the parsed manifest once Shaka has loaded it, so the sabr:
// scheme plugin can read variant/segment indices from it.
// scheme plugin can read variant/segment indices from it. Install once per
// player instance (loadVideo re-runs on every reload).
if (!loadedListenerInstalled) {
player.addEventListener('loaded', function () {
if (typeof player.getManifest === 'function') {
sabrManifest = player.getManifest();
}
});
loadedListenerInstalled = true;
}
var startTime = options.startTime;
if (startTime === undefined && options.savePlayerPos !== false) {

View File

@ -12,6 +12,10 @@
'use strict';
var BotguardService = (function() {
// WEB WAA request key. Note: the TV/living-room key 'Z1elNkAKLpSR3oPOUMSN' (used by
// youtube.com/tv) is rejected (null integrity token) when the Create/GenerateIT flow
// runs from our (non-youtube.com) origin, so we keep the WEB key which at least mints a
// token (though only StreamProtectionStatus=2/pending without a youtube.com origin).
var WAA_REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
// Use the API key from bgutils-js/Kira which has access to Web Anti-Abuse API
var GOOG_API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
@ -20,6 +24,11 @@ var BotguardService = (function() {
var initializationPromise = null;
var integrityTokenBasedMinter = null;
var bgChallenge = null;
// InnerTube session context (client.visitorData, clientVersion, ...). Required to
// fetch a SESSION-BOUND BotGuard challenge from youtubei/v1/att/get. Without it the
// attestation is session-less and YouTube's SABR server marks the PO token
// StreamProtectionStatus=2 (pending) -> media stops at ~60s and seeks return no media.
var sessionContext = null;
/**
* Build URL for BotGuard API calls (using YouTube endpoint, not googleapis.com)
@ -77,7 +86,8 @@ var BotguardService = (function() {
* Initialize the BotGuard client
* @returns {Promise<Object|undefined>}
*/
async function init() {
async function init(context) {
if (context) sessionContext = context;
if (initializationPromise) {
return await initializationPromise;
}
@ -115,8 +125,12 @@ var BotguardService = (function() {
}
try {
// First call (Create) uses direct fetch - no proxy needed
var challengeResponse = await fetch(buildURL('Create', true), {
// TV / living-room (Cobalt) attestation via the generic WAA 'Create' endpoint
// with the TV request key. youtube.com/tv uses this key and its GenerateIT
// returns a VALID integrity token (index 0) - unlike the strict WEB att/get flow
// which is rejected (null) from a non-youtube origin. Testing whether the lenient
// TV attestation yields a StreamProtectionStatus=1 token from our origin.
var challengeResponse = await fetchWithProxy(buildURL('Create', true), {
method: 'POST',
headers: {
'content-type': 'application/json+protobuf',
@ -130,11 +144,12 @@ var BotguardService = (function() {
bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
if (!bgChallenge) {
console.error('[BotguardService]', 'Failed to parse challenge data');
console.error('[BotguardService]', 'Failed to parse challenge data (Create)');
return undefined;
}
var interpreterJavascript = bgChallenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
var interpreterJavascript = bgChallenge.interpreterJavascript &&
bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (!interpreterJavascript) {
console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', bgChallenge.interpreterHash);
@ -173,7 +188,12 @@ var BotguardService = (function() {
});
var integrityTokenResponseData = await integrityTokenResponse.json();
var integrityToken = integrityTokenResponseData[0];
// The att/get flow's GenerateIT returns e.g. [null, ttl, null, "<token>"]
// (token not always at index 0). Take the first string element.
var integrityToken = Array.isArray(integrityTokenResponseData)
? integrityTokenResponseData.find(function (x) { return typeof x === 'string' && x.length > 0; })
: integrityTokenResponseData;
console.info('[BotguardService]', 'GenerateIT response shape:', JSON.stringify(integrityTokenResponseData.map(function (x) { return typeof x === 'string' ? 'str(' + x.length + ')' : x; })));
if (!integrityToken) {
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', bgChallenge.interpreterHash);
@ -248,7 +268,8 @@ var BotguardService = (function() {
* Reinitialize BotGuard
* @returns {Promise<Object|undefined>}
*/
async function reinit() {
async function reinit(context) {
if (context) sessionContext = context;
if (initializationPromise) {
return initializationPromise;
}

View File

@ -18,6 +18,7 @@
return JSON.parse(JSON.stringify(obj));
}
function formatIdFromString(str) {
var parts = str.split('-');
return {
@ -205,6 +206,27 @@
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
}
// --- TEMP SEEK DIAGNOSTICS (toggle: window.SABR_DEBUG = true) ---
var _dbg = (typeof window !== 'undefined' && window.SABR_DEBUG);
var _dbgHeaders = [];
var _dbgPartTypes = {};
if (_dbg) {
try {
console.log('[SABR_DBG] REQ', JSON.stringify({
uri: operationInputs.uri,
isInit: operationInputs.isInit,
sq: operationInputs.sequenceNumber,
playerTimeMs: currentState.abrRequest.clientAbrState.playerTimeMs,
bufRanges: (currentState.abrRequest.bufferedRanges || []).length,
rn: currentState.sabrStreamState.requestNumber,
ctxStored: currentState.sabrStreamState.sabrContexts.size,
ctxActive: Array.from(currentState.sabrStreamState.activeSabrContextTypes),
ctxSent: (currentState.abrRequest.streamerContext.sabrContexts || []).length,
sabrHost: (new URL(currentState.sabrStreamState.sabrUrl)).host
}));
} catch (e) {}
}
try {
var shouldReloadDueToBackoffLoop = false;
if ((currentState.sabrStreamState.nextRequestPolicy?.backoffTimeMs || 0) > 0) {
@ -255,11 +277,15 @@
}
var remainingData = new UmpReader(chunkedDataBuffer).read(function (part) {
if (_dbg) { _dbgPartTypes[part.type] = (_dbgPartTypes[part.type] || 0) + 1; }
switch (part.type) {
case UMPPartId.STREAM_PROTECTION_STATUS: {
var streamProtectionStatus = decodePart(part, StreamProtectionStatus);
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
invalidPoToken = true;
if (streamProtectionStatus.maxRetries) {
currentState.attestationMaxRetries = streamProtectionStatus.maxRetries;
}
}
break;
}
@ -272,9 +298,14 @@
case UMPPartId.SABR_REDIRECT: {
var sabrRedirect = decodePart(part, SabrRedirect);
if (!sabrRedirect) break;
// BUGFIX (vs FreeTube): the read site reads sabrStreamState.sabrUrl,
// so write there, not currentState.sabrUrl.
currentState.sabrStreamState.sabrUrl = sabrRedirect.url;
// Match FreeTube EXACTLY: it writes currentState.sabrUrl (a field that does
// NOT exist on currentState), so the redirect URL is effectively ignored and
// every request keeps POSTing to the ORIGINAL server_abr_streaming_url
// (sabrStreamState.sabrUrl). Earlier we "fixed" this to follow the redirect,
// which pins the session to a specific CDN host (rrN---snXXX) that maintains a
// sequential cursor and returns 0 media when the client jumps/seeks. Following
// the redirect breaks seeking; ignoring it (as FreeTube does) keeps seeks working.
currentState.sabrUrl = sabrRedirect.url;
shouldRetry = true;
break;
}
@ -282,6 +313,11 @@
if (mediaHeaderId === undefined) {
var mediaHeader = decodePart(part, MediaHeader);
if (!mediaHeader) break;
if (_dbg) {
try {
_dbgHeaders.push({ itag: mediaHeader.formatId.itag, seq: mediaHeader.sequenceNumber, isInitSeg: !!mediaHeader.isInitSeg, start: mediaHeader.startMs, want: operationInputs.sequenceNumber });
} catch (e) {}
}
if (
mediaHeader.formatId.itag === itag &&
mediaHeader.formatId.lastModified === lastModified &&
@ -328,6 +364,7 @@
}
case UMPPartId.SABR_CONTEXT_UPDATE: {
var sabrContextUpdate = decodePart(part, SabrContextUpdate);
if (_dbg) { try { console.log('[SABR_DBG] CTX_UPDATE', JSON.stringify({ decoded: !!sabrContextUpdate, type: sabrContextUpdate ? sabrContextUpdate.type : null, valueLen: sabrContextUpdate && sabrContextUpdate.value ? sabrContextUpdate.value.length : 0, sendByDefault: sabrContextUpdate ? sabrContextUpdate.sendByDefault : null, writePolicy: sabrContextUpdate ? sabrContextUpdate.writePolicy : null })); } catch (e) {} }
if (!sabrContextUpdate) break;
if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
if (
@ -407,6 +444,29 @@
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
}
if (_dbg) {
try {
console.log('[SABR_DBG] RESP', JSON.stringify({
sq: operationInputs.sequenceNumber,
isInit: operationInputs.isInit,
mediaBytes: responseDataChunks.reduce(function (n, c) { return n + (c.length || c.byteLength || 0); }, 0),
segmentComplete: segmentComplete,
shouldRetry: shouldRetry,
retryNextPolicy: shouldRetryDueToNextRequestPolicy,
invalidPoToken: invalidPoToken,
backoffMs: currentState.sabrStreamState.nextRequestPolicy ? currentState.sabrStreamState.nextRequestPolicy.backoffTimeMs : undefined,
reload: currentState.sabrStreamState.playerReloadRequested,
error: error,
httpStatus: response ? response.status : undefined,
headersSeen: _dbgHeaders,
partTypes: _dbgPartTypes,
CTX_UPDATE_ID: UMPPartId.SABR_CONTEXT_UPDATE,
CTX_SENDING_ID: UMPPartId.SABR_CONTEXT_SENDING_POLICY
}));
} catch (e) {}
}
if (responseDataChunks.length > 0 && segmentComplete) {
var concatenateChunks = utils.concatenateChunks;
var data = concatenateChunks(responseDataChunks);
@ -422,7 +482,35 @@
fromCache: false,
originalRequest: operationInputs.request
};
} else if (shouldRetry) {
} else if (shouldRetry || invalidPoToken) {
// StreamProtectionStatus 3 = ATTESTATION_REQUIRED: the server is refusing to
// send media until we re-attest. Retrying with the same PO token just loops on
// the 2s backoff forever (the symptom: endless "SABR throttled" toasts), so
// mint a fresh token before retrying and give up loudly once maxRetries is hit.
if (invalidPoToken) {
currentState.attestationRetries = (currentState.attestationRetries || 0) + 1;
var attestationLimit = currentState.attestationMaxRetries || 10;
if (currentState.attestationRetries > attestationLimit) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.NETWORK,
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('SABR attestation required and PO token re-minting did not satisfy it'),
operationInputs.requestType
);
}
var freshPoToken = null;
try {
freshPoToken = currentState.mintPoToken ? await currentState.mintPoToken() : null;
} catch (e) {
console.warn('[SabrScheme] PO token re-mint failed', e);
}
if (freshPoToken) {
currentState.abrRequest.streamerContext.poToken = utils.base64ToU8(freshPoToken);
}
}
if (shouldRetryDueToNextRequestPolicy) {
currentState.cumulativeRetryDueToNextRequestPolicy += 1;
}
@ -452,15 +540,6 @@
currentState.abortStatus.timedOut = false;
currentState.abortStatus.finished = false;
return doRequest(operationInputs, currentState);
} else if (invalidPoToken) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.NETWORK,
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Invalid PO token'),
operationInputs.requestType
);
} else if (error) {
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, new Error(error), operationInputs.requestType);
} else if (responseDataChunks.length > 0 && !segmentComplete) {
@ -495,7 +574,7 @@
}
}
function setupSabrScheme(sabrData, getPlayer, getManifest, getWidth, getHeight) {
function setupSabrScheme(sabrData, getPlayer, getManifest, getWidth, getHeight, mintPoToken) {
var ShakaAbortableOperation = shaka().util.AbortableOperation;
var ShakaError = shaka().util.Error;
var protos = gv().protos;
@ -669,6 +748,8 @@
sabrStreamState: sabrStreamState,
timeoutController: timeoutController,
eventEmitter: eventEmitter,
mintPoToken: mintPoToken,
attestationRetries: 0,
cumulativeBackOffTimeMs: 0,
cumulativeBackOffRequested: 0,
cumulativeRetryDueToNextRequestPolicy: 0

View File

@ -6,9 +6,9 @@
"bundle-sabr": "node scripts/bundle-sabr-libs.js"
},
"dependencies": {
"googlevideo": "^4.0.4",
"youtubei.js": "^17.0.1",
"bgutils-js": "^3.2.0"
"googlevideo": "^4.1.1",
"youtubei.js": "^17.2.0",
"bgutils-js": "^4.0.2"
},
"devDependencies": {
"esbuild": "^0.27.2"

View File

@ -52,7 +52,9 @@ module Invidious::Routes::BeforeAll
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self' https://*.googleapis.com https://*.youtube.com " + COMPANION_CSP.companion_urls,
# http://127.0.0.1:4416 = bg-helper-server (/generate) for server-side (youtube.com-origin
# jsdom) BotGuard PO token minting, which browser-origin BotGuard cannot attest.
"connect-src 'self' https://*.googleapis.com https://*.youtube.com http://127.0.0.1:4416 " + COMPANION_CSP.companion_urls,
"manifest-src 'self'",
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
"child-src 'self' blob:",

View File

@ -40,6 +40,10 @@ module Invidious::Routes::Proxy
/^redirector\.googlevideo\.com$/,
/^jnn-pa\.googleapis\.com$/,
/^play\.googleapis\.com$/,
# BotGuard interpreter JS is served from www.google.com / gstatic (session-bound
# PO token attestation via the InnerTube att/get flow).
/(^|\.)google\.com$/,
/(^|\.)gstatic\.com$/,
]
def self.is_host_allowed?(host : String) : Bool
@ -174,6 +178,9 @@ module Invidious::Routes::Proxy
# mid-stream (backoff / reload / seek).
begin
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
# Route the googlevideo/youtubei egress through the configured HTTP proxy when
# set (e.g. to bypass a YouTube IP block on the instance's own address).
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
client.connect_timeout = 10.seconds
client.read_timeout = 30.seconds
# Don't let HTTP::Client advertise its own Accept-Encoding; we forward the