From 2b32cdb4f357ee1247f5b2840bbe1105e4993ccc Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:53:46 +0200 Subject: [PATCH] fix: use external bg solver --- assets/css/sabr_player.css | 10 +- assets/js/sabr_manifest_parser.js | 7 ++ assets/js/sabr_player.js | 172 +++++++++++++++++++++++++---- assets/js/sabr_potoken.js | 35 ++++-- assets/js/sabr_scheme_plugin.js | 109 +++++++++++++++--- package.json | 6 +- src/invidious/routes/before_all.cr | 4 +- src/invidious/routes/proxy.cr | 7 ++ 8 files changed, 298 insertions(+), 52 deletions(-) diff --git a/assets/css/sabr_player.css b/assets/css/sabr_player.css index 318d583b..6495f53a 100644 --- a/assets/css/sabr_player.css +++ b/assets/css/sabr_player.css @@ -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 */ diff --git a/assets/js/sabr_manifest_parser.js b/assets/js/sabr_manifest_parser.js index 3bdb38fe..71335a3e 100644 --- a/assets/js/sabr_manifest_parser.js +++ b/assets/js/sabr_manifest_parser.js @@ -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)); diff --git a/assets/js/sabr_player.js b/assets/js/sabr_player.js index 3d780d29..4e587d08 100644 --- a/assets/js/sabr_player.js +++ b/assets/js/sabr_player.js @@ -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 || ''; - - setupRequestFilters(); + 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. - player.addEventListener('loaded', function () { - if (typeof player.getManifest === 'function') { - sabrManifest = player.getManifest(); - } - }); + // 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) { diff --git a/assets/js/sabr_potoken.js b/assets/js/sabr_potoken.js index 9bb75072..6211b731 100644 --- a/assets/js/sabr_potoken.js +++ b/assets/js/sabr_potoken.js @@ -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} */ - 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 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} */ - async function reinit() { + async function reinit(context) { + if (context) sessionContext = context; if (initializationPromise) { return initializationPromise; } diff --git a/assets/js/sabr_scheme_plugin.js b/assets/js/sabr_scheme_plugin.js index 4b3d54b4..81052cf3 100644 --- a/assets/js/sabr_scheme_plugin.js +++ b/assets/js/sabr_scheme_plugin.js @@ -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 diff --git a/package.json b/package.json index b545e0bf..33526587 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index df2472c8..199b2b5d 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -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:", diff --git a/src/invidious/routes/proxy.cr b/src/invidious/routes/proxy.cr index 734be1e8..0b331277 100644 --- a/src/invidious/routes/proxy.cr +++ b/src/invidious/routes/proxy.cr @@ -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