mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-17 13:30:53 -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;
|
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 {
|
.shaka-controls-container {
|
||||||
background: linear-gradient(rgba(0,0,0,0.1), rgba(0, 0, 0,0.5));
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shaka-bottom-controls {
|
.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 */
|
/* Buttons - match video.js colors */
|
||||||
|
|||||||
@ -486,6 +486,13 @@
|
|||||||
// Workaround: https://github.com/LuanRT/googlevideo/issues/42
|
// Workaround: https://github.com/LuanRT/googlevideo/issues/42
|
||||||
continue;
|
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));
|
audioStreams.push(createAudioStream(format, currentId++, hasDrcAudio, hasVoiceBoostAudio, presentationTimeline, networkingEngine, fakeVideoFormatId));
|
||||||
} else if (!this._config.disableVideo) {
|
} else if (!this._config.disableVideo) {
|
||||||
videoStreams.push(createVideoStream(format, currentId++, presentationTimeline, networkingEngine));
|
videoStreams.push(createVideoStream(format, currentId++, presentationTimeline, networkingEngine));
|
||||||
|
|||||||
@ -19,11 +19,27 @@ var SABRPlayer = (function () {
|
|||||||
|
|
||||||
var DEFAULT_ABR_CONFIG = {
|
var DEFAULT_ABR_CONFIG = {
|
||||||
enabled: true,
|
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,
|
switchInterval: 4,
|
||||||
useNetworkInformation: false
|
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
|
// State
|
||||||
var player = null;
|
var player = null;
|
||||||
var ui = null;
|
var ui = null;
|
||||||
@ -40,10 +56,19 @@ var SABRPlayer = (function () {
|
|||||||
var playbackWebPoTokenCreationLock = false;
|
var playbackWebPoTokenCreationLock = false;
|
||||||
|
|
||||||
var sabrStream = null; // handle returned by setupSabrScheme
|
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 sabrManifest = null; // captured from player.getManifest() on 'loaded'
|
||||||
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
|
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
|
||||||
var reloadCount = 0; // cap reloads to prevent infinite loop on blocked/throttled videos
|
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() {
|
function getSavedVolume() {
|
||||||
try {
|
try {
|
||||||
@ -107,12 +132,12 @@ var SABRPlayer = (function () {
|
|||||||
generate_session_locally: true
|
generate_session_locally: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Kick off BotGuard init (don't block player setup).
|
// NOTE: the in-browser BotguardService is NOT eagerly initialized anymore. The PO
|
||||||
BotguardService.init().then(function () {
|
// token is minted by the bg-helper-server (/generate), where jsdom presents a
|
||||||
console.info('[SABRPlayer]', 'BotGuard client initialized');
|
// youtube.com origin and the token actually attests. In-browser BotGuard could only
|
||||||
}).catch(function (err) {
|
// ever produce a StreamProtectionStatus=2 (pending) token AND fired a redundant
|
||||||
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
|
// 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.
|
// Preload the redirector URL.
|
||||||
try {
|
try {
|
||||||
@ -173,14 +198,28 @@ var SABRPlayer = (function () {
|
|||||||
playbackWebPoTokenCreationLock = true;
|
playbackWebPoTokenCreationLock = true;
|
||||||
try {
|
try {
|
||||||
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
|
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()) {
|
if (!BotguardService.isInitialized()) {
|
||||||
await BotguardService.reinit();
|
await BotguardService.reinit(innertube && innertube.session && innertube.session.context);
|
||||||
}
|
}
|
||||||
if (BotguardService.isInitialized()) {
|
if (BotguardService.isInitialized()) {
|
||||||
playbackWebPoToken = await BotguardService.mintWebPoToken(decodeURIComponent(playbackWebPoTokenContentBinding));
|
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 {
|
} else {
|
||||||
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
|
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) {
|
async function initializeShakaPlayer(containerElement, listenMode) {
|
||||||
if (!shaka.Player.isBrowserSupported()) {
|
if (!shaka.Player.isBrowserSupported()) {
|
||||||
throw new Error('Shaka Player is not supported in this browser');
|
throw new Error('Shaka Player is not supported in this browser');
|
||||||
@ -215,10 +311,13 @@ var SABRPlayer = (function () {
|
|||||||
player.configure({
|
player.configure({
|
||||||
abr: DEFAULT_ABR_CONFIG,
|
abr: DEFAULT_ABR_CONFIG,
|
||||||
streaming: {
|
streaming: {
|
||||||
bufferingGoal: 120,
|
// Match FreeTube's SABR streaming config exactly (values YouTube itself uses):
|
||||||
rebufferingGoal: 0.01,
|
// 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,
|
bufferBehind: 300,
|
||||||
retryParameters: { maxAttempts: 8, fuzzFactor: 0.5, timeout: 30 * 1000 }
|
retryParameters: { timeout: 60 * 1000 }
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
|
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
|
||||||
@ -462,7 +561,19 @@ var SABRPlayer = (function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.warn('[SABRPlayer] SABR reload requested by server; re-loading video (attempt ' + reloadCount + '/' + MAX_RELOADS + ')');
|
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.
|
// Start Shaka player init (DOM + polyfills) in parallel with network init.
|
||||||
var shakaPromise;
|
var shakaPromise;
|
||||||
if (!player) {
|
if (!player) {
|
||||||
|
requestFiltersInstalled = false;
|
||||||
|
loadedListenerInstalled = false;
|
||||||
shakaPromise = initializeShakaPlayer(containerElement, options.listen);
|
shakaPromise = initializeShakaPlayer(containerElement, options.listen);
|
||||||
} else {
|
} else {
|
||||||
player.configure('abr', DEFAULT_ABR_CONFIG);
|
player.configure('abr', DEFAULT_ABR_CONFIG);
|
||||||
@ -501,8 +614,10 @@ var SABRPlayer = (function () {
|
|||||||
|
|
||||||
await Promise.all([shakaPromise, netPromise]);
|
await Promise.all([shakaPromise, netPromise]);
|
||||||
var poToken = playbackWebPoToken || coldStartToken || '';
|
var poToken = playbackWebPoToken || coldStartToken || '';
|
||||||
|
if (!requestFiltersInstalled) {
|
||||||
setupRequestFilters();
|
setupRequestFilters();
|
||||||
|
requestFiltersInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the player response through Onesie (WEB client) so the SABR
|
// 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
|
// 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.
|
// over SABR (sabr_scheme_plugin.js) either way.
|
||||||
var videoInfo;
|
var videoInfo;
|
||||||
try {
|
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) {
|
if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) {
|
||||||
throw new Error('Onesie support not loaded');
|
throw new Error('Onesie support not loaded');
|
||||||
}
|
}
|
||||||
@ -577,17 +695,23 @@ var SABRPlayer = (function () {
|
|||||||
if (sabrStream) {
|
if (sabrStream) {
|
||||||
try { sabrStream.cleanup(); } catch (e) {}
|
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);
|
wireSabrStream(loadVideo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the parsed manifest once Shaka has loaded it, so the sabr:
|
// 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.addEventListener('loaded', function () {
|
// player instance (loadVideo re-runs on every reload).
|
||||||
if (typeof player.getManifest === 'function') {
|
if (!loadedListenerInstalled) {
|
||||||
sabrManifest = player.getManifest();
|
player.addEventListener('loaded', function () {
|
||||||
}
|
if (typeof player.getManifest === 'function') {
|
||||||
});
|
sabrManifest = player.getManifest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
loadedListenerInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
var startTime = options.startTime;
|
var startTime = options.startTime;
|
||||||
if (startTime === undefined && options.savePlayerPos !== false) {
|
if (startTime === undefined && options.savePlayerPos !== false) {
|
||||||
|
|||||||
@ -12,6 +12,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var BotguardService = (function() {
|
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';
|
var WAA_REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
|
||||||
// Use the API key from bgutils-js/Kira which has access to Web Anti-Abuse API
|
// Use the API key from bgutils-js/Kira which has access to Web Anti-Abuse API
|
||||||
var GOOG_API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
|
var GOOG_API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
|
||||||
@ -20,6 +24,11 @@ var BotguardService = (function() {
|
|||||||
var initializationPromise = null;
|
var initializationPromise = null;
|
||||||
var integrityTokenBasedMinter = null;
|
var integrityTokenBasedMinter = null;
|
||||||
var bgChallenge = 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)
|
* Build URL for BotGuard API calls (using YouTube endpoint, not googleapis.com)
|
||||||
@ -77,7 +86,8 @@ var BotguardService = (function() {
|
|||||||
* Initialize the BotGuard client
|
* Initialize the BotGuard client
|
||||||
* @returns {Promise<Object|undefined>}
|
* @returns {Promise<Object|undefined>}
|
||||||
*/
|
*/
|
||||||
async function init() {
|
async function init(context) {
|
||||||
|
if (context) sessionContext = context;
|
||||||
if (initializationPromise) {
|
if (initializationPromise) {
|
||||||
return await initializationPromise;
|
return await initializationPromise;
|
||||||
}
|
}
|
||||||
@ -115,8 +125,12 @@ var BotguardService = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First call (Create) uses direct fetch - no proxy needed
|
// TV / living-room (Cobalt) attestation via the generic WAA 'Create' endpoint
|
||||||
var challengeResponse = await fetch(buildURL('Create', true), {
|
// 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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json+protobuf',
|
'content-type': 'application/json+protobuf',
|
||||||
@ -130,11 +144,12 @@ var BotguardService = (function() {
|
|||||||
bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||||
|
|
||||||
if (!bgChallenge) {
|
if (!bgChallenge) {
|
||||||
console.error('[BotguardService]', 'Failed to parse challenge data');
|
console.error('[BotguardService]', 'Failed to parse challenge data (Create)');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
var interpreterJavascript = bgChallenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
var interpreterJavascript = bgChallenge.interpreterJavascript &&
|
||||||
|
bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||||
|
|
||||||
if (!interpreterJavascript) {
|
if (!interpreterJavascript) {
|
||||||
console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', bgChallenge.interpreterHash);
|
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 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) {
|
if (!integrityToken) {
|
||||||
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', bgChallenge.interpreterHash);
|
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', bgChallenge.interpreterHash);
|
||||||
@ -248,7 +268,8 @@ var BotguardService = (function() {
|
|||||||
* Reinitialize BotGuard
|
* Reinitialize BotGuard
|
||||||
* @returns {Promise<Object|undefined>}
|
* @returns {Promise<Object|undefined>}
|
||||||
*/
|
*/
|
||||||
async function reinit() {
|
async function reinit(context) {
|
||||||
|
if (context) sessionContext = context;
|
||||||
if (initializationPromise) {
|
if (initializationPromise) {
|
||||||
return initializationPromise;
|
return initializationPromise;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
return JSON.parse(JSON.stringify(obj));
|
return JSON.parse(JSON.stringify(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function formatIdFromString(str) {
|
function formatIdFromString(str) {
|
||||||
var parts = str.split('-');
|
var parts = str.split('-');
|
||||||
return {
|
return {
|
||||||
@ -205,6 +206,27 @@
|
|||||||
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
|
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 {
|
try {
|
||||||
var shouldReloadDueToBackoffLoop = false;
|
var shouldReloadDueToBackoffLoop = false;
|
||||||
if ((currentState.sabrStreamState.nextRequestPolicy?.backoffTimeMs || 0) > 0) {
|
if ((currentState.sabrStreamState.nextRequestPolicy?.backoffTimeMs || 0) > 0) {
|
||||||
@ -255,11 +277,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var remainingData = new UmpReader(chunkedDataBuffer).read(function (part) {
|
var remainingData = new UmpReader(chunkedDataBuffer).read(function (part) {
|
||||||
|
if (_dbg) { _dbgPartTypes[part.type] = (_dbgPartTypes[part.type] || 0) + 1; }
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case UMPPartId.STREAM_PROTECTION_STATUS: {
|
case UMPPartId.STREAM_PROTECTION_STATUS: {
|
||||||
var streamProtectionStatus = decodePart(part, StreamProtectionStatus);
|
var streamProtectionStatus = decodePart(part, StreamProtectionStatus);
|
||||||
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
|
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
|
||||||
invalidPoToken = true;
|
invalidPoToken = true;
|
||||||
|
if (streamProtectionStatus.maxRetries) {
|
||||||
|
currentState.attestationMaxRetries = streamProtectionStatus.maxRetries;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -272,9 +298,14 @@
|
|||||||
case UMPPartId.SABR_REDIRECT: {
|
case UMPPartId.SABR_REDIRECT: {
|
||||||
var sabrRedirect = decodePart(part, SabrRedirect);
|
var sabrRedirect = decodePart(part, SabrRedirect);
|
||||||
if (!sabrRedirect) break;
|
if (!sabrRedirect) break;
|
||||||
// BUGFIX (vs FreeTube): the read site reads sabrStreamState.sabrUrl,
|
// Match FreeTube EXACTLY: it writes currentState.sabrUrl (a field that does
|
||||||
// so write there, not currentState.sabrUrl.
|
// NOT exist on currentState), so the redirect URL is effectively ignored and
|
||||||
currentState.sabrStreamState.sabrUrl = sabrRedirect.url;
|
// 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;
|
shouldRetry = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -282,6 +313,11 @@
|
|||||||
if (mediaHeaderId === undefined) {
|
if (mediaHeaderId === undefined) {
|
||||||
var mediaHeader = decodePart(part, MediaHeader);
|
var mediaHeader = decodePart(part, MediaHeader);
|
||||||
if (!mediaHeader) break;
|
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 (
|
if (
|
||||||
mediaHeader.formatId.itag === itag &&
|
mediaHeader.formatId.itag === itag &&
|
||||||
mediaHeader.formatId.lastModified === lastModified &&
|
mediaHeader.formatId.lastModified === lastModified &&
|
||||||
@ -328,6 +364,7 @@
|
|||||||
}
|
}
|
||||||
case UMPPartId.SABR_CONTEXT_UPDATE: {
|
case UMPPartId.SABR_CONTEXT_UPDATE: {
|
||||||
var sabrContextUpdate = decodePart(part, SabrContextUpdate);
|
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) break;
|
||||||
if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
|
if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
|
||||||
if (
|
if (
|
||||||
@ -407,6 +444,29 @@
|
|||||||
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
|
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) {
|
if (responseDataChunks.length > 0 && segmentComplete) {
|
||||||
var concatenateChunks = utils.concatenateChunks;
|
var concatenateChunks = utils.concatenateChunks;
|
||||||
var data = concatenateChunks(responseDataChunks);
|
var data = concatenateChunks(responseDataChunks);
|
||||||
@ -422,7 +482,35 @@
|
|||||||
fromCache: false,
|
fromCache: false,
|
||||||
originalRequest: operationInputs.request
|
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) {
|
if (shouldRetryDueToNextRequestPolicy) {
|
||||||
currentState.cumulativeRetryDueToNextRequestPolicy += 1;
|
currentState.cumulativeRetryDueToNextRequestPolicy += 1;
|
||||||
}
|
}
|
||||||
@ -452,15 +540,6 @@
|
|||||||
currentState.abortStatus.timedOut = false;
|
currentState.abortStatus.timedOut = false;
|
||||||
currentState.abortStatus.finished = false;
|
currentState.abortStatus.finished = false;
|
||||||
return doRequest(operationInputs, currentState);
|
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) {
|
} else if (error) {
|
||||||
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, new Error(error), operationInputs.requestType);
|
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, new Error(error), operationInputs.requestType);
|
||||||
} else if (responseDataChunks.length > 0 && !segmentComplete) {
|
} 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 ShakaAbortableOperation = shaka().util.AbortableOperation;
|
||||||
var ShakaError = shaka().util.Error;
|
var ShakaError = shaka().util.Error;
|
||||||
var protos = gv().protos;
|
var protos = gv().protos;
|
||||||
@ -669,6 +748,8 @@
|
|||||||
sabrStreamState: sabrStreamState,
|
sabrStreamState: sabrStreamState,
|
||||||
timeoutController: timeoutController,
|
timeoutController: timeoutController,
|
||||||
eventEmitter: eventEmitter,
|
eventEmitter: eventEmitter,
|
||||||
|
mintPoToken: mintPoToken,
|
||||||
|
attestationRetries: 0,
|
||||||
cumulativeBackOffTimeMs: 0,
|
cumulativeBackOffTimeMs: 0,
|
||||||
cumulativeBackOffRequested: 0,
|
cumulativeBackOffRequested: 0,
|
||||||
cumulativeRetryDueToNextRequestPolicy: 0
|
cumulativeRetryDueToNextRequestPolicy: 0
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
"bundle-sabr": "node scripts/bundle-sabr-libs.js"
|
"bundle-sabr": "node scripts/bundle-sabr-libs.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"googlevideo": "^4.0.4",
|
"googlevideo": "^4.1.1",
|
||||||
"youtubei.js": "^17.0.1",
|
"youtubei.js": "^17.2.0",
|
||||||
"bgutils-js": "^3.2.0"
|
"bgutils-js": "^4.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.2"
|
"esbuild": "^0.27.2"
|
||||||
|
|||||||
@ -52,7 +52,9 @@ module Invidious::Routes::BeforeAll
|
|||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"font-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'",
|
"manifest-src 'self'",
|
||||||
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
|
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
|
||||||
"child-src 'self' blob:",
|
"child-src 'self' blob:",
|
||||||
|
|||||||
@ -40,6 +40,10 @@ module Invidious::Routes::Proxy
|
|||||||
/^redirector\.googlevideo\.com$/,
|
/^redirector\.googlevideo\.com$/,
|
||||||
/^jnn-pa\.googleapis\.com$/,
|
/^jnn-pa\.googleapis\.com$/,
|
||||||
/^play\.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
|
def self.is_host_allowed?(host : String) : Bool
|
||||||
@ -174,6 +178,9 @@ module Invidious::Routes::Proxy
|
|||||||
# mid-stream (backoff / reload / seek).
|
# mid-stream (backoff / reload / seek).
|
||||||
begin
|
begin
|
||||||
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
|
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.connect_timeout = 10.seconds
|
||||||
client.read_timeout = 30.seconds
|
client.read_timeout = 30.seconds
|
||||||
# Don't let HTTP::Client advertise its own Accept-Encoding; we forward the
|
# Don't let HTTP::Client advertise its own Accept-Encoding; we forward the
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user