mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-28 00:16:47 -05:00
feat(sabr): fetch player response via Onesie (WEB client)
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>
This commit is contained in:
parent
e3b3e68380
commit
105a69d9ea
@ -75,6 +75,54 @@ async function encryptRequest(clientKey, data) {
|
|||||||
return { encrypted: encrypted, hmac: hmac, iv: iv };
|
return { encrypted: encrypted, hmac: hmac, iv: iv };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an (optionally) encrypted Onesie response with AES-CTR and verify
|
||||||
|
* its HMAC-SHA256. Mirrors googlevideo/examples/onesie-request/utils.ts.
|
||||||
|
* @param {Uint8Array} iv
|
||||||
|
* @param {Uint8Array} hmac
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @param {Uint8Array} clientKeyData - 32-byte client key (16B AES + 16B HMAC)
|
||||||
|
* @returns {Promise<Uint8Array>}
|
||||||
|
*/
|
||||||
|
async function decryptResponse(iv, hmac, data, clientKeyData) {
|
||||||
|
if (!iv || !hmac || !data || !clientKeyData) {
|
||||||
|
throw new Error('Invalid input to decryptResponse');
|
||||||
|
}
|
||||||
|
|
||||||
|
var aesKey = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
clientKeyData.slice(0, 16),
|
||||||
|
{ name: 'AES-CTR', length: 128 },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
var decryptedData = new Uint8Array(await window.crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-CTR', counter: iv, length: 128 },
|
||||||
|
aesKey,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
|
||||||
|
var hmacKey = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
clientKeyData.slice(16, 32),
|
||||||
|
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
var dataToVerify = new Uint8Array(data.length + iv.length);
|
||||||
|
dataToVerify.set(data, 0);
|
||||||
|
dataToVerify.set(iv, data.length);
|
||||||
|
|
||||||
|
var isValid = await window.crypto.subtle.verify('HMAC', hmacKey, hmac, dataToVerify);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('HMAC verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return decryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Onesie client config is still valid
|
* Check if Onesie client config is still valid
|
||||||
* @param {Object} config - Client config object
|
* @param {Object} config - Client config object
|
||||||
@ -324,6 +372,7 @@ function generateRandomString(length) {
|
|||||||
window.SABRHelpers = {
|
window.SABRHelpers = {
|
||||||
getProxyConfig: getProxyConfig,
|
getProxyConfig: getProxyConfig,
|
||||||
encryptRequest: encryptRequest,
|
encryptRequest: encryptRequest,
|
||||||
|
decryptResponse: decryptResponse,
|
||||||
isConfigValid: isConfigValid,
|
isConfigValid: isConfigValid,
|
||||||
loadCachedClientConfig: loadCachedClientConfig,
|
loadCachedClientConfig: loadCachedClientConfig,
|
||||||
saveCachedClientConfig: saveCachedClientConfig,
|
saveCachedClientConfig: saveCachedClientConfig,
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
* onto window.googlevideo so the plain-script plugin can read them at call time.
|
* onto window.googlevideo so the plain-script plugin can read them at call time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Innertube, { Platform, Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
import Innertube, { Platform, Constants, YT } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
||||||
import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
|
import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
|
||||||
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
||||||
|
|
||||||
@ -18,6 +18,9 @@ import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
|||||||
window.Innertube = Innertube;
|
window.Innertube = Innertube;
|
||||||
window.Platform = Platform;
|
window.Platform = Platform;
|
||||||
window.Constants = Constants;
|
window.Constants = Constants;
|
||||||
|
// YT.VideoInfo: build a VideoInfo from a raw player response fetched via the
|
||||||
|
// onesie path (sabr_onesie.js) instead of innertube.getInfo().
|
||||||
|
window.YT = YT;
|
||||||
|
|
||||||
// googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
|
// googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
|
||||||
window.googlevideo = googlevideo;
|
window.googlevideo = googlevideo;
|
||||||
|
|||||||
211
assets/js/sabr_onesie.js
Normal file
211
assets/js/sabr_onesie.js
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
})();
|
||||||
@ -504,7 +504,26 @@ var SABRPlayer = (function () {
|
|||||||
|
|
||||||
setupRequestFilters();
|
setupRequestFilters();
|
||||||
|
|
||||||
var videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
|
// 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
|
||||||
|
// plain getInfo() if the onesie path is unavailable. Media still flows
|
||||||
|
// over SABR (sabr_scheme_plugin.js) either way.
|
||||||
|
var videoInfo;
|
||||||
|
try {
|
||||||
|
if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) {
|
||||||
|
throw new Error('Onesie support not loaded');
|
||||||
|
}
|
||||||
|
var playerResponse = await window.fetchOnesiePlayerResponse(innertube, videoId, poToken || undefined, clientConfig);
|
||||||
|
videoInfo = new window.YT.VideoInfo(
|
||||||
|
[{ success: true, status_code: 200, data: playerResponse }],
|
||||||
|
innertube.actions,
|
||||||
|
SABRHelpers.generateRandomString(16)
|
||||||
|
);
|
||||||
|
console.info('[SABRPlayer]', 'Player response fetched via Onesie (WEB)');
|
||||||
|
} catch (onesieErr) {
|
||||||
|
console.warn('[SABRPlayer]', 'Onesie player request failed, falling back to getInfo:', onesieErr);
|
||||||
|
videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
|
||||||
|
}
|
||||||
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
|
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
|
||||||
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
|
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
|
||||||
throw new Error('Video unavailable: ' + reason);
|
throw new Error('Video unavailable: ' + reason);
|
||||||
|
|||||||
@ -125,6 +125,7 @@
|
|||||||
<!-- SABR orchestrator + helpers -->
|
<!-- SABR orchestrator + helpers -->
|
||||||
<script defer src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
<script defer src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script defer src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
|
<script defer src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script defer src="/js/sabr_onesie.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script defer src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
|
<script defer src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script defer src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
|
<script defer src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
|
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user