mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-17 05:20:51 -05:00
fix: use external bg solver
This commit is contained in:
parent
2cc3b61cb7
commit
2b32cdb4f3
@ -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 */
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user