mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-27 16:06:47 -05:00
Replace the plain /youtubei/v1/player request (made through the proxy, and therefore bound to the proxy's egress IP) with an encrypted Onesie request using the WEB client. Onesie is proxied by YouTube's "trusted bandaid", so the returned player response - and the server_abr_streaming_url inside it - is not tied to our egress IP. Media is still pulled over SABR (sabr_scheme_plugin.js); only how the player response is obtained changes. - sabr_onesie.js: new module, window.fetchOnesiePlayerResponse(). Builds the WEB player request, encrypts it (OnesieInnertubeRequest + OnesieRequest), POSTs to the onesie endpoint through /proxy, parses ONESIE_HEADER/ONESIE_DATA UMP parts, gunzips/decrypts, returns the raw player response JSON. Adapted from googlevideo/examples/onesie-request and invidious-secret-companion's WEB-client variant. - sabr_helpers.js: add decryptResponse() (AES-CTR + HMAC verify), companion to the existing encryptRequest(). - sabr_loader.js: expose window.YT so a VideoInfo can be built from the raw onesie player response. - sabr_player.js: in loadVideo(), fetch the player response via Onesie and wrap it in new YT.VideoInfo(...); fall back to innertube.getInfo() on failure. - player.ecr: load sabr_onesie.js before sabr_player.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
8.4 KiB
JavaScript
212 lines
8.4 KiB
JavaScript
/**
|
|
* SABR Onesie - fetch the YouTube player response through an encrypted Onesie
|
|
* request (WEB client), instead of a plain /youtubei/v1/player call.
|
|
*
|
|
* Why: a normal player request is bound to the IP that made it, so the
|
|
* server_abr_streaming_url it returns only works from that same IP. An Onesie
|
|
* request is proxied by YouTube's "trusted bandaid", so the player response
|
|
* (and the SABR streaming URL within it) is not tied to our proxy's egress IP.
|
|
* Subsequent media is still pulled with SABR (see sabr_scheme_plugin.js) - this
|
|
* module only replaces how the player response itself is obtained.
|
|
*
|
|
* Adapted from googlevideo/examples/onesie-request/main.ts and the WEB-client
|
|
* variant in invidious-secret-companion (override/onesiePlayerReq.ts).
|
|
*
|
|
* Reads googlevideo protos/ump/utils from window.googlevideo, crypto helpers
|
|
* from window.SABRHelpers, and Constants from window.Constants.
|
|
* Exposes window.fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
(function () {
|
|
// Public WEB player API key used for the inner player request.
|
|
var PLAYER_API_KEY = 'AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8';
|
|
|
|
function gv() { return window.googlevideo; }
|
|
|
|
// Concatenate a UMP part's chunks into a single Uint8Array.
|
|
function partBytes(part) {
|
|
var chunks = part.data.chunks;
|
|
if (chunks.length === 1) return chunks[0];
|
|
return gv().utils.concatenateChunks(chunks);
|
|
}
|
|
|
|
// Ensure we have a googlevideo redirector base (e.g. https://rrX---snXXX.googlevideo.com).
|
|
// Reuses the value preloaded into localStorage by SABRPlayer.initInnertube.
|
|
async function getRedirectorBase() {
|
|
var stored = null;
|
|
try { stored = localStorage.getItem(SABRHelpers.REDIRECTOR_STORAGE_KEY); } catch (e) {}
|
|
if (!stored || !stored.startsWith('https://')) {
|
|
var resp = await SABRHelpers.fetchWithProxy(
|
|
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1e5),
|
|
{ method: 'GET' }
|
|
);
|
|
stored = (await resp.text()).trim();
|
|
if (stored.startsWith('https://')) {
|
|
try { localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, stored); } catch (e) {}
|
|
}
|
|
}
|
|
if (!stored.startsWith('https://')) throw new Error('Invalid redirector response');
|
|
return stored.split('/initplayback')[0];
|
|
}
|
|
|
|
// Build the encrypted Onesie request body + the hex-encoded video id.
|
|
async function prepareOnesieRequest(innertube, videoId, poToken, clientConfig) {
|
|
var protos = gv().protos;
|
|
var base64ToU8 = gv().utils.base64ToU8;
|
|
|
|
// Shallow clone of the session context, deep-copying only `client` since
|
|
// that's all we mutate (force WEB client - matches the secret-companion).
|
|
var ctx = Object.assign({}, innertube.session.context);
|
|
ctx.client = Object.assign({}, innertube.session.context.client, {
|
|
clientName: Constants.CLIENTS.WEB.NAME,
|
|
clientVersion: Constants.CLIENTS.WEB.VERSION
|
|
});
|
|
|
|
var playerRequestJson = {
|
|
context: ctx,
|
|
playbackContext: {
|
|
contentPlaybackContext: {
|
|
vis: 0,
|
|
splay: false,
|
|
lactMilliseconds: '-1',
|
|
signatureTimestamp: innertube.session.player && innertube.session.player.signature_timestamp
|
|
}
|
|
},
|
|
videoId: videoId,
|
|
racyCheckOk: true,
|
|
contentCheckOk: true
|
|
};
|
|
|
|
if (poToken) {
|
|
playerRequestJson.serviceIntegrityDimensions = { poToken: poToken };
|
|
}
|
|
|
|
var headers = [
|
|
{ name: 'Content-Type', value: 'application/json' },
|
|
{ name: 'User-Agent', value: ctx.client.userAgent },
|
|
{ name: 'X-Goog-Visitor-Id', value: ctx.client.visitorData }
|
|
];
|
|
|
|
var onesieInnertubeRequest = protos.OnesieInnertubeRequest.encode({
|
|
url: 'https://youtubei.googleapis.com/youtubei/v1/player?key=' + PLAYER_API_KEY,
|
|
headers: headers,
|
|
body: JSON.stringify(playerRequestJson),
|
|
proxiedByTrustedBandaid: true,
|
|
skipResponseEncryption: true
|
|
}).finish();
|
|
|
|
var enc = await SABRHelpers.encryptRequest(clientConfig.clientKeyData, onesieInnertubeRequest);
|
|
|
|
var body = protos.OnesieRequest.encode({
|
|
urls: [],
|
|
innertubeRequest: {
|
|
enableCompression: true,
|
|
encryptedClientKey: clientConfig.encryptedClientKey,
|
|
encryptedOnesieInnertubeRequest: enc.encrypted,
|
|
hmac: enc.hmac,
|
|
iv: enc.iv,
|
|
useJsonformatterToParsePlayerResponse: false,
|
|
serializeResponseAsJson: true
|
|
},
|
|
streamerContext: {
|
|
sabrContexts: [],
|
|
unsentSabrContexts: [],
|
|
poToken: poToken ? base64ToU8(poToken) : undefined,
|
|
playbackCookie: undefined,
|
|
clientInfo: {
|
|
clientName: parseInt(Constants.CLIENT_NAME_IDS[Constants.CLIENTS.WEB.NAME], 10),
|
|
clientVersion: Constants.CLIENTS.WEB.VERSION
|
|
}
|
|
},
|
|
bufferedRanges: [],
|
|
onesieUstreamerConfig: clientConfig.onesieUstreamerConfig
|
|
}).finish();
|
|
|
|
// The `id` query param is the hex of the (base64-decoded) video id.
|
|
var videoIdBytes = base64ToU8(videoId);
|
|
var hex = [];
|
|
for (var i = 0; i < videoIdBytes.length; i++) {
|
|
hex.push(videoIdBytes[i].toString(16).padStart(2, '0'));
|
|
}
|
|
|
|
return { body: body, encodedVideoId: hex.join('') };
|
|
}
|
|
|
|
/**
|
|
* Fetch and decode the player response JSON via Onesie.
|
|
* @returns {Promise<Object>} the raw /player response JSON
|
|
*/
|
|
async function fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig) {
|
|
if (!clientConfig) throw new Error('Onesie client config not available');
|
|
var protos = gv().protos;
|
|
var ump = gv().ump;
|
|
|
|
var base = await getRedirectorBase();
|
|
var prepared = await prepareOnesieRequest(innertube, videoId, poToken, clientConfig);
|
|
|
|
var url = base + clientConfig.baseUrl +
|
|
'&id=' + prepared.encodedVideoId +
|
|
'&cmo:sensitive_content=yes' +
|
|
'&opr=1' + // Onesie Playback Request
|
|
'&osts=0' + // Onesie Start Time Seconds
|
|
'&por=1' +
|
|
'&rn=0';
|
|
|
|
var resp = await SABRHelpers.fetchWithProxy(url, {
|
|
method: 'POST',
|
|
headers: { 'accept': '*/*', 'content-type': 'application/octet-stream' },
|
|
body: prepared.body
|
|
});
|
|
var buffer = new Uint8Array(await resp.arrayBuffer());
|
|
|
|
// Parse the UMP stream: collect ONESIE_HEADER parts and attach their data.
|
|
var onesie = [];
|
|
new ump.UmpReader(new ump.CompositeBuffer([buffer])).read(function (part) {
|
|
if (part.type === protos.UMPPartId.ONESIE_HEADER) {
|
|
onesie.push(protos.OnesieHeader.decode(partBytes(part)));
|
|
} else if (part.type === protos.UMPPartId.ONESIE_DATA) {
|
|
if (onesie.length) onesie[onesie.length - 1].data = partBytes(part);
|
|
} else if (part.type === protos.UMPPartId.SABR_ERROR) {
|
|
try { console.error('[SABROnesie] SABR_ERROR', protos.SabrError.decode(partBytes(part))); } catch (e) {}
|
|
}
|
|
});
|
|
|
|
var header = null;
|
|
for (var i = 0; i < onesie.length; i++) {
|
|
if (onesie[i].type === protos.OnesieHeaderType.ONESIE_PLAYER_RESPONSE) { header = onesie[i]; break; }
|
|
}
|
|
if (!header) throw new Error('Onesie player response not found');
|
|
if (!header.cryptoParams) throw new Error('Onesie crypto params not found');
|
|
|
|
var responseData = header.data;
|
|
|
|
// Decompress (gzip) if requested.
|
|
if (responseData && header.cryptoParams.compressionType === protos.CompressionType.GZIP) {
|
|
var ds = new DecompressionStream('gzip');
|
|
var stream = new Blob([responseData]).stream().pipeThrough(ds);
|
|
responseData = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
}
|
|
|
|
// Decrypt only if the response was encrypted (skipResponseEncryption=true means it usually isn't).
|
|
var iv = header.cryptoParams.iv;
|
|
var hmac = header.cryptoParams.hmac;
|
|
var decrypted = (hmac && hmac.length && iv && iv.length)
|
|
? await SABRHelpers.decryptResponse(iv, hmac, responseData, clientConfig.clientKeyData)
|
|
: responseData;
|
|
|
|
var innerResponse = protos.OnesieInnertubeResponse.decode(decrypted);
|
|
if (innerResponse.onesieProxyStatus !== protos.OnesieProxyStatus.OK) {
|
|
throw new Error('Onesie proxy status not OK (' + innerResponse.onesieProxyStatus + ')');
|
|
}
|
|
if (innerResponse.httpStatus !== 200) {
|
|
throw new Error('Onesie player HTTP status ' + innerResponse.httpStatus);
|
|
}
|
|
|
|
return JSON.parse(new TextDecoder().decode(innerResponse.body));
|
|
}
|
|
|
|
window.fetchOnesiePlayerResponse = fetchOnesiePlayerResponse;
|
|
})();
|