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>
390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
/**
|
|
* SABR Helpers - Utility functions for SABR streaming
|
|
* Ported from Kira project (https://github.com/LuanRT/kira)
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// Proxy configuration - uses Invidious proxy endpoint
|
|
var SABR_PROXY_PROTOCOL = window.location.protocol.replace(':', '');
|
|
var SABR_PROXY_HOST = window.location.hostname;
|
|
var SABR_PROXY_PORT = window.location.port || (SABR_PROXY_PROTOCOL === 'https' ? '443' : '80');
|
|
|
|
var REDIRECTOR_STORAGE_KEY = 'googlevideo_redirector';
|
|
var CLIENT_CONFIG_STORAGE_KEY = 'yt_client_config';
|
|
|
|
/**
|
|
* Get proxy configuration
|
|
*/
|
|
function getProxyConfig() {
|
|
return {
|
|
PROXY_PROTOCOL: SABR_PROXY_PROTOCOL,
|
|
PROXY_HOST: SABR_PROXY_HOST,
|
|
PROXY_PORT: SABR_PROXY_PORT
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Encrypt a request using AES-CTR and HMAC-SHA256
|
|
* @param {Uint8Array} clientKey - 32-byte client key
|
|
* @param {Uint8Array} data - Data to encrypt
|
|
* @returns {Promise<{encrypted: Uint8Array, hmac: Uint8Array, iv: Uint8Array}>}
|
|
*/
|
|
async function encryptRequest(clientKey, data) {
|
|
if (clientKey.length !== 32)
|
|
throw new Error('Invalid client key length');
|
|
|
|
var aesKeyData = clientKey.slice(0, 16);
|
|
var hmacKeyData = clientKey.slice(16, 32);
|
|
|
|
var iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
|
|
var aesKey = await window.crypto.subtle.importKey(
|
|
'raw',
|
|
aesKeyData,
|
|
{ name: 'AES-CTR', length: 128 },
|
|
false,
|
|
['encrypt']
|
|
);
|
|
|
|
var encrypted = new Uint8Array(await window.crypto.subtle.encrypt(
|
|
{ name: 'AES-CTR', counter: iv, length: 128 },
|
|
aesKey,
|
|
data
|
|
));
|
|
|
|
var hmacKey = await window.crypto.subtle.importKey(
|
|
'raw',
|
|
hmacKeyData,
|
|
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
// Concatenate encrypted and iv for HMAC
|
|
var dataToSign = new Uint8Array(encrypted.length + iv.length);
|
|
dataToSign.set(encrypted, 0);
|
|
dataToSign.set(iv, encrypted.length);
|
|
|
|
var hmac = new Uint8Array(await window.crypto.subtle.sign(
|
|
'HMAC',
|
|
hmacKey,
|
|
dataToSign
|
|
));
|
|
|
|
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
|
|
* @returns {boolean}
|
|
*/
|
|
function isConfigValid(config) {
|
|
if (!config.timestamp || !config.keyExpiresInSeconds) {
|
|
return false;
|
|
}
|
|
|
|
var currentTime = Date.now();
|
|
var expirationTime = config.timestamp + (config.keyExpiresInSeconds * 1000);
|
|
return currentTime < expirationTime;
|
|
}
|
|
|
|
/**
|
|
* Load cached client config from localStorage
|
|
* @returns {Object|null}
|
|
*/
|
|
function loadCachedClientConfig() {
|
|
try {
|
|
var cachedData = localStorage.getItem(CLIENT_CONFIG_STORAGE_KEY);
|
|
if (!cachedData) return null;
|
|
|
|
var parsed = JSON.parse(cachedData);
|
|
|
|
if (!isConfigValid(parsed)) {
|
|
localStorage.removeItem(CLIENT_CONFIG_STORAGE_KEY);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
clientKeyData: new Uint8Array(Object.values(parsed.clientKeyData)),
|
|
encryptedClientKey: new Uint8Array(Object.values(parsed.encryptedClientKey)),
|
|
onesieUstreamerConfig: new Uint8Array(Object.values(parsed.onesieUstreamerConfig)),
|
|
baseUrl: parsed.baseUrl,
|
|
keyExpiresInSeconds: parsed.keyExpiresInSeconds,
|
|
timestamp: parsed.timestamp
|
|
};
|
|
} catch (error) {
|
|
console.error('[SABR]', 'Failed to load cached client config', error);
|
|
localStorage.removeItem(CLIENT_CONFIG_STORAGE_KEY);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save client config to localStorage
|
|
* @param {Object} config - Client config to save
|
|
*/
|
|
function saveCachedClientConfig(config) {
|
|
try {
|
|
config.timestamp = Date.now();
|
|
localStorage.setItem(CLIENT_CONFIG_STORAGE_KEY, JSON.stringify({
|
|
clientKeyData: Array.from(config.clientKeyData),
|
|
encryptedClientKey: Array.from(config.encryptedClientKey),
|
|
onesieUstreamerConfig: Array.from(config.onesieUstreamerConfig),
|
|
baseUrl: config.baseUrl,
|
|
keyExpiresInSeconds: config.keyExpiresInSeconds,
|
|
timestamp: config.timestamp
|
|
}));
|
|
} catch (error) {
|
|
console.error('[SABR]', 'Failed to save client config', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert object to Map
|
|
* @param {Object} object
|
|
* @returns {Map}
|
|
*/
|
|
function asMap(object) {
|
|
var map = new Map();
|
|
for (var key of Object.keys(object)) {
|
|
map.set(key, object[key]);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Convert Headers to plain object
|
|
* @param {Headers} headers
|
|
* @returns {Object}
|
|
*/
|
|
function headersToGenericObject(headers) {
|
|
var headersObj = {};
|
|
headers.forEach(function(value, key) {
|
|
headersObj[key.trim()] = value;
|
|
});
|
|
return headersObj;
|
|
}
|
|
|
|
/**
|
|
* Create a Shaka response object
|
|
* @param {Object} headers - Response headers
|
|
* @param {BufferSource} data - Response data
|
|
* @param {number} status - HTTP status code
|
|
* @param {string} uri - Original URI
|
|
* @param {string} responseURL - Final response URL
|
|
* @param {Object} request - Original request
|
|
* @param {number} requestType - Request type
|
|
* @returns {Object}
|
|
*/
|
|
function makeResponse(headers, data, status, uri, responseURL, request, requestType) {
|
|
if (status >= 200 && status <= 299 && status !== 202) {
|
|
return {
|
|
uri: responseURL || uri,
|
|
originalUri: uri,
|
|
data: data,
|
|
status: status,
|
|
headers: headers,
|
|
originalRequest: request,
|
|
fromCache: !!headers['x-shaka-from-cache']
|
|
};
|
|
}
|
|
|
|
var responseText = null;
|
|
try {
|
|
responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
|
|
} catch (e) { /* no-op */ }
|
|
|
|
var severity = (status === 401 || status === 403)
|
|
? shaka.util.Error.Severity.CRITICAL
|
|
: shaka.util.Error.Severity.RECOVERABLE;
|
|
|
|
throw new shaka.util.Error(
|
|
severity,
|
|
shaka.util.Error.Category.NETWORK,
|
|
shaka.util.Error.Code.BAD_HTTP_STATUS,
|
|
uri,
|
|
status,
|
|
responseText,
|
|
headers,
|
|
requestType,
|
|
responseURL || uri
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a recoverable Shaka error
|
|
* @param {string} message - Error message
|
|
* @param {Object} info - Additional info
|
|
* @returns {shaka.util.Error}
|
|
*/
|
|
function createRecoverableError(message, info) {
|
|
return new shaka.util.Error(
|
|
shaka.util.Error.Severity.RECOVERABLE,
|
|
shaka.util.Error.Category.NETWORK,
|
|
shaka.util.Error.Code.HTTP_ERROR,
|
|
message,
|
|
{ info: info }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Proxy a URL through Invidious proxy
|
|
* @param {string|URL} url - URL to proxy
|
|
* @param {Headers|Object} headers - Additional headers
|
|
* @returns {URL}
|
|
*/
|
|
function proxyUrl(url, headers) {
|
|
var config = getProxyConfig();
|
|
var urlObj = typeof url === 'string' ? new URL(url) : new URL(url.toString());
|
|
var newUrl = new URL(urlObj.toString());
|
|
|
|
if (headers) {
|
|
var headersArray = [];
|
|
if (headers instanceof Headers) {
|
|
headers.forEach(function(value, key) {
|
|
headersArray.push([key, value]);
|
|
});
|
|
} else {
|
|
for (var key in headers) {
|
|
headersArray.push([key, headers[key]]);
|
|
}
|
|
}
|
|
newUrl.searchParams.set('__headers', JSON.stringify(headersArray));
|
|
}
|
|
|
|
newUrl.searchParams.set('__host', urlObj.host);
|
|
newUrl.host = config.PROXY_HOST;
|
|
newUrl.port = config.PROXY_PORT;
|
|
newUrl.protocol = config.PROXY_PROTOCOL + ':';
|
|
newUrl.pathname = '/proxy' + urlObj.pathname;
|
|
|
|
return newUrl;
|
|
}
|
|
|
|
/**
|
|
* Fetch through proxy
|
|
* @param {string|URL} input - URL to fetch
|
|
* @param {RequestInit} init - Fetch init options
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
async function fetchWithProxy(input, init) {
|
|
var url = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url));
|
|
var headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
|
|
var requestInit = Object.assign({}, init, { headers: headers });
|
|
|
|
var config = getProxyConfig();
|
|
|
|
var newUrl = new URL(url.toString());
|
|
newUrl.searchParams.set('__headers', JSON.stringify(Array.from(headers.entries())));
|
|
newUrl.searchParams.set('__host', url.host);
|
|
newUrl.host = config.PROXY_HOST;
|
|
newUrl.port = config.PROXY_PORT;
|
|
newUrl.protocol = config.PROXY_PROTOCOL + ':';
|
|
newUrl.pathname = '/proxy' + url.pathname;
|
|
|
|
var request = new Request(newUrl, input instanceof Request ? input : undefined);
|
|
headers.delete('user-agent');
|
|
|
|
return fetch(request, requestInit);
|
|
}
|
|
|
|
/**
|
|
* Check if URL is a Google Video URL
|
|
* @param {string} url
|
|
* @returns {boolean}
|
|
*/
|
|
function isGoogleVideoURL(url) {
|
|
try {
|
|
var urlObj = new URL(url);
|
|
return urlObj.hostname.endsWith('.googlevideo.com') ||
|
|
urlObj.hostname.endsWith('.youtube.com') ||
|
|
urlObj.hostname.includes('googlevideo');
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a random string
|
|
* @param {number} length
|
|
* @returns {string}
|
|
*/
|
|
function generateRandomString(length) {
|
|
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
var result = '';
|
|
for (var i = 0; i < length; i++) {
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Export for use in other modules
|
|
window.SABRHelpers = {
|
|
getProxyConfig: getProxyConfig,
|
|
encryptRequest: encryptRequest,
|
|
decryptResponse: decryptResponse,
|
|
isConfigValid: isConfigValid,
|
|
loadCachedClientConfig: loadCachedClientConfig,
|
|
saveCachedClientConfig: saveCachedClientConfig,
|
|
asMap: asMap,
|
|
headersToGenericObject: headersToGenericObject,
|
|
makeResponse: makeResponse,
|
|
createRecoverableError: createRecoverableError,
|
|
proxyUrl: proxyUrl,
|
|
fetchWithProxy: fetchWithProxy,
|
|
isGoogleVideoURL: isGoogleVideoURL,
|
|
generateRandomString: generateRandomString,
|
|
REDIRECTOR_STORAGE_KEY: REDIRECTOR_STORAGE_KEY,
|
|
CLIENT_CONFIG_STORAGE_KEY: CLIENT_CONFIG_STORAGE_KEY
|
|
};
|