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:
Emilien 2026-06-28 17:50:41 +02:00
parent e3b3e68380
commit 105a69d9ea
5 changed files with 285 additions and 2 deletions

View File

@ -75,6 +75,54 @@ async function encryptRequest(clientKey, data) {
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
* @param {Object} config - Client config object
@ -324,6 +372,7 @@ function generateRandomString(length) {
window.SABRHelpers = {
getProxyConfig: getProxyConfig,
encryptRequest: encryptRequest,
decryptResponse: decryptResponse,
isConfigValid: isConfigValid,
loadCachedClientConfig: loadCachedClientConfig,
saveCachedClientConfig: saveCachedClientConfig,

View File

@ -10,7 +10,7 @@
* 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 { 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.Platform = Platform;
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
window.googlevideo = googlevideo;

211
assets/js/sabr_onesie.js Normal file
View 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;
})();

View File

@ -504,7 +504,26 @@ var SABRPlayer = (function () {
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') {
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
throw new Error('Video unavailable: ' + reason);

View File

@ -125,6 +125,7 @@
<!-- SABR orchestrator + helpers -->
<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_onesie.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>
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->