mirror of
https://github.com/iv-org/invidious.git
synced 2026-01-28 07:48:31 -06:00
Merge 13f094302392228d3db2a07ece67a6cf3db2b588 into d51a7a44ad91d2fa7d1330970a15a0d8f365f250
This commit is contained in:
commit
c6eb7b5024
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@
|
|||||||
/invidious
|
/invidious
|
||||||
/sentry
|
/sentry
|
||||||
/config/config.yml
|
/config/config.yml
|
||||||
|
node_modules/
|
||||||
259
assets/css/sabr_player.css
Normal file
259
assets/css/sabr_player.css
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* SABR Player CSS Styles
|
||||||
|
* Customizes Shaka Player UI to match Invidious video.js theme
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Override Shaka Player's Google Fonts with system fonts */
|
||||||
|
.shaka-video-container,
|
||||||
|
.shaka-controls-container,
|
||||||
|
.shaka-overflow-menu,
|
||||||
|
.shaka-settings-menu,
|
||||||
|
.shaka-tooltip,
|
||||||
|
.shaka-current-time,
|
||||||
|
.shaka-time-separator,
|
||||||
|
.shaka-duration {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container styling - match video.js #player-container */
|
||||||
|
#sabr-player-container {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-right: 1em;
|
||||||
|
padding-bottom: 82vh;
|
||||||
|
height: 0;
|
||||||
|
background-color: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(100% - 2em);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sabr-player-container video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Shaka video container */
|
||||||
|
.shaka-video-container {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-video-container video {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
object-fit: contain !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IMPORTANT: Force controls to be visible */
|
||||||
|
.shaka-controls-button-panel,
|
||||||
|
.shaka-scrim-container,
|
||||||
|
.shaka-play-button {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transition: opacity cubic-bezier(.4, 0, .6, 1) .6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls bar - match video.js style */
|
||||||
|
.shaka-controls-container {
|
||||||
|
background: linear-gradient(rgba(0,0,0,0.1), rgba(0, 0, 0,0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-bottom-controls {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons - match video.js colors */
|
||||||
|
.shaka-controls-button-panel button,
|
||||||
|
.shaka-overflow-menu-button {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-controls-button-panel button:hover,
|
||||||
|
.shaka-overflow-menu-button:hover {
|
||||||
|
color: rgba(0, 182, 240, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar - match video.js style */
|
||||||
|
.shaka-seek-bar-container {
|
||||||
|
height: 5px !important;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-seek-bar {
|
||||||
|
background-color: rgba(15, 15, 15, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-seek-bar-value {
|
||||||
|
background-color: rgba(0, 182, 240, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-seek-bar-container:hover {
|
||||||
|
height: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buffer indicator - match video.js */
|
||||||
|
.shaka-seek-bar-buffer {
|
||||||
|
background-color: rgba(87, 87, 88, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume slider */
|
||||||
|
.shaka-volume-bar-container {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-volume-bar {
|
||||||
|
background-color: rgba(15, 15, 15, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-volume-bar-value {
|
||||||
|
background-color: rgba(0, 182, 240, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time display */
|
||||||
|
.shaka-current-time,
|
||||||
|
.shaka-time-separator,
|
||||||
|
.shaka-duration {
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overflow menu - match video.js menu style */
|
||||||
|
.shaka-overflow-menu,
|
||||||
|
.shaka-settings-menu {
|
||||||
|
background-color: rgba(35, 35, 35, 0.75);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-overflow-menu button,
|
||||||
|
.shaka-settings-menu button {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-overflow-menu button:hover,
|
||||||
|
.shaka-settings-menu button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
color: rgba(49, 49, 51, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-overflow-menu button[aria-selected="true"],
|
||||||
|
.shaka-settings-menu button[aria-selected="true"] {
|
||||||
|
background-color: rgba(0, 182, 240, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quality/resolution labels */
|
||||||
|
.shaka-resolution-button span,
|
||||||
|
.shaka-language-button span {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big play button - match video.js style */
|
||||||
|
.shaka-play-button-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-play-button {
|
||||||
|
background-color: rgba(35, 35, 35, 0.75) !important;
|
||||||
|
border-radius: 0.3em !important;
|
||||||
|
width: 1.5em !important;
|
||||||
|
height: 1.5em !important;
|
||||||
|
padding: 1.5em !important;
|
||||||
|
box-sizing: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-play-button:hover {
|
||||||
|
background-color: rgba(35, 35, 35, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.shaka-spinner-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-spinner {
|
||||||
|
border-color: rgba(0, 182, 240, 1) transparent transparent transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Captions/subtitles - match video.js */
|
||||||
|
.shaka-text-container {
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-text-container > div > div > div {
|
||||||
|
background-color: rgba(0, 0, 0, 0.75) !important;
|
||||||
|
border-radius: 9px !important;
|
||||||
|
padding: 5px !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
.shaka-tooltip {
|
||||||
|
background-color: rgba(35, 35, 35, 0.75);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error display */
|
||||||
|
.sabr-error-display {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: rgba(35, 35, 35, 0.75);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sabr-error-display a {
|
||||||
|
color: rgba(0, 182, 240, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sabr-error-display a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - match video.js responsive behavior */
|
||||||
|
@media only screen and (max-aspect-ratio: 16/9) {
|
||||||
|
#sabr-player-container {
|
||||||
|
padding-bottom: 46.86% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shaka-play-button {
|
||||||
|
padding: 1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shaka-controls-button-panel button {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide unnecessary Shaka elements */
|
||||||
|
.shaka-ad-controls,
|
||||||
|
.shaka-client-side-ad-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Poster/thumbnail styling */
|
||||||
|
.shaka-video-container .shaka-poster {
|
||||||
|
background-size: cover;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
3
assets/js/sabr/bgutils-js/bgutils.bundle.min.js
vendored
Normal file
3
assets/js/sabr/bgutils-js/bgutils.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/js/sabr/bgutils-js/versions.yml
Normal file
2
assets/js/sabr/bgutils-js/versions.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
version: 3.2.0
|
||||||
46
assets/js/sabr/googlevideo/googlevideo.bundle.min.js
vendored
Normal file
46
assets/js/sabr/googlevideo/googlevideo.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/js/sabr/googlevideo/versions.yml
Normal file
2
assets/js/sabr/googlevideo/versions.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
version: 4.0.4
|
||||||
30
assets/js/sabr/shaka-player/controls.css
Normal file
30
assets/js/sabr/shaka-player/controls.css
Normal file
File diff suppressed because one or more lines are too long
2155
assets/js/sabr/shaka-player/shaka-player.ui.js
Normal file
2155
assets/js/sabr/shaka-player/shaka-player.ui.js
Normal file
File diff suppressed because it is too large
Load Diff
2
assets/js/sabr/shaka-player/versions.yml
Normal file
2
assets/js/sabr/shaka-player/versions.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
version: 4.16.4
|
||||||
2
assets/js/sabr/youtubei.js/versions.yml
Normal file
2
assets/js/sabr/youtubei.js/versions.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
version: 16.0.1
|
||||||
8
assets/js/sabr/youtubei.js/youtubei.bundle.min.js
vendored
Normal file
8
assets/js/sabr/youtubei.js/youtubei.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
340
assets/js/sabr_helpers.js
Normal file
340
assets/js/sabr_helpers.js
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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
|
||||||
|
};
|
||||||
41
assets/js/sabr_init.js
Normal file
41
assets/js/sabr_init.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* SABR Init - Initialize SABR player when page loads
|
||||||
|
* This file handles the SABR player initialization without inline scripts
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// Wait for SABR libs to be loaded
|
||||||
|
window.addEventListener('sabr-libs-loaded', async function() {
|
||||||
|
var container = document.getElementById('sabr-player-container');
|
||||||
|
if (!container) {
|
||||||
|
console.error('[SABR]', 'Player container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoId = container.dataset.videoId;
|
||||||
|
var autoplay = container.dataset.autoplay === 'true';
|
||||||
|
var videoLoop = container.dataset.videoLoop === 'true';
|
||||||
|
var codecPref = container.dataset.qualitySabr || 'vp9';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = await SABRPlayer.loadVideo(videoId, container, {
|
||||||
|
autoplay: autoplay,
|
||||||
|
loop: videoLoop,
|
||||||
|
codecPreference: codecPref
|
||||||
|
});
|
||||||
|
console.info('[SABR]', 'Video loaded successfully', result.videoInfo?.basic_info?.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SABR]', 'Failed to load video:', error);
|
||||||
|
// Show error message in container
|
||||||
|
var errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'sabr-error-display';
|
||||||
|
errorDiv.innerHTML = '<p>Failed to load video with SABR player.</p>' +
|
||||||
|
'<p>' + error.message + '</p>' +
|
||||||
|
'<p><a href="?quality=dash">Try DASH player instead</a></p>';
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
32
assets/js/sabr_loader.js
Normal file
32
assets/js/sabr_loader.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* SABR Loader - ES module loader for SABR dependencies
|
||||||
|
*
|
||||||
|
* All dependencies are ES modules:
|
||||||
|
* - youtubei.js: Provides Innertube for YouTube API access
|
||||||
|
* - googlevideo: Provides SABR streaming adapter
|
||||||
|
* - bgutils-js: Provides BotGuard utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import all ES modules
|
||||||
|
import Innertube from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
||||||
|
import { Platform } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
||||||
|
import { Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
||||||
|
import * as googlevideo from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
|
||||||
|
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
||||||
|
|
||||||
|
// Expose all SABR-related functions to window
|
||||||
|
window.Innertube = Innertube;
|
||||||
|
window.Platform = Platform;
|
||||||
|
window.Constants = Constants;
|
||||||
|
window.SabrStreamingAdapter = googlevideo.SabrStreamingAdapter;
|
||||||
|
window.SabrUmpProcessor = googlevideo.SabrUmpProcessor;
|
||||||
|
window.buildSabrFormat = googlevideo.buildSabrFormat;
|
||||||
|
window.FormatKeyUtils = googlevideo.FormatKeyUtils;
|
||||||
|
window.UmpUtils = googlevideo.UmpUtils;
|
||||||
|
window.SABR_CONSTANTS = googlevideo.SABR_CONSTANTS;
|
||||||
|
window.isGoogleVideoURL = googlevideo.isGoogleVideoURL;
|
||||||
|
window.BG = BG;
|
||||||
|
|
||||||
|
// Signal that all SABR libraries are loaded and ready
|
||||||
|
console.info('[SABR Loader]', 'All SABR libraries loaded');
|
||||||
|
window.dispatchEvent(new Event('sabr-libs-loaded'));
|
||||||
665
assets/js/sabr_player.js
Normal file
665
assets/js/sabr_player.js
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
/**
|
||||||
|
* SABR Player - Main player initialization for SABR streaming
|
||||||
|
* Ported from Kira project (https://github.com/LuanRT/kira)
|
||||||
|
*
|
||||||
|
* This module provides:
|
||||||
|
* - Innertube API initialization
|
||||||
|
* - Onesie config fetching
|
||||||
|
* - Shaka Player setup with SABR adapter
|
||||||
|
* - Video playback management
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var SABRPlayer = (function() {
|
||||||
|
// Constants
|
||||||
|
var VOLUME_KEY = 'youtube_player_volume';
|
||||||
|
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
|
||||||
|
var SAVE_POSITION_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
var DEFAULT_ABR_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
restrictions: { maxHeight: 480 },
|
||||||
|
switchInterval: 4,
|
||||||
|
useNetworkInformation: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
var player = null;
|
||||||
|
var ui = null;
|
||||||
|
var sabrAdapter = null;
|
||||||
|
var videoElement = null;
|
||||||
|
var shakaContainer = null;
|
||||||
|
var currentVideoId = '';
|
||||||
|
var isLive = false;
|
||||||
|
var innertube = null;
|
||||||
|
var clientConfig = null;
|
||||||
|
var savePositionInterval = null;
|
||||||
|
var playbackWebPoToken = null;
|
||||||
|
var coldStartToken = null;
|
||||||
|
var playbackWebPoTokenContentBinding = null;
|
||||||
|
var playbackWebPoTokenCreationLock = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get saved volume from localStorage
|
||||||
|
*/
|
||||||
|
function getSavedVolume() {
|
||||||
|
try {
|
||||||
|
var volume = localStorage.getItem(VOLUME_KEY);
|
||||||
|
return volume ? parseFloat(volume) : 1;
|
||||||
|
} catch (error) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save volume to localStorage
|
||||||
|
*/
|
||||||
|
function saveVolume(volume) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VOLUME_KEY, volume.toString());
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all saved playback positions
|
||||||
|
*/
|
||||||
|
function getPlaybackPositions() {
|
||||||
|
try {
|
||||||
|
var positions = localStorage.getItem(PLAYBACK_POSITION_KEY);
|
||||||
|
return positions ? JSON.parse(positions) : {};
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save playback position
|
||||||
|
*/
|
||||||
|
function savePlaybackPosition(videoId, time) {
|
||||||
|
if (!videoId || time < 1) return;
|
||||||
|
try {
|
||||||
|
var positions = getPlaybackPositions();
|
||||||
|
positions[videoId] = time;
|
||||||
|
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get playback position for a video
|
||||||
|
*/
|
||||||
|
function getPlaybackPosition(videoId) {
|
||||||
|
var positions = getPlaybackPositions();
|
||||||
|
return positions[videoId] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Innertube API
|
||||||
|
*/
|
||||||
|
async function initInnertube() {
|
||||||
|
if (innertube) return innertube;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.info('[SABRPlayer]', 'Initializing InnerTube API');
|
||||||
|
|
||||||
|
// Check if Innertube is available from youtubei.js
|
||||||
|
if (typeof Innertube === 'undefined') {
|
||||||
|
throw new Error('youtubei.js not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up Platform.shim.eval for URL deciphering (like Kira does)
|
||||||
|
// This is required because the browser bundle doesn't include Jinter
|
||||||
|
if (typeof Platform !== 'undefined' && Platform.shim) {
|
||||||
|
Platform.shim.eval = async function(data, env) {
|
||||||
|
var properties = [];
|
||||||
|
|
||||||
|
if (env.n) {
|
||||||
|
properties.push('n: exportedVars.nFunction("' + env.n + '")');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.sig) {
|
||||||
|
properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
|
||||||
|
return new Function(code)();
|
||||||
|
};
|
||||||
|
console.info('[SABRPlayer]', 'Platform.shim.eval configured for URL deciphering');
|
||||||
|
} else {
|
||||||
|
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Innertube with proxy fetch for CSP compliance
|
||||||
|
innertube = await Innertube.create({
|
||||||
|
fetch: SABRHelpers.fetchWithProxy,
|
||||||
|
retrieve_player: true,
|
||||||
|
generate_session_locally: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize BotGuard for PoToken generation
|
||||||
|
BotguardService.init().then(function() {
|
||||||
|
console.info('[SABRPlayer]', 'BotGuard client initialized');
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preload the redirector URL
|
||||||
|
try {
|
||||||
|
var redirectorResponse = 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' }
|
||||||
|
);
|
||||||
|
var redirectorResponseUrl = await redirectorResponse.text();
|
||||||
|
|
||||||
|
if (redirectorResponseUrl.startsWith('https://')) {
|
||||||
|
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SABRPlayer]', 'Failed to preload redirector URL', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return innertube;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SABRPlayer]', 'Failed to initialize Innertube', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Onesie client config
|
||||||
|
*/
|
||||||
|
async function fetchOnesieConfig() {
|
||||||
|
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) {
|
||||||
|
return clientConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try loading from cache
|
||||||
|
var cachedConfig = SABRHelpers.loadCachedClientConfig();
|
||||||
|
if (cachedConfig) {
|
||||||
|
clientConfig = cachedConfig;
|
||||||
|
return clientConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
|
||||||
|
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
var tvConfigText = await tvConfigResponse.text();
|
||||||
|
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
|
||||||
|
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
|
||||||
|
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
|
||||||
|
|
||||||
|
// Helper to decode base64 to Uint8Array
|
||||||
|
function base64ToU8(base64) {
|
||||||
|
var binary = atob(base64);
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig = {
|
||||||
|
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
|
||||||
|
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
|
||||||
|
encryptedClientKey: base64ToU8(onesieHotConfig.encryptedClientKey),
|
||||||
|
onesieUstreamerConfig: base64ToU8(onesieHotConfig.onesieUstreamerConfig),
|
||||||
|
baseUrl: onesieHotConfig.baseUrl,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
SABRHelpers.saveCachedClientConfig(clientConfig);
|
||||||
|
return clientConfig;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SABRPlayer]', 'Failed to fetch Onesie client config', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint content-bound PoToken
|
||||||
|
*/
|
||||||
|
async function mintContentWebPO() {
|
||||||
|
console.log('[SABRPlayer] mintContentWebPO called, binding:', playbackWebPoTokenContentBinding);
|
||||||
|
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) {
|
||||||
|
console.log('[SABRPlayer] mintContentWebPO skipped:', { binding: !!playbackWebPoTokenContentBinding, locked: playbackWebPoTokenCreationLock });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackWebPoTokenCreationLock = true;
|
||||||
|
try {
|
||||||
|
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
|
||||||
|
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
|
||||||
|
|
||||||
|
if (!BotguardService.isInitialized()) {
|
||||||
|
console.log('[SABRPlayer] BotGuard not initialized, reinitializing...');
|
||||||
|
await BotguardService.reinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BotguardService.isInitialized()) {
|
||||||
|
playbackWebPoToken = await BotguardService.mintWebPoToken(
|
||||||
|
decodeURIComponent(playbackWebPoTokenContentBinding)
|
||||||
|
);
|
||||||
|
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
|
||||||
|
} else {
|
||||||
|
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SABRPlayer]', 'Error minting WebPO token', err);
|
||||||
|
} finally {
|
||||||
|
playbackWebPoTokenCreationLock = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Shaka Player
|
||||||
|
*/
|
||||||
|
async function initializeShakaPlayer(containerElement) {
|
||||||
|
if (!shaka.Player.isBrowserSupported()) {
|
||||||
|
throw new Error('Shaka Player is not supported in this browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install polyfills
|
||||||
|
shaka.polyfill.installAll();
|
||||||
|
|
||||||
|
shakaContainer = document.createElement('div');
|
||||||
|
shakaContainer.className = 'sabr-player-container';
|
||||||
|
shakaContainer.style.width = '100%';
|
||||||
|
shakaContainer.style.height = '100%';
|
||||||
|
|
||||||
|
videoElement = document.createElement('video');
|
||||||
|
videoElement.autoplay = true;
|
||||||
|
videoElement.style.width = '100%';
|
||||||
|
videoElement.style.height = '100%';
|
||||||
|
videoElement.style.backgroundColor = '#000';
|
||||||
|
|
||||||
|
shakaContainer.appendChild(videoElement);
|
||||||
|
containerElement.appendChild(shakaContainer);
|
||||||
|
|
||||||
|
player = new shaka.Player();
|
||||||
|
|
||||||
|
player.configure({
|
||||||
|
preferredAudioLanguage: 'en-US',
|
||||||
|
abr: DEFAULT_ABR_CONFIG,
|
||||||
|
streaming: {
|
||||||
|
bufferingGoal: 120,
|
||||||
|
rebufferingGoal: 0.01,
|
||||||
|
bufferBehind: 300,
|
||||||
|
retryParameters: {
|
||||||
|
maxAttempts: 8,
|
||||||
|
fuzzFactor: 0.5,
|
||||||
|
timeout: 30 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
videoElement.volume = getSavedVolume();
|
||||||
|
videoElement.addEventListener('volumechange', function() {
|
||||||
|
saveVolume(videoElement.volume);
|
||||||
|
});
|
||||||
|
videoElement.addEventListener('playing', function() {
|
||||||
|
player.configure('abr.restrictions.maxHeight', Infinity);
|
||||||
|
});
|
||||||
|
videoElement.addEventListener('pause', function() {
|
||||||
|
if (currentVideoId) {
|
||||||
|
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await player.attach(videoElement);
|
||||||
|
|
||||||
|
// Initialize UI if available
|
||||||
|
if (shaka.ui && shaka.ui.Overlay) {
|
||||||
|
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
|
||||||
|
ui.configure({
|
||||||
|
addBigPlayButton: true,
|
||||||
|
overflowMenuButtons: [
|
||||||
|
'captions',
|
||||||
|
'quality',
|
||||||
|
'language',
|
||||||
|
'playback_rate',
|
||||||
|
'loop',
|
||||||
|
'picture_in_picture'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize SABR adapter
|
||||||
|
*/
|
||||||
|
async function initializeSabrAdapter() {
|
||||||
|
if (!player || !innertube) return;
|
||||||
|
|
||||||
|
// Check if SabrStreamingAdapter is available from googlevideo
|
||||||
|
if (typeof SabrStreamingAdapter === 'undefined') {
|
||||||
|
console.error('[SABRPlayer]', 'googlevideo library not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the player adapter
|
||||||
|
var playerAdapter = new ShakaPlayerAdapter();
|
||||||
|
|
||||||
|
// Use YouTube.js ANDROID client constants
|
||||||
|
var androidClient = Constants.CLIENTS.ANDROID;
|
||||||
|
|
||||||
|
sabrAdapter = new SabrStreamingAdapter({
|
||||||
|
playerAdapter: playerAdapter,
|
||||||
|
clientInfo: {
|
||||||
|
osName: 'Android',
|
||||||
|
osVersion: androidClient.OS_VERSION || '14',
|
||||||
|
clientName: 3, // ANDROID - Used exclusively for SABR streaming requests
|
||||||
|
clientVersion: androidClient.VERSION
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sabrAdapter.onMintPoToken(async function() {
|
||||||
|
console.log('[SABRPlayer] onMintPoToken callback invoked');
|
||||||
|
if (!playbackWebPoToken) {
|
||||||
|
if (isLive) {
|
||||||
|
await mintContentWebPO();
|
||||||
|
} else {
|
||||||
|
mintContentWebPO(); // Don't block for VOD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var token = playbackWebPoToken || coldStartToken || '';
|
||||||
|
console.log('[SABRPlayer] Returning token:', token ? 'token present (' + token.substring(0, 20) + '...)' : 'empty');
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
|
||||||
|
sabrAdapter.attach(player);
|
||||||
|
return sabrAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup request filters for proxying
|
||||||
|
*/
|
||||||
|
async function setupRequestFilters() {
|
||||||
|
var networkingEngine = player?.getNetworkingEngine();
|
||||||
|
if (!networkingEngine) return;
|
||||||
|
|
||||||
|
var config = SABRHelpers.getProxyConfig();
|
||||||
|
|
||||||
|
networkingEngine.registerRequestFilter(async function(type, request) {
|
||||||
|
var url = new URL(request.uris[0]);
|
||||||
|
|
||||||
|
// Proxy googlevideo requests
|
||||||
|
if (url.host.endsWith('.googlevideo.com') || url.host.includes('youtube')) {
|
||||||
|
var newUrl = new URL(url.toString());
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Add required headers for googlevideo requests to avoid 403
|
||||||
|
var proxyHeaders = [
|
||||||
|
['user-agent', navigator.userAgent],
|
||||||
|
['origin', 'https://www.youtube.com'],
|
||||||
|
['referer', 'https://www.youtube.com/']
|
||||||
|
];
|
||||||
|
|
||||||
|
// CRITICAL: Add content-type for POST requests (SABR videoplayback)
|
||||||
|
// This is REQUIRED for YouTube to accept the protobuf request body
|
||||||
|
if (request.body && request.body.byteLength > 0) {
|
||||||
|
proxyHeaders.push(['content-type', 'application/x-protobuf']);
|
||||||
|
console.log('[SABRPlayer] Adding content-type: application/x-protobuf for POST request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add visitor ID if available from innertube session
|
||||||
|
if (innertube?.session?.context?.client?.visitorData) {
|
||||||
|
proxyHeaders.push(['x-goog-visitor-id', innertube.session.context.client.visitorData]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client name and version - Force ANDROID (3) for SABR requests
|
||||||
|
proxyHeaders.push(['x-youtube-client-name', '3']); // ANDROID
|
||||||
|
if (innertube?.session?.context?.client?.clientVersion) {
|
||||||
|
proxyHeaders.push(['x-youtube-client-version', innertube.session.context.client.clientVersion]);
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl.searchParams.set('__headers', JSON.stringify(proxyHeaders));
|
||||||
|
|
||||||
|
request.uris[0] = newUrl.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a video
|
||||||
|
*/
|
||||||
|
async function loadVideo(videoId, containerElement, options) {
|
||||||
|
options = options || {};
|
||||||
|
currentVideoId = videoId;
|
||||||
|
playbackWebPoToken = null;
|
||||||
|
playbackWebPoTokenContentBinding = videoId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize components if needed
|
||||||
|
if (!innertube) {
|
||||||
|
innertube = await initInnertube();
|
||||||
|
if (!innertube) {
|
||||||
|
throw new Error('Failed to initialize Innertube');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientConfig) {
|
||||||
|
clientConfig = await fetchOnesieConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
await initializeShakaPlayer(containerElement);
|
||||||
|
} else {
|
||||||
|
// Reset player configuration
|
||||||
|
player.configure('abr', DEFAULT_ABR_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sabrAdapter) {
|
||||||
|
await initializeSabrAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupRequestFilters();
|
||||||
|
|
||||||
|
// Fetch video info using Innertube
|
||||||
|
var videoInfo = await innertube.getInfo(videoId);
|
||||||
|
|
||||||
|
if (!videoInfo || videoInfo.playability_status?.status !== 'OK') {
|
||||||
|
var reason = videoInfo?.playability_status?.reason || 'Unknown error';
|
||||||
|
throw new Error('Video unavailable: ' + reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLive = !!videoInfo.basic_info?.is_live;
|
||||||
|
|
||||||
|
// Get streaming URL
|
||||||
|
var streamingData = videoInfo.streaming_data;
|
||||||
|
if (!streamingData) {
|
||||||
|
throw new Error('No streaming data available');
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestUri;
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
// For live streams, use HLS or DASH manifest
|
||||||
|
manifestUri = streamingData.hls_manifest_url || streamingData.dash_manifest_url;
|
||||||
|
} else {
|
||||||
|
// For VOD, generate DASH manifest from adaptive formats
|
||||||
|
if (sabrAdapter && streamingData.server_abr_streaming_url) {
|
||||||
|
// SABR mode - need to decipher the server_abr_streaming_url first
|
||||||
|
var sabrUrl = streamingData.server_abr_streaming_url;
|
||||||
|
|
||||||
|
// Decipher the URL if the player has a decipher function
|
||||||
|
if (innertube?.session?.player?.decipher) {
|
||||||
|
try {
|
||||||
|
sabrUrl = await innertube.session.player.decipher(sabrUrl);
|
||||||
|
console.log('[SABRPlayer] Deciphered streaming URL:', sabrUrl?.substring(0, 100) + '...');
|
||||||
|
} catch (decipherErr) {
|
||||||
|
console.error('[SABRPlayer] Failed to decipher URL:', decipherErr);
|
||||||
|
// Try to use the raw URL anyway
|
||||||
|
console.warn('[SABRPlayer] Trying raw URL instead');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[SABRPlayer] Player decipher not available, using raw URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SABRPlayer] Setting streaming URL:', sabrUrl);
|
||||||
|
sabrAdapter.setStreamingURL(sabrUrl);
|
||||||
|
|
||||||
|
// Build SABR formats
|
||||||
|
console.log('[SABRPlayer] Checking SABR format requirements:', {
|
||||||
|
buildSabrFormat: typeof buildSabrFormat,
|
||||||
|
adaptive_formats: !!streamingData.adaptive_formats,
|
||||||
|
formats_length: streamingData.adaptive_formats?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof buildSabrFormat !== 'undefined' && streamingData.adaptive_formats) {
|
||||||
|
console.log('[SABRPlayer] Building SABR formats from', streamingData.adaptive_formats.length, 'adaptive formats');
|
||||||
|
var sabrFormats = streamingData.adaptive_formats.map(function(fmt) {
|
||||||
|
return buildSabrFormat(fmt);
|
||||||
|
}).filter(function(format) {
|
||||||
|
return !format.xtags;
|
||||||
|
});
|
||||||
|
console.log('[SABRPlayer] Setting', sabrFormats.length, 'SABR formats on adapter');
|
||||||
|
sabrAdapter.setServerAbrFormats(sabrFormats);
|
||||||
|
console.log('[SABRPlayer] SABR formats set successfully');
|
||||||
|
} else {
|
||||||
|
console.warn('[SABRPlayer] buildSabrFormat not available or no adaptive formats', {
|
||||||
|
buildSabrFormat: typeof buildSabrFormat,
|
||||||
|
has_adaptive_formats: !!streamingData.adaptive_formats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ustreamer config
|
||||||
|
var ustreamerConfig = videoInfo.player_config?.media_common_config?.media_ustreamer_request_config?.video_playback_ustreamer_config;
|
||||||
|
if (ustreamerConfig) {
|
||||||
|
sabrAdapter.setUstreamerConfig(ustreamerConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate DASH manifest
|
||||||
|
var dashManifest = await videoInfo.toDash({
|
||||||
|
manifest_options: {
|
||||||
|
is_sabr: !!sabrAdapter,
|
||||||
|
captions_format: 'vtt',
|
||||||
|
include_thumbnails: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifestUri) {
|
||||||
|
throw new Error('Could not find a valid manifest URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start time
|
||||||
|
var startTime = options.startTime;
|
||||||
|
if (startTime === undefined && options.savePlayerPos !== false) {
|
||||||
|
startTime = getPlaybackPosition(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the manifest
|
||||||
|
await player.load(manifestUri, isLive ? undefined : startTime);
|
||||||
|
|
||||||
|
// Start playback
|
||||||
|
videoElement.play().catch(function(err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start saving position periodically
|
||||||
|
if (savePositionInterval) {
|
||||||
|
clearInterval(savePositionInterval);
|
||||||
|
}
|
||||||
|
savePositionInterval = setInterval(function() {
|
||||||
|
if (videoElement && currentVideoId && !videoElement.paused) {
|
||||||
|
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
|
}
|
||||||
|
}, SAVE_POSITION_INTERVAL_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
player: player,
|
||||||
|
videoElement: videoElement,
|
||||||
|
videoInfo: videoInfo
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SABRPlayer]', 'Error loading video:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the player
|
||||||
|
*/
|
||||||
|
async function dispose() {
|
||||||
|
if (savePositionInterval) {
|
||||||
|
clearInterval(savePositionInterval);
|
||||||
|
savePositionInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoElement && currentVideoId) {
|
||||||
|
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sabrAdapter) {
|
||||||
|
sabrAdapter.dispose();
|
||||||
|
sabrAdapter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player) {
|
||||||
|
await player.destroy();
|
||||||
|
player = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui) {
|
||||||
|
ui.destroy();
|
||||||
|
ui = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shakaContainer && shakaContainer.parentNode) {
|
||||||
|
shakaContainer.parentNode.removeChild(shakaContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
videoElement = null;
|
||||||
|
shakaContainer = null;
|
||||||
|
currentVideoId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current player instance
|
||||||
|
*/
|
||||||
|
function getPlayer() {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current video element
|
||||||
|
*/
|
||||||
|
function getVideoElement() {
|
||||||
|
return videoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadVideo: loadVideo,
|
||||||
|
dispose: dispose,
|
||||||
|
getPlayer: getPlayer,
|
||||||
|
getVideoElement: getVideoElement,
|
||||||
|
initInnertube: initInnertube,
|
||||||
|
fetchOnesieConfig: fetchOnesieConfig
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Export for use
|
||||||
|
window.SABRPlayer = SABRPlayer;
|
||||||
270
assets/js/sabr_potoken.js
Normal file
270
assets/js/sabr_potoken.js
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* BotGuard Service - PoToken generation for SABR streaming
|
||||||
|
* Ported from Kira project (https://github.com/LuanRT/kira)
|
||||||
|
*
|
||||||
|
* This module handles:
|
||||||
|
* - BotGuard challenge fetching and processing
|
||||||
|
* - Integrity token generation
|
||||||
|
* - WebPO minter creation for content-bound tokens
|
||||||
|
* - Cold start token generation for quick fallback
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var BotguardService = (function() {
|
||||||
|
var WAA_REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
|
||||||
|
// Use the API key from bgutils-js/Kira which has access to Web Anti-Abuse API
|
||||||
|
var GOOG_API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
|
||||||
|
|
||||||
|
var botguardClient = null;
|
||||||
|
var initializationPromise = null;
|
||||||
|
var integrityTokenBasedMinter = null;
|
||||||
|
var bgChallenge = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build URL for BotGuard API calls (using YouTube endpoint, not googleapis.com)
|
||||||
|
* @param {string} action - 'Create' or 'GenerateIT'
|
||||||
|
* @param {boolean} useTrustedEnv
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildURL(action, useTrustedEnv) {
|
||||||
|
// Use YouTube's endpoint instead of googleapis.com to avoid CORS issues
|
||||||
|
var baseUrl = 'https://www.youtube.com/api/jnn/v1/';
|
||||||
|
return baseUrl + action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with proxy support for CORS compliance
|
||||||
|
* All external URLs must go through the Invidious proxy to avoid CORS issues
|
||||||
|
* @param {string} url - URL to fetch
|
||||||
|
* @param {Object} options - Fetch options
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function fetchWithProxy(url, options) {
|
||||||
|
var parsedUrl = new URL(url);
|
||||||
|
var host = parsedUrl.host;
|
||||||
|
|
||||||
|
// Build proxy URL with __host and __path parameters
|
||||||
|
// We use __path instead of putting the path in the URL to avoid issues with special chars like $
|
||||||
|
var proxyUrl = new URL('/proxy', window.location.origin);
|
||||||
|
proxyUrl.searchParams.set('__host', host);
|
||||||
|
proxyUrl.searchParams.set('__path', parsedUrl.pathname);
|
||||||
|
|
||||||
|
// Copy original query parameters
|
||||||
|
parsedUrl.searchParams.forEach(function(value, key) {
|
||||||
|
proxyUrl.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass custom headers through __headers parameter
|
||||||
|
if (options && options.headers) {
|
||||||
|
var headersArray = [];
|
||||||
|
for (var key in options.headers) {
|
||||||
|
if (options.headers.hasOwnProperty(key)) {
|
||||||
|
headersArray.push([key, options.headers[key]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyUrl.searchParams.set('__headers', JSON.stringify(headersArray));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the proxied request (headers are passed through __headers param)
|
||||||
|
return fetch(proxyUrl.toString(), {
|
||||||
|
method: options?.method || 'GET',
|
||||||
|
body: options?.body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the BotGuard client
|
||||||
|
* @returns {Promise<Object|undefined>}
|
||||||
|
*/
|
||||||
|
async function init() {
|
||||||
|
if (initializationPromise) {
|
||||||
|
return await initializationPromise;
|
||||||
|
}
|
||||||
|
return setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal setup function
|
||||||
|
* @returns {Promise<Object|undefined>}
|
||||||
|
*/
|
||||||
|
async function setup() {
|
||||||
|
if (initializationPromise) {
|
||||||
|
return await initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializationPromise = _initBotguard();
|
||||||
|
|
||||||
|
try {
|
||||||
|
botguardClient = await initializationPromise;
|
||||||
|
return botguardClient;
|
||||||
|
} finally {
|
||||||
|
initializationPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal BotGuard initialization
|
||||||
|
* @returns {Promise<Object|undefined>}
|
||||||
|
*/
|
||||||
|
async function _initBotguard() {
|
||||||
|
// Check if BG (bgutils-js) is available
|
||||||
|
if (typeof BG === 'undefined') {
|
||||||
|
console.error('[BotguardService]', 'bgutils-js not loaded');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First call (Create) uses direct fetch - no proxy needed
|
||||||
|
var challengeResponse = await fetch(buildURL('Create', true), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json+protobuf',
|
||||||
|
'x-goog-api-key': GOOG_API_KEY,
|
||||||
|
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||||
|
},
|
||||||
|
body: JSON.stringify([WAA_REQUEST_KEY])
|
||||||
|
});
|
||||||
|
|
||||||
|
var challengeResponseData = await challengeResponse.json();
|
||||||
|
bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||||
|
|
||||||
|
if (!bgChallenge) {
|
||||||
|
console.error('[BotguardService]', 'Failed to parse challenge data');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
var interpreterJavascript = bgChallenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||||
|
|
||||||
|
if (!interpreterJavascript) {
|
||||||
|
console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', bgChallenge.interpreterHash);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the interpreter script if not already present
|
||||||
|
if (!document.getElementById(bgChallenge.interpreterHash)) {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
script.id = bgChallenge.interpreterHash;
|
||||||
|
script.textContent = interpreterJavascript;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the BotGuard client
|
||||||
|
botguardClient = await BG.BotGuardClient.create({
|
||||||
|
globalObj: globalThis,
|
||||||
|
globalName: bgChallenge.globalName,
|
||||||
|
program: bgChallenge.program
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate integrity token and create WebPO minter
|
||||||
|
if (bgChallenge) {
|
||||||
|
var webPoSignalOutput = [];
|
||||||
|
var botguardResponse = await botguardClient.snapshot({ webPoSignalOutput: webPoSignalOutput });
|
||||||
|
|
||||||
|
var integrityTokenResponse = await fetchWithProxy(buildURL('GenerateIT', true), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json+protobuf',
|
||||||
|
'x-goog-api-key': GOOG_API_KEY,
|
||||||
|
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||||
|
},
|
||||||
|
body: JSON.stringify([WAA_REQUEST_KEY, botguardResponse])
|
||||||
|
});
|
||||||
|
|
||||||
|
var integrityTokenResponseData = await integrityTokenResponse.json();
|
||||||
|
var integrityToken = integrityTokenResponseData[0];
|
||||||
|
|
||||||
|
if (!integrityToken) {
|
||||||
|
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', bgChallenge.interpreterHash);
|
||||||
|
return botguardClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: integrityToken }, webPoSignalOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
return botguardClient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BotguardService]', 'Error initializing BotGuard:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a cold start token (quick fallback)
|
||||||
|
* @param {string} contentBinding - Content binding (usually video ID)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function mintColdStartToken(contentBinding) {
|
||||||
|
if (typeof BG === 'undefined') {
|
||||||
|
console.error('[BotguardService]', 'bgutils-js not loaded');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return BG.PoToken.generateColdStartToken(contentBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if BotGuard is fully initialized
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isInitialized() {
|
||||||
|
return !!botguardClient && !!integrityTokenBasedMinter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a WebPO token for content binding
|
||||||
|
* @param {string} contentBinding - Content binding (usually video ID)
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function mintWebPoToken(contentBinding) {
|
||||||
|
if (!integrityTokenBasedMinter) {
|
||||||
|
throw new Error('WebPO minter not initialized');
|
||||||
|
}
|
||||||
|
return await integrityTokenBasedMinter.mintAsWebsafeString(contentBinding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the BotGuard client
|
||||||
|
*/
|
||||||
|
function dispose() {
|
||||||
|
if (botguardClient && bgChallenge) {
|
||||||
|
try {
|
||||||
|
botguardClient.shutdown();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore shutdown errors
|
||||||
|
}
|
||||||
|
botguardClient = null;
|
||||||
|
integrityTokenBasedMinter = null;
|
||||||
|
|
||||||
|
var script = document.getElementById(bgChallenge.interpreterHash);
|
||||||
|
if (script) {
|
||||||
|
script.remove();
|
||||||
|
}
|
||||||
|
bgChallenge = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinitialize BotGuard
|
||||||
|
* @returns {Promise<Object|undefined>}
|
||||||
|
*/
|
||||||
|
async function reinit() {
|
||||||
|
if (initializationPromise) {
|
||||||
|
return initializationPromise;
|
||||||
|
}
|
||||||
|
dispose();
|
||||||
|
return setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
mintColdStartToken: mintColdStartToken,
|
||||||
|
mintWebPoToken: mintWebPoToken,
|
||||||
|
isInitialized: isInitialized,
|
||||||
|
dispose: dispose,
|
||||||
|
reinit: reinit
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
window.BotguardService = BotguardService;
|
||||||
736
assets/js/sabr_shaka_adapter.js
Normal file
736
assets/js/sabr_shaka_adapter.js
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* SABR Shaka Player Adapter
|
||||||
|
* Ported from Kira project (https://github.com/LuanRT/kira)
|
||||||
|
*
|
||||||
|
* This module provides the ShakaPlayerAdapter class that implements
|
||||||
|
* the SabrPlayerAdapter interface for use with the SABR streaming adapter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var ShakaPlayerAdapter = (function() {
|
||||||
|
/**
|
||||||
|
* Convert object to Map
|
||||||
|
* @param {Object} object
|
||||||
|
* @returns {Map}
|
||||||
|
*/
|
||||||
|
function asMap(object) {
|
||||||
|
var map = new Map();
|
||||||
|
for (var key in object) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
||||||
|
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
|
||||||
|
* @param {BufferSource} data
|
||||||
|
* @param {number} status
|
||||||
|
* @param {string} uri
|
||||||
|
* @param {string} responseURL
|
||||||
|
* @param {Object} request
|
||||||
|
* @param {number} requestType
|
||||||
|
* @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
|
||||||
|
* @param {Object} 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShakaPlayerAdapter class implementing SabrPlayerAdapter interface
|
||||||
|
*/
|
||||||
|
function ShakaPlayerAdapter() {
|
||||||
|
this.player = null;
|
||||||
|
this.requestMetadataManager = null;
|
||||||
|
this.cacheManager = null;
|
||||||
|
this.abortController = null;
|
||||||
|
this.requestFilter = null;
|
||||||
|
this.responseFilter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the adapter with a Shaka player instance
|
||||||
|
* @param {shaka.Player} player
|
||||||
|
* @param {RequestMetadataManager} requestMetadataManager
|
||||||
|
* @param {CacheManager} cacheManager
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.initialize = function(player, requestMetadataManager, cacheManager) {
|
||||||
|
console.log('[ShakaPlayerAdapter] initialize() called', { player: !!player, requestMetadataManager: !!requestMetadataManager, cacheManager: !!cacheManager });
|
||||||
|
var self = this;
|
||||||
|
this.player = player;
|
||||||
|
this.requestMetadataManager = requestMetadataManager;
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
|
||||||
|
var networkingEngine = shaka.net.NetworkingEngine;
|
||||||
|
var schemes = ['http', 'https'];
|
||||||
|
console.log('[ShakaPlayerAdapter] Registering schemes:', schemes);
|
||||||
|
|
||||||
|
if (!shaka.net.HttpFetchPlugin.isSupported()) {
|
||||||
|
throw new Error('The Fetch API is not supported in this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
schemes.forEach(function(scheme) {
|
||||||
|
console.log('[ShakaPlayerAdapter] Registering scheme:', scheme);
|
||||||
|
networkingEngine.registerScheme(
|
||||||
|
scheme,
|
||||||
|
self.parseRequest.bind(self),
|
||||||
|
networkingEngine.PluginPriority.PREFERRED
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.log('[ShakaPlayerAdapter] Initialization complete');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and handle a network request
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.parseRequest = function(
|
||||||
|
uri, request, requestType, progressUpdated, headersReceived, config
|
||||||
|
) {
|
||||||
|
var self = this;
|
||||||
|
var headers = new Headers();
|
||||||
|
asMap(request.headers).forEach(function(value, key) {
|
||||||
|
headers.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = new AbortController();
|
||||||
|
this.abortController = controller;
|
||||||
|
|
||||||
|
var init = {
|
||||||
|
body: request.body || undefined,
|
||||||
|
headers: headers,
|
||||||
|
method: request.method,
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
var abortStatus = { canceled: false, timedOut: false };
|
||||||
|
var minBytes = config.minBytesForProgressEvents || 0;
|
||||||
|
|
||||||
|
var pendingRequest = this.doRequest(
|
||||||
|
uri, request, requestType, init, controller,
|
||||||
|
abortStatus, progressUpdated, headersReceived, minBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
var operation = new shaka.util.AbortableOperation(
|
||||||
|
pendingRequest,
|
||||||
|
function() {
|
||||||
|
abortStatus.canceled = true;
|
||||||
|
controller.abort();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
var timeoutMs = request.retryParameters.timeout;
|
||||||
|
if (timeoutMs) {
|
||||||
|
var timer = new shaka.util.Timer(function() {
|
||||||
|
abortStatus.timedOut = true;
|
||||||
|
controller.abort();
|
||||||
|
console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
|
||||||
|
});
|
||||||
|
timer.tickAfter(timeoutMs / 1000);
|
||||||
|
operation.finally(function() { timer.stop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cached request
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.handleCachedRequest = async function(
|
||||||
|
requestMetadata, uri, request, progressUpdated, headersReceived, requestType
|
||||||
|
) {
|
||||||
|
if (!requestMetadata.byteRange || !this.cacheManager) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if FormatKeyUtils is available
|
||||||
|
if (typeof FormatKeyUtils === 'undefined' || !FormatKeyUtils.createSegmentCacheKeyFromMetadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
|
||||||
|
|
||||||
|
var arrayBuffer = requestMetadata.isInit
|
||||||
|
? this.cacheManager.getInitSegment(segmentKey)?.buffer
|
||||||
|
: this.cacheManager.getSegment(segmentKey)?.buffer;
|
||||||
|
|
||||||
|
if (!arrayBuffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestMetadata.isInit) {
|
||||||
|
arrayBuffer = arrayBuffer.slice(
|
||||||
|
requestMetadata.byteRange.start,
|
||||||
|
requestMetadata.byteRange.end + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
'content-type': requestMetadata.format?.mimeType?.split(';')[0] || '',
|
||||||
|
'content-length': arrayBuffer.byteLength.toString(),
|
||||||
|
'x-shaka-from-cache': 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
headersReceived(headers);
|
||||||
|
progressUpdated(0, arrayBuffer.byteLength, 0);
|
||||||
|
|
||||||
|
return makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle UMP response (SABR streaming format)
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.handleUmpResponse = async function(
|
||||||
|
response, requestMetadata, uri, request, requestType,
|
||||||
|
progressUpdated, abortController, minBytes
|
||||||
|
) {
|
||||||
|
var self = this;
|
||||||
|
var lastTime = Date.now();
|
||||||
|
|
||||||
|
// Check if SabrUmpProcessor is available
|
||||||
|
if (typeof SabrUmpProcessor === 'undefined') {
|
||||||
|
console.warn('[ShakaPlayerAdapter]', 'SabrUmpProcessor not available, falling back to normal handling');
|
||||||
|
var arrayBuffer = await response.arrayBuffer();
|
||||||
|
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: arrayBuffer });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
|
||||||
|
|
||||||
|
function checkResultIntegrity(result) {
|
||||||
|
if (!result.data && ((!!requestMetadata.error || requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) && !requestMetadata.streamInfo?.sabrContextUpdate)) {
|
||||||
|
throw createRecoverableError('Server streaming error', requestMetadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReturnEmptyResponse() {
|
||||||
|
return requestMetadata.isSABR && (requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If response body is not a ReadableStream, handle whole response
|
||||||
|
if (!response.body) {
|
||||||
|
var arrayBuffer = await response.arrayBuffer();
|
||||||
|
var currentTime = Date.now();
|
||||||
|
|
||||||
|
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||||
|
|
||||||
|
var result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
checkResultIntegrity(result);
|
||||||
|
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: result.data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReturnEmptyResponse()) {
|
||||||
|
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream processing with ReadableStream
|
||||||
|
var reader = response.body.getReader();
|
||||||
|
var loaded = 0;
|
||||||
|
var lastLoaded = 0;
|
||||||
|
var contentLength;
|
||||||
|
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
var readObj;
|
||||||
|
try {
|
||||||
|
readObj = await reader.read();
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = readObj.value;
|
||||||
|
var done = readObj.done;
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
if (shouldReturnEmptyResponse()) {
|
||||||
|
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: undefined });
|
||||||
|
}
|
||||||
|
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await sabrUmpReader.processChunk(value);
|
||||||
|
var segmentInfo = sabrUmpReader.getSegmentInfo();
|
||||||
|
|
||||||
|
if (segmentInfo) {
|
||||||
|
if (!contentLength) {
|
||||||
|
contentLength = segmentInfo.mediaHeader.contentLength;
|
||||||
|
}
|
||||||
|
loaded += segmentInfo.lastChunkSize || 0;
|
||||||
|
segmentInfo.lastChunkSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTime = Date.now();
|
||||||
|
var chunkSize = loaded - lastLoaded;
|
||||||
|
|
||||||
|
if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
|
||||||
|
if (result) checkResultIntegrity(result);
|
||||||
|
if (contentLength) {
|
||||||
|
var numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
|
||||||
|
try {
|
||||||
|
progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
|
||||||
|
} catch (e) { /* no-op */ }
|
||||||
|
finally {
|
||||||
|
lastLoaded = loaded;
|
||||||
|
lastTime = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
abortController.abort();
|
||||||
|
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: result.data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createRecoverableError('UMP stream processing was aborted but did not produce a result.', requestMetadata);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the network request
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.doRequest = async function(
|
||||||
|
uri, request, requestType, init, abortController,
|
||||||
|
abortStatus, progressUpdated, headersReceived, minBytes
|
||||||
|
) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[ShakaPlayerAdapter] doRequest called:', { uri: uri, requestType: requestType });
|
||||||
|
|
||||||
|
// Convert sabr:// URLs to HTTP URLs before processing
|
||||||
|
if (uri.startsWith('sabr://')) {
|
||||||
|
// sabr:// URLs should have been converted by the request interceptor
|
||||||
|
// If we reach here, the interceptor wasn't set up properly
|
||||||
|
console.error('[ShakaPlayerAdapter] *** sabr:// URL reached doRequest without being converted:', uri);
|
||||||
|
console.error('[ShakaPlayerAdapter] This means the request interceptor is not working!');
|
||||||
|
// Try to handle it anyway - this shouldn't normally happen
|
||||||
|
return makeResponse({}, new ArrayBuffer(0), 200, uri, uri, request, requestType);
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (requestMetadata) {
|
||||||
|
var cachedResponse = await this.handleCachedRequest(
|
||||||
|
requestMetadata, uri, request, progressUpdated, headersReceived, requestType
|
||||||
|
);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy Google Video URLs
|
||||||
|
var fetchUrl = uri;
|
||||||
|
if (isGoogleVideoURL(uri)) {
|
||||||
|
// Ensure required headers are present for googlevideo requests
|
||||||
|
var headersForProxy = init.headers;
|
||||||
|
|
||||||
|
// Debug: log initial headers
|
||||||
|
console.log('[ShakaPlayerAdapter] Initial init.headers:', init.headers ? 'exists' : 'null',
|
||||||
|
'method:', init.method);
|
||||||
|
if (init.headers) {
|
||||||
|
var initHeadersDebug = [];
|
||||||
|
init.headers.forEach(function(v, k) { initHeadersDebug.push(k); });
|
||||||
|
console.log('[ShakaPlayerAdapter] Init headers keys:', initHeadersDebug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For SABR requests (POST to videoplayback), ensure we have proper headers
|
||||||
|
if (!headersForProxy || headersForProxy.entries().next().done) {
|
||||||
|
headersForProxy = new Headers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add User-Agent for googlevideo requests to avoid 403
|
||||||
|
// Use the browser's actual User-Agent
|
||||||
|
if (!headersForProxy.has('user-agent')) {
|
||||||
|
headersForProxy.set('user-agent', navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For POST requests (SABR), add additional required headers
|
||||||
|
if (init.method === 'POST') {
|
||||||
|
console.log('[ShakaPlayerAdapter] POST request detected, adding content-type header');
|
||||||
|
headersForProxy.set('content-type', 'application/x-protobuf');
|
||||||
|
if (!headersForProxy.has('origin')) {
|
||||||
|
headersForProxy.set('origin', 'https://www.youtube.com');
|
||||||
|
}
|
||||||
|
if (!headersForProxy.has('referer')) {
|
||||||
|
headersForProxy.set('referer', 'https://www.youtube.com/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: log the headers being sent
|
||||||
|
var debugHeaders = [];
|
||||||
|
headersForProxy.forEach(function(v, k) { debugHeaders.push([k, v.substring(0, 50)]); });
|
||||||
|
console.log('[ShakaPlayerAdapter] Headers for proxy:', debugHeaders);
|
||||||
|
|
||||||
|
fetchUrl = SABRHelpers.proxyUrl(uri, headersForProxy).toString();
|
||||||
|
// Set Content-Type on the fetch request for POST (needed for the proxy to know the body type)
|
||||||
|
// Other headers are passed via __headers param and will be forwarded by the proxy
|
||||||
|
init.headers = new Headers();
|
||||||
|
if (init.method === 'POST') {
|
||||||
|
init.headers.set('Content-Type', 'application/x-protobuf');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await fetch(fetchUrl, init);
|
||||||
|
|
||||||
|
// Debug log for POST requests
|
||||||
|
if (init.method === 'POST' && init.body) {
|
||||||
|
var bodySize = init.body instanceof ArrayBuffer ? init.body.byteLength :
|
||||||
|
(init.body instanceof Uint8Array ? init.body.byteLength : 0);
|
||||||
|
console.log('[ShakaPlayerAdapter] POST request sent:', {
|
||||||
|
url: fetchUrl.substring(0, 100) + '...',
|
||||||
|
bodySize: bodySize + ' bytes',
|
||||||
|
status: response.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
headersReceived(headersToGenericObject(response.headers));
|
||||||
|
|
||||||
|
// Handle UMP response
|
||||||
|
if (requestMetadata && init.method !== 'HEAD' && response.headers.get('content-type') === 'application/vnd.yt-ump') {
|
||||||
|
return this.handleUmpResponse(
|
||||||
|
response, requestMetadata, uri, request, requestType,
|
||||||
|
progressUpdated, abortController, minBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle normal response
|
||||||
|
var lastTime = Date.now();
|
||||||
|
var arrayBuffer = await response.arrayBuffer();
|
||||||
|
var currentTime = Date.now();
|
||||||
|
|
||||||
|
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
||||||
|
|
||||||
|
return this.createShakaResponse({
|
||||||
|
uri: uri,
|
||||||
|
request: request,
|
||||||
|
requestType: requestType,
|
||||||
|
response: response,
|
||||||
|
arrayBuffer: arrayBuffer
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (abortStatus.canceled) {
|
||||||
|
throw new shaka.util.Error(
|
||||||
|
shaka.util.Error.Severity.RECOVERABLE,
|
||||||
|
shaka.util.Error.Category.NETWORK,
|
||||||
|
shaka.util.Error.Code.OPERATION_ABORTED,
|
||||||
|
uri, requestType
|
||||||
|
);
|
||||||
|
} else if (abortStatus.timedOut) {
|
||||||
|
throw new shaka.util.Error(
|
||||||
|
shaka.util.Error.Severity.RECOVERABLE,
|
||||||
|
shaka.util.Error.Category.NETWORK,
|
||||||
|
shaka.util.Error.Code.TIMEOUT,
|
||||||
|
uri, requestType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new shaka.util.Error(
|
||||||
|
shaka.util.Error.Severity.RECOVERABLE,
|
||||||
|
shaka.util.Error.Category.NETWORK,
|
||||||
|
shaka.util.Error.Code.HTTP_ERROR,
|
||||||
|
uri, error, requestType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that player is initialized
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.checkPlayerStatus = function() {
|
||||||
|
if (!this.player) {
|
||||||
|
throw new Error('Player not initialized');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current playback time
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.getPlayerTime = function() {
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
var mediaElement = this.player.getMediaElement();
|
||||||
|
return mediaElement ? mediaElement.currentTime : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current playback rate
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.getPlaybackRate = function() {
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
return this.player.getPlaybackRate();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bandwidth estimate
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.getBandwidthEstimate = function() {
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
return this.player.getStats().estimatedBandwidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active track formats
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.getActiveTrackFormats = function(activeFormat, sabrFormats) {
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
|
||||||
|
// Check if FormatKeyUtils is available
|
||||||
|
if (typeof FormatKeyUtils === 'undefined' || !FormatKeyUtils.getUniqueFormatId) {
|
||||||
|
return { videoFormat: undefined, audioFormat: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeVariant = this.player.getVariantTracks().find(function(track) {
|
||||||
|
return FormatKeyUtils.getUniqueFormatId(activeFormat) === (activeFormat.width ? track.originalVideoId : track.originalAudioId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeVariant) {
|
||||||
|
return { videoFormat: undefined, audioFormat: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
var formatMap = new Map(sabrFormats.map(function(format) {
|
||||||
|
return [FormatKeyUtils.getUniqueFormatId(format), format];
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoFormat: activeVariant.originalVideoId ? formatMap.get(activeVariant.originalVideoId) : undefined,
|
||||||
|
audioFormat: activeVariant.originalAudioId ? formatMap.get(activeVariant.originalAudioId) : undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register request interceptor
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.registerRequestInterceptor = function(interceptor) {
|
||||||
|
console.log('[ShakaPlayerAdapter] registerRequestInterceptor() called');
|
||||||
|
var self = this;
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
|
||||||
|
var networkingEngine = this.player.getNetworkingEngine();
|
||||||
|
if (!networkingEngine) {
|
||||||
|
console.warn('[ShakaPlayerAdapter] No networking engine available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[ShakaPlayerAdapter] Got networking engine, registering filter');
|
||||||
|
|
||||||
|
this.requestFilter = async function(type, request, context) {
|
||||||
|
console.log('[ShakaPlayerAdapter] Request filter called:', { type: type, uri: request.uris[0] });
|
||||||
|
|
||||||
|
// Check if this is a SEGMENT request that needs processing
|
||||||
|
// Process sabr:// URLs (need conversion) and googlevideo URLs (already converted)
|
||||||
|
var uri = request.uris[0];
|
||||||
|
var isSabrUrl = uri.startsWith('sabr://');
|
||||||
|
var isGoogleVideo = isGoogleVideoURL(uri);
|
||||||
|
|
||||||
|
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || (!isSabrUrl && !isGoogleVideo)) {
|
||||||
|
console.log('[ShakaPlayerAdapter] Skipping request (not segment or not sabr/googlevideo):', uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ShakaPlayerAdapter] Calling interceptor for URL:', request.uris[0]);
|
||||||
|
try {
|
||||||
|
var modifiedRequest = await interceptor({
|
||||||
|
headers: request.headers,
|
||||||
|
url: request.uris[0],
|
||||||
|
method: request.method,
|
||||||
|
segment: {
|
||||||
|
getStartTime: function() { return context?.segment?.getStartTime() ?? null; },
|
||||||
|
isInit: function() { return !context?.segment; }
|
||||||
|
},
|
||||||
|
body: request.body
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[ShakaPlayerAdapter] Interceptor returned:', modifiedRequest);
|
||||||
|
|
||||||
|
if (modifiedRequest) {
|
||||||
|
console.log('[ShakaPlayerAdapter] Request modified:', { oldUrl: request.uris[0], newUrl: modifiedRequest.url });
|
||||||
|
request.uris = modifiedRequest.url ? [modifiedRequest.url] : request.uris;
|
||||||
|
request.method = modifiedRequest.method || request.method;
|
||||||
|
request.headers = modifiedRequest.headers || request.headers;
|
||||||
|
request.body = modifiedRequest.body || request.body;
|
||||||
|
} else {
|
||||||
|
console.warn('[ShakaPlayerAdapter] Interceptor returned null/undefined for:', request.uris[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ShakaPlayerAdapter] Interceptor error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
networkingEngine.registerRequestFilter(this.requestFilter);
|
||||||
|
console.log('[ShakaPlayerAdapter] Request filter registered');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register response interceptor
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.registerResponseInterceptor = function(interceptor) {
|
||||||
|
var self = this;
|
||||||
|
this.checkPlayerStatus();
|
||||||
|
|
||||||
|
var networkingEngine = this.player.getNetworkingEngine();
|
||||||
|
if (!networkingEngine) return;
|
||||||
|
|
||||||
|
this.responseFilter = async function(type, response, context) {
|
||||||
|
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(response.uri)) return;
|
||||||
|
|
||||||
|
var modifiedResponse = await interceptor({
|
||||||
|
url: response.originalRequest.uris[0],
|
||||||
|
method: response.originalRequest.method,
|
||||||
|
headers: response.headers,
|
||||||
|
data: response.data,
|
||||||
|
makeRequest: async function(url, headers) {
|
||||||
|
var retryParameters = self.player.getConfiguration().streaming.retryParameters;
|
||||||
|
var redirectRequest = shaka.net.NetworkingEngine.makeRequest([url], retryParameters);
|
||||||
|
Object.assign(redirectRequest.headers, headers);
|
||||||
|
|
||||||
|
var requestOperation = networkingEngine.request(type, redirectRequest, context);
|
||||||
|
var redirectResponse = await requestOperation.promise;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: redirectResponse.uri,
|
||||||
|
method: redirectResponse.originalRequest.method,
|
||||||
|
headers: redirectResponse.headers,
|
||||||
|
data: redirectResponse.data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modifiedResponse) {
|
||||||
|
response.data = modifiedResponse.data ?? response.data;
|
||||||
|
Object.assign(response.headers, modifiedResponse.headers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
networkingEngine.registerResponseFilter(this.responseFilter);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Shaka response object
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.createShakaResponse = function(args) {
|
||||||
|
return makeResponse(
|
||||||
|
headersToGenericObject(args.response.headers),
|
||||||
|
args.arrayBuffer || new ArrayBuffer(0),
|
||||||
|
args.response.status,
|
||||||
|
args.uri,
|
||||||
|
args.response.url,
|
||||||
|
args.request,
|
||||||
|
args.requestType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the adapter
|
||||||
|
*/
|
||||||
|
ShakaPlayerAdapter.prototype.dispose = function() {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.player) {
|
||||||
|
var networkingEngine = this.player.getNetworkingEngine();
|
||||||
|
|
||||||
|
if (networkingEngine) {
|
||||||
|
if (this.requestFilter) {
|
||||||
|
networkingEngine.unregisterRequestFilter(this.requestFilter);
|
||||||
|
}
|
||||||
|
if (this.responseFilter) {
|
||||||
|
networkingEngine.unregisterResponseFilter(this.responseFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shaka.net.NetworkingEngine.unregisterScheme('http');
|
||||||
|
shaka.net.NetworkingEngine.unregisterScheme('https');
|
||||||
|
|
||||||
|
this.player = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return ShakaPlayerAdapter;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Export for use
|
||||||
|
window.ShakaPlayerAdapter = ShakaPlayerAdapter;
|
||||||
@ -80,6 +80,7 @@
|
|||||||
"preferences_speed_label": "Default speed: ",
|
"preferences_speed_label": "Default speed: ",
|
||||||
"preferences_quality_label": "Preferred video quality: ",
|
"preferences_quality_label": "Preferred video quality: ",
|
||||||
"preferences_quality_option_dash": "DASH (adaptive quality)",
|
"preferences_quality_option_dash": "DASH (adaptive quality)",
|
||||||
|
"preferences_quality_option_sabr": "SABR (experimental)",
|
||||||
"preferences_quality_option_hd720": "HD720",
|
"preferences_quality_option_hd720": "HD720",
|
||||||
"preferences_quality_option_medium": "Medium",
|
"preferences_quality_option_medium": "Medium",
|
||||||
"preferences_quality_option_small": "Small",
|
"preferences_quality_option_small": "Small",
|
||||||
@ -96,6 +97,10 @@
|
|||||||
"preferences_quality_dash_option_360p": "360p",
|
"preferences_quality_dash_option_360p": "360p",
|
||||||
"preferences_quality_dash_option_240p": "240p",
|
"preferences_quality_dash_option_240p": "240p",
|
||||||
"preferences_quality_dash_option_144p": "144p",
|
"preferences_quality_dash_option_144p": "144p",
|
||||||
|
"preferences_quality_sabr_label": "Preferred SABR video codec: ",
|
||||||
|
"preferences_quality_sabr_option_vp9": "VP9 (default)",
|
||||||
|
"preferences_quality_sabr_option_av1": "AV1",
|
||||||
|
"preferences_quality_sabr_option_h264": "H.264",
|
||||||
"preferences_volume_label": "Player volume: ",
|
"preferences_volume_label": "Player volume: ",
|
||||||
"preferences_comments_label": "Default comments: ",
|
"preferences_comments_label": "Default comments: ",
|
||||||
"youtube": "YouTube",
|
"youtube": "YouTube",
|
||||||
|
|||||||
551
package-lock.json
generated
Normal file
551
package-lock.json
generated
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
{
|
||||||
|
"name": "invidious-sabr",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "invidious-sabr",
|
||||||
|
"dependencies": {
|
||||||
|
"bgutils-js": "^3.1.3",
|
||||||
|
"googlevideo": "^4.0.4",
|
||||||
|
"youtubei.js": "^16.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.27.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "2.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
|
||||||
|
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bgutils-js": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/LuanRT"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
|
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.2",
|
||||||
|
"@esbuild/android-arm": "0.27.2",
|
||||||
|
"@esbuild/android-arm64": "0.27.2",
|
||||||
|
"@esbuild/android-x64": "0.27.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.2",
|
||||||
|
"@esbuild/darwin-x64": "0.27.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.2",
|
||||||
|
"@esbuild/linux-arm": "0.27.2",
|
||||||
|
"@esbuild/linux-arm64": "0.27.2",
|
||||||
|
"@esbuild/linux-ia32": "0.27.2",
|
||||||
|
"@esbuild/linux-loong64": "0.27.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.2",
|
||||||
|
"@esbuild/linux-s390x": "0.27.2",
|
||||||
|
"@esbuild/linux-x64": "0.27.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.2",
|
||||||
|
"@esbuild/sunos-x64": "0.27.2",
|
||||||
|
"@esbuild/win32-arm64": "0.27.2",
|
||||||
|
"@esbuild/win32-ia32": "0.27.2",
|
||||||
|
"@esbuild/win32-x64": "0.27.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googlevideo": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/LuanRT"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/meriyah": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/youtubei.js": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/LuanRT"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.0.0",
|
||||||
|
"meriyah": "^6.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "invidious-sabr",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"bundle-sabr": "node scripts/bundle-sabr-libs.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"googlevideo": "^4.0.4",
|
||||||
|
"youtubei.js": "^16.0.0",
|
||||||
|
"bgutils-js": "^3.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.27.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
sabr-dependencies.yml
Normal file
23
sabr-dependencies.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# SABR (Server ABR) streaming dependencies
|
||||||
|
# These are fetched from esm.sh as pre-built ESM bundles
|
||||||
|
|
||||||
|
shaka-player:
|
||||||
|
version: 4.16.4
|
||||||
|
# esm.sh URL: https://esm.sh/shaka-player@4.16.4/dist/shaka-player.compiled.js
|
||||||
|
files:
|
||||||
|
- src: "dist/shaka-player.compiled.js"
|
||||||
|
dest: "shaka-player.js"
|
||||||
|
- src: "dist/controls.css"
|
||||||
|
dest: "shaka-player.css"
|
||||||
|
|
||||||
|
googlevideo:
|
||||||
|
version: 4.0.4
|
||||||
|
# Pre-bundled ESM from esm.sh
|
||||||
|
|
||||||
|
youtubei.js:
|
||||||
|
version: 16.0.1
|
||||||
|
# Pre-bundled ESM from esm.sh (web bundle)
|
||||||
|
|
||||||
|
bgutils-js:
|
||||||
|
version: 3.2.0
|
||||||
|
# Pre-bundled ESM from esm.sh
|
||||||
93
scripts/bundle-sabr-libs.js
Normal file
93
scripts/bundle-sabr-libs.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle SABR libraries (googlevideo, youtubei.js, bgutils-js) using esbuild
|
||||||
|
* This creates properly bundled files with all necessary exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const nodeModules = path.join(__dirname, '../node_modules');
|
||||||
|
const outputDir = path.join(__dirname, '../assets/js/sabr');
|
||||||
|
|
||||||
|
// Ensure output directories exist
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'googlevideo'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'youtubei.js'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(outputDir, 'bgutils-js'), { recursive: true });
|
||||||
|
|
||||||
|
// Create a custom entry point that re-exports everything from googlevideo
|
||||||
|
const googlevideoEntryContent = `
|
||||||
|
export * from 'googlevideo/sabr-streaming-adapter';
|
||||||
|
export * from 'googlevideo/ump';
|
||||||
|
export * from 'googlevideo/utils';
|
||||||
|
export * from 'googlevideo/protos';
|
||||||
|
`;
|
||||||
|
|
||||||
|
const googlevideoEntryPath = path.join(__dirname, 'temp-googlevideo-entry.js');
|
||||||
|
fs.writeFileSync(googlevideoEntryPath, googlevideoEntryContent);
|
||||||
|
|
||||||
|
// Bundle googlevideo with all exports
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [googlevideoEntryPath],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: path.join(outputDir, 'googlevideo/googlevideo.bundle.min.js'),
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
external: [],
|
||||||
|
nodePaths: [nodeModules],
|
||||||
|
banner: {
|
||||||
|
js: '// googlevideo library - bundled with esbuild'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
console.log('✓ googlevideo bundled successfully');
|
||||||
|
fs.unlinkSync(googlevideoEntryPath);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('✗ googlevideo bundling failed:', err);
|
||||||
|
try { fs.unlinkSync(googlevideoEntryPath); } catch (e) {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bundle youtubei.js
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(nodeModules, 'youtubei.js/bundle/browser.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: path.join(outputDir, 'youtubei.js/youtubei.bundle.min.js'),
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
external: [],
|
||||||
|
banner: {
|
||||||
|
js: '// youtubei.js library - bundled with esbuild'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
console.log('✓ youtubei.js bundled successfully');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('✗ youtubei.js bundling failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bundle bgutils-js
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(nodeModules, 'bgutils-js/dist/index.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: path.join(outputDir, 'bgutils-js/bgutils.bundle.min.js'),
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
external: [],
|
||||||
|
banner: {
|
||||||
|
js: '// bgutils-js library - bundled with esbuild'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
console.log('✓ bgutils-js bundled successfully');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('✗ bgutils-js bundling failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -162,3 +162,7 @@ end
|
|||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
`rm -rf #{tmp_dir_path}`
|
`rm -rf #{tmp_dir_path}`
|
||||||
|
|
||||||
|
# Also fetch SABR dependencies
|
||||||
|
puts "#{"Checking".colorize(:green)} #{"SABR".colorize(:blue)} dependencies..."
|
||||||
|
`crystal run scripts/fetch-sabr-dependencies.cr`
|
||||||
|
|||||||
183
scripts/fetch-sabr-dependencies.cr
Normal file
183
scripts/fetch-sabr-dependencies.cr
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
require "http"
|
||||||
|
require "yaml"
|
||||||
|
require "digest/sha1"
|
||||||
|
require "option_parser"
|
||||||
|
require "colorize"
|
||||||
|
|
||||||
|
# Script to fetch SABR (Server ABR) dependencies
|
||||||
|
# These are pre-built bundles for client-side SABR streaming support
|
||||||
|
|
||||||
|
SABR_DEPENDENCIES = {
|
||||||
|
"shaka-player" => {
|
||||||
|
"version" => "4.16.4",
|
||||||
|
"files" => [
|
||||||
|
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
|
||||||
|
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/controls.css", "dest" => "controls.css"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"googlevideo" => {
|
||||||
|
"version" => "4.0.4",
|
||||||
|
"files" => [
|
||||||
|
# esm.sh bundled version - fetch full bundle path directly
|
||||||
|
{"url" => "https://esm.sh/googlevideo@4.0.4/es2022/sabr-streaming-adapter.bundle.mjs", "dest" => "googlevideo.bundle.min.js"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"youtubei.js" => {
|
||||||
|
"version" => "16.0.1",
|
||||||
|
"files" => [
|
||||||
|
# Use the web bundle from jsdelivr (pre-built by youtubei.js)
|
||||||
|
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@16.0.1/bundle/browser.min.js", "dest" => "youtubei.bundle.min.js"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bgutils-js" => {
|
||||||
|
"version" => "3.2.0",
|
||||||
|
"files" => [
|
||||||
|
{"url" => "https://esm.sh/bgutils-js@3.2.0/es2022/bgutils-js.bundle.mjs", "dest" => "bgutils.bundle.min.js"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_versions_yaml(dep_name : String, version : String)
|
||||||
|
File.open("assets/js/sabr/#{dep_name}/versions.yml", "w") do |io|
|
||||||
|
YAML.build(io) do |builder|
|
||||||
|
builder.mapping do
|
||||||
|
builder.scalar "version"
|
||||||
|
builder.scalar version
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create the main sabr directory if it doesn't exist
|
||||||
|
sabr_dir = "assets/js/sabr"
|
||||||
|
Dir.mkdir_p(sabr_dir) unless Dir.exists?(sabr_dir)
|
||||||
|
|
||||||
|
dependencies_to_install = [] of String
|
||||||
|
|
||||||
|
SABR_DEPENDENCIES.each do |dep_name, dep_info|
|
||||||
|
path = "#{sabr_dir}/#{dep_name}"
|
||||||
|
version = dep_info["version"].as(String)
|
||||||
|
|
||||||
|
if !Dir.exists?(path)
|
||||||
|
Dir.mkdir_p(path)
|
||||||
|
dependencies_to_install << dep_name
|
||||||
|
else
|
||||||
|
if File.exists?("#{path}/versions.yml")
|
||||||
|
config = File.open("#{path}/versions.yml") do |file|
|
||||||
|
YAML.parse(file).as_h
|
||||||
|
end
|
||||||
|
|
||||||
|
if config["version"].as_s != version
|
||||||
|
# Clean old files
|
||||||
|
Dir.glob("#{path}/*.js").each { |f| File.delete(f) }
|
||||||
|
Dir.glob("#{path}/*.css").each { |f| File.delete(f) }
|
||||||
|
dependencies_to_install << dep_name
|
||||||
|
end
|
||||||
|
else
|
||||||
|
dependencies_to_install << dep_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
channel = Channel(String | Exception).new
|
||||||
|
|
||||||
|
dependencies_to_install.each do |dep_name|
|
||||||
|
spawn do
|
||||||
|
dep_info = SABR_DEPENDENCIES[dep_name]
|
||||||
|
version = dep_info["version"].as(String)
|
||||||
|
files = dep_info["files"].as(Array(Hash(String, String)))
|
||||||
|
dest_path = "#{sabr_dir}/#{dep_name}"
|
||||||
|
|
||||||
|
files.each do |file_info|
|
||||||
|
url = file_info["url"]
|
||||||
|
dest_file = file_info["dest"]
|
||||||
|
|
||||||
|
HTTP::Client.get(url) do |response|
|
||||||
|
if response.status_code == 200
|
||||||
|
File.write("#{dest_path}/#{dest_file}", response.body_io.gets_to_end)
|
||||||
|
else
|
||||||
|
raise Exception.new("Failed to fetch #{url}: HTTP #{response.status_code}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
update_versions_yaml(dep_name, version)
|
||||||
|
channel.send(dep_name)
|
||||||
|
rescue ex
|
||||||
|
channel.send(ex)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if dependencies_to_install.empty?
|
||||||
|
puts "#{"SABR".colorize(:blue)} #{"dependencies".colorize(:green)} are satisfied"
|
||||||
|
else
|
||||||
|
puts "#{"Resolving".colorize(:green)} #{"SABR".colorize(:blue)} dependencies"
|
||||||
|
dependencies_to_install.size.times do
|
||||||
|
result = channel.receive
|
||||||
|
|
||||||
|
if result.is_a? Exception
|
||||||
|
raise result
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "#{"Fetched".colorize(:green)} #{result.colorize(:blue)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Post-process: Remove Google Fonts from Shaka controls.css
|
||||||
|
shaka_css_path = "#{sabr_dir}/shaka-player/controls.css"
|
||||||
|
if File.exists?(shaka_css_path)
|
||||||
|
css_content = File.read(shaka_css_path)
|
||||||
|
# Remove @font-face rule that loads from fonts.gstatic.com
|
||||||
|
css_content = css_content.gsub(/@font-face\{[^}]*fonts\.gstatic\.com[^}]*\}/, "")
|
||||||
|
# Replace Roboto font-family with system fonts
|
||||||
|
css_content = css_content.gsub(/font-family:\s*Roboto[^;]*;/, "font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif;")
|
||||||
|
File.write(shaka_css_path, css_content)
|
||||||
|
puts "#{"Patched".colorize(:green)} Shaka CSS to use system fonts"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Post-process: Patch googlevideo bundle to remove esm.sh import and add process shim
|
||||||
|
googlevideo_path = "#{sabr_dir}/googlevideo/googlevideo.bundle.min.js"
|
||||||
|
if File.exists?(googlevideo_path)
|
||||||
|
js_content = File.read(googlevideo_path)
|
||||||
|
|
||||||
|
# Add process shim at the beginning and remove the esm.sh import
|
||||||
|
process_shim = <<-JS
|
||||||
|
// Browser-compatible process shim for googlevideo
|
||||||
|
var __Process$ = { env: {} };
|
||||||
|
|
||||||
|
JS
|
||||||
|
|
||||||
|
# Remove the esm.sh import line: import __Process$ from "/node/process.mjs";
|
||||||
|
js_content = js_content.gsub(/import\s+__Process\$\s+from\s*["'][^"']+["'];?\s*/, "")
|
||||||
|
|
||||||
|
# Prepend the shim
|
||||||
|
js_content = process_shim + js_content
|
||||||
|
|
||||||
|
File.write(googlevideo_path, js_content)
|
||||||
|
puts "#{"Patched".colorize(:green)} googlevideo bundle with process shim"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Post-process: Patch bgutils-js bundle to be self-contained
|
||||||
|
bgutils_path = "#{sabr_dir}/bgutils-js/bgutils.bundle.min.js"
|
||||||
|
if File.exists?(bgutils_path)
|
||||||
|
js_content = File.read(bgutils_path)
|
||||||
|
|
||||||
|
# Check if it's just an export redirect and fetch the actual bundle
|
||||||
|
if js_content.includes?("export * from")
|
||||||
|
# The esm.sh bundle is just a redirect, we need the actual content
|
||||||
|
puts "#{"Info".colorize(:yellow)} bgutils bundle is a redirect, fetching actual content..."
|
||||||
|
|
||||||
|
# Extract the actual path from: export * from "/bgutils-js@3.1.3/es2022/bgutils-js.bundle.mjs";
|
||||||
|
if match = js_content.match(/export \* from ["']([^"']+)["']/)
|
||||||
|
actual_path = match[1]
|
||||||
|
actual_url = "https://esm.sh#{actual_path}"
|
||||||
|
|
||||||
|
HTTP::Client.get(actual_url) do |response|
|
||||||
|
if response.status_code == 200
|
||||||
|
File.write(bgutils_path, response.body_io.gets_to_end)
|
||||||
|
puts "#{"Fetched".colorize(:green)} actual bgutils bundle"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -37,6 +37,7 @@ struct ConfigPreferences
|
|||||||
property player_style : String = "invidious"
|
property player_style : String = "invidious"
|
||||||
property quality : String = "dash"
|
property quality : String = "dash"
|
||||||
property quality_dash : String = "auto"
|
property quality_dash : String = "auto"
|
||||||
|
property quality_sabr : String = "vp9"
|
||||||
property default_home : String? = "Popular"
|
property default_home : String? = "Popular"
|
||||||
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
||||||
property automatic_instance_redirect : Bool = false
|
property automatic_instance_redirect : Bool = false
|
||||||
|
|||||||
@ -29,13 +29,16 @@ module Invidious::Routes::BeforeAll
|
|||||||
|
|
||||||
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
|
# TODO: Remove style-src's 'unsafe-inline', requires to remove all
|
||||||
# inline styles (<style> [..] </style>, style=" [..] ")
|
# inline styles (<style> [..] </style>, style=" [..] ")
|
||||||
|
# Note: 'unsafe-eval' is required for SABR player (youtubei.js URL deciphering)
|
||||||
|
# Note: 'unsafe-inline' is required for BotGuard interpreter injection
|
||||||
|
# Note: googleapis.com and youtube.com are required for BotGuard/PoToken generation
|
||||||
env.response.headers["Content-Security-Policy"] = {
|
env.response.headers["Content-Security-Policy"] = {
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
"script-src 'self'",
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||||
"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'",
|
"connect-src 'self' https://*.googleapis.com https://*.youtube.com",
|
||||||
"manifest-src 'self'",
|
"manifest-src 'self'",
|
||||||
"media-src 'self' blob:",
|
"media-src 'self' blob:",
|
||||||
"child-src 'self' blob:",
|
"child-src 'self' blob:",
|
||||||
|
|||||||
@ -66,6 +66,9 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
quality_dash = env.params.body["quality_dash"]?.try &.as(String)
|
quality_dash = env.params.body["quality_dash"]?.try &.as(String)
|
||||||
quality_dash ||= CONFIG.default_user_preferences.quality_dash
|
quality_dash ||= CONFIG.default_user_preferences.quality_dash
|
||||||
|
|
||||||
|
quality_sabr = env.params.body["quality_sabr"]?.try &.as(String)
|
||||||
|
quality_sabr ||= CONFIG.default_user_preferences.quality_sabr
|
||||||
|
|
||||||
volume = env.params.body["volume"]?.try &.as(String).to_i?
|
volume = env.params.body["volume"]?.try &.as(String).to_i?
|
||||||
volume ||= CONFIG.default_user_preferences.volume
|
volume ||= CONFIG.default_user_preferences.volume
|
||||||
|
|
||||||
@ -166,6 +169,7 @@ module Invidious::Routes::PreferencesRoute
|
|||||||
player_style: player_style,
|
player_style: player_style,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
quality_dash: quality_dash,
|
quality_dash: quality_dash,
|
||||||
|
quality_sabr: quality_sabr,
|
||||||
default_home: default_home,
|
default_home: default_home,
|
||||||
feed_menu: feed_menu,
|
feed_menu: feed_menu,
|
||||||
automatic_instance_redirect: automatic_instance_redirect,
|
automatic_instance_redirect: automatic_instance_redirect,
|
||||||
|
|||||||
245
src/invidious/routes/proxy.cr
Normal file
245
src/invidious/routes/proxy.cr
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# HTTP Proxy route for SABR streaming
|
||||||
|
# This is a "dumb" proxy that forwards requests to googlevideo.com and other YouTube services
|
||||||
|
# Used by the client-side SABR player to proxy segment requests
|
||||||
|
|
||||||
|
module Invidious::Routes::Proxy
|
||||||
|
ALLOWED_HEADERS = [
|
||||||
|
"origin",
|
||||||
|
"x-requested-with",
|
||||||
|
"content-type",
|
||||||
|
"accept",
|
||||||
|
"authorization",
|
||||||
|
"x-goog-visitor-id",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"x-origin",
|
||||||
|
"x-youtube-client-version",
|
||||||
|
"x-youtube-client-name",
|
||||||
|
"x-goog-api-format-version",
|
||||||
|
"x-goog-authuser",
|
||||||
|
"x-user-agent",
|
||||||
|
"accept-language",
|
||||||
|
"x-goog-fieldmask",
|
||||||
|
"range",
|
||||||
|
"referer",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTENT_HEADERS = [
|
||||||
|
"content-length",
|
||||||
|
"content-type",
|
||||||
|
"content-disposition",
|
||||||
|
"accept-ranges",
|
||||||
|
"content-range",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Allowed hosts for proxying (security measure)
|
||||||
|
ALLOWED_HOST_PATTERNS = [
|
||||||
|
/\.googlevideo\.com$/,
|
||||||
|
/\.youtube\.com$/,
|
||||||
|
/\.ytimg\.com$/,
|
||||||
|
/\.ggpht\.com$/,
|
||||||
|
/^redirector\.googlevideo\.com$/,
|
||||||
|
/^jnn-pa\.googleapis\.com$/,
|
||||||
|
/^play\.googleapis\.com$/,
|
||||||
|
]
|
||||||
|
|
||||||
|
def self.is_host_allowed?(host : String) : Bool
|
||||||
|
ALLOWED_HOST_PATTERNS.any? { |pattern| host.matches?(pattern) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# OPTIONS /proxy
|
||||||
|
def self.options(env)
|
||||||
|
origin = env.request.headers["Origin"]? || "*"
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
|
||||||
|
env.response.headers["Access-Control-Max-Age"] = "86400"
|
||||||
|
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
|
||||||
|
env.response.status_code = 200
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /proxy
|
||||||
|
# POST /proxy
|
||||||
|
def self.proxy(env)
|
||||||
|
origin = env.request.headers["Origin"]? || "*"
|
||||||
|
query_params = env.params.query
|
||||||
|
|
||||||
|
# Get target host from __host parameter
|
||||||
|
target_host = query_params["__host"]?
|
||||||
|
|
||||||
|
if target_host.nil? || target_host.empty?
|
||||||
|
env.response.status_code = 400
|
||||||
|
return "Request is formatted incorrectly. Please include __host in the query string."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security check: only allow proxying to known YouTube/Google domains
|
||||||
|
if !is_host_allowed?(target_host)
|
||||||
|
env.response.status_code = 403
|
||||||
|
return "Proxying to this host is not allowed."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse custom headers from __headers parameter
|
||||||
|
custom_headers = HTTP::Headers.new
|
||||||
|
if headers_param = query_params["__headers"]?
|
||||||
|
begin
|
||||||
|
puts "[DEBUG] Proxy: Parsing __headers parameter: #{headers_param}"
|
||||||
|
headers_array = JSON.parse(headers_param).as_a
|
||||||
|
headers_array.each do |header|
|
||||||
|
# header is a JSON::Any, need to extract as array
|
||||||
|
header_array = header.as_a
|
||||||
|
name = header_array[0]?.try &.as_s
|
||||||
|
value = header_array[1]?.try &.as_s
|
||||||
|
if name && value
|
||||||
|
custom_headers[name] = value
|
||||||
|
puts "[DEBUG] Proxy: Adding custom header #{name}: #{value}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue ex
|
||||||
|
# Ignore malformed headers but log the error
|
||||||
|
puts "[WARN] Proxy: Failed to parse __headers: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get target path from __path parameter, or fall back to the request path
|
||||||
|
target_path = query_params["__path"]? || env.request.path.sub("/proxy", "")
|
||||||
|
if target_path.empty?
|
||||||
|
target_path = "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build the target URL
|
||||||
|
target_url = URI.new(
|
||||||
|
scheme: "https",
|
||||||
|
host: target_host,
|
||||||
|
port: 443,
|
||||||
|
path: target_path,
|
||||||
|
query: build_query_without_proxy_params(query_params)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build request headers
|
||||||
|
request_headers = HTTP::Headers.new
|
||||||
|
|
||||||
|
# Copy custom headers
|
||||||
|
puts "[DEBUG] Proxy: custom_headers size: #{custom_headers.size}"
|
||||||
|
custom_headers.each do |key, values|
|
||||||
|
# HTTP::Headers stores values as arrays, get the first value
|
||||||
|
value = values.is_a?(Array) ? values.first : values.to_s
|
||||||
|
puts "[DEBUG] Proxy: Forwarding header #{key}: #{value}"
|
||||||
|
request_headers[key] = value
|
||||||
|
end
|
||||||
|
puts "[DEBUG] Proxy: request_headers size after copying: #{request_headers.size}"
|
||||||
|
|
||||||
|
# Copy range header from original request
|
||||||
|
if range = env.request.headers["Range"]?
|
||||||
|
request_headers["Range"] = range
|
||||||
|
end
|
||||||
|
|
||||||
|
# Copy content-type header for POST requests
|
||||||
|
if content_type = env.request.headers["Content-Type"]?
|
||||||
|
if !request_headers["Content-Type"]?
|
||||||
|
request_headers["Content-Type"] = content_type
|
||||||
|
puts "[DEBUG] Proxy: Copied Content-Type from request: #{content_type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Copy user-agent if not already set
|
||||||
|
if !request_headers["User-Agent"]? && env.request.headers["User-Agent"]?
|
||||||
|
request_headers["User-Agent"] = env.request.headers["User-Agent"]
|
||||||
|
puts "[DEBUG] Proxy: Copied User-Agent from request: #{request_headers["User-Agent"]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set origin/referer for YouTube and Google domains (only if not already set)
|
||||||
|
if target_host.includes?("youtube") || target_host.includes?("googlevideo") || target_host.includes?("googleapis")
|
||||||
|
if !request_headers["Origin"]?
|
||||||
|
request_headers["Origin"] = "https://www.youtube.com"
|
||||||
|
puts "[DEBUG] Proxy: Set Origin header"
|
||||||
|
end
|
||||||
|
if !request_headers["Referer"]?
|
||||||
|
request_headers["Referer"] = "https://www.youtube.com/"
|
||||||
|
puts "[DEBUG] Proxy: Set Referer header"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# CRITICAL: Set Content-Type for POST requests to videoplayback (SABR protocol)
|
||||||
|
# YouTube requires application/x-protobuf for SABR videoplayback requests
|
||||||
|
if env.request.method == "POST" && target_url.path.includes?("videoplayback")
|
||||||
|
request_headers["Content-Type"] = "application/x-protobuf"
|
||||||
|
puts "[DEBUG] Proxy: Set Content-Type: application/x-protobuf for videoplayback POST"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Copy authorization if present
|
||||||
|
if auth = env.request.headers["Authorization"]?
|
||||||
|
request_headers["Authorization"] = auth
|
||||||
|
end
|
||||||
|
|
||||||
|
# Final debug output showing all headers being sent
|
||||||
|
puts "[DEBUG] Proxy: Final headers to send: #{request_headers.to_a.map { |k, v| "#{k}: #{v[0..50]}..." }.join(", ")}"
|
||||||
|
|
||||||
|
# Make the proxied request
|
||||||
|
begin
|
||||||
|
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
|
||||||
|
client.connect_timeout = 10.seconds
|
||||||
|
client.read_timeout = 30.seconds
|
||||||
|
|
||||||
|
case env.request.method
|
||||||
|
when "GET"
|
||||||
|
response = client.get(target_url.request_target, headers: request_headers)
|
||||||
|
when "POST"
|
||||||
|
# Read body as binary Slice to preserve protobuf data integrity
|
||||||
|
body_io = env.request.body
|
||||||
|
body_bytes : Bytes? = nil
|
||||||
|
if body_io
|
||||||
|
body_bytes = body_io.getb_to_end
|
||||||
|
end
|
||||||
|
body_size = body_bytes.try(&.size) || 0
|
||||||
|
puts "[DEBUG] Proxy: POST body size: #{body_size} bytes (binary)"
|
||||||
|
puts "[DEBUG] Proxy: POST target: #{target_url.request_target[0..200]}"
|
||||||
|
response = client.post(target_url.request_target, headers: request_headers, body: body_bytes)
|
||||||
|
puts "[DEBUG] Proxy: Response status: #{response.status_code}, content-type: #{response.headers["content-type"]?}"
|
||||||
|
else
|
||||||
|
env.response.status_code = 405
|
||||||
|
return "Method not allowed"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set response status
|
||||||
|
env.response.status_code = response.status_code
|
||||||
|
|
||||||
|
# Copy content headers
|
||||||
|
CONTENT_HEADERS.each do |header|
|
||||||
|
if value = response.headers[header]?
|
||||||
|
env.response.headers[header] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add CORS headers
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
|
||||||
|
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||||
|
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
|
||||||
|
# Return the response body
|
||||||
|
# Wrap in error handling to suppress "Broken pipe" errors when client disconnects
|
||||||
|
begin
|
||||||
|
response.body
|
||||||
|
rescue ex : IO::Error
|
||||||
|
# Client disconnected (common during seeking/quality changes) - this is expected
|
||||||
|
puts "[DEBUG] Proxy: Client disconnected (#{ex.message})"
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue ex
|
||||||
|
env.response.status_code = 502
|
||||||
|
"Proxy error: #{ex.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.build_query_without_proxy_params(params : HTTP::Params) : String?
|
||||||
|
filtered = HTTP::Params.new
|
||||||
|
params.each do |key, value|
|
||||||
|
next if key == "__host" || key == "__headers" || key == "__path"
|
||||||
|
filtered.add(key, value)
|
||||||
|
end
|
||||||
|
filtered.empty? ? nil : filtered.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -46,6 +46,7 @@ module Invidious::Routing
|
|||||||
self.register_api_v1_routes
|
self.register_api_v1_routes
|
||||||
self.register_api_manifest_routes
|
self.register_api_manifest_routes
|
||||||
self.register_video_playback_routes
|
self.register_video_playback_routes
|
||||||
|
self.register_proxy_routes
|
||||||
self.register_companion_routes
|
self.register_companion_routes
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -224,6 +225,16 @@ module Invidious::Routing
|
|||||||
get "/vi/:id/:name", Routes::Images, :thumbnails
|
get "/vi/:id/:name", Routes::Images, :thumbnails
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def register_proxy_routes
|
||||||
|
# SABR proxy routes
|
||||||
|
get "/proxy", Routes::Proxy, :proxy
|
||||||
|
get "/proxy/*", Routes::Proxy, :proxy
|
||||||
|
post "/proxy", Routes::Proxy, :proxy
|
||||||
|
post "/proxy/*", Routes::Proxy, :proxy
|
||||||
|
options "/proxy", Routes::Proxy, :options
|
||||||
|
options "/proxy/*", Routes::Proxy, :options
|
||||||
|
end
|
||||||
|
|
||||||
def register_companion_routes
|
def register_companion_routes
|
||||||
if CONFIG.invidious_companion.present?
|
if CONFIG.invidious_companion.present?
|
||||||
get "/companion/*", Routes::Companion, :get_companion
|
get "/companion/*", Routes::Companion, :get_companion
|
||||||
|
|||||||
@ -43,6 +43,8 @@ struct Preferences
|
|||||||
property quality : String = CONFIG.default_user_preferences.quality
|
property quality : String = CONFIG.default_user_preferences.quality
|
||||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||||
property quality_dash : String = CONFIG.default_user_preferences.quality_dash
|
property quality_dash : String = CONFIG.default_user_preferences.quality_dash
|
||||||
|
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||||
|
property quality_sabr : String = CONFIG.default_user_preferences.quality_sabr
|
||||||
property default_home : String? = CONFIG.default_user_preferences.default_home
|
property default_home : String? = CONFIG.default_user_preferences.default_home
|
||||||
property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
|
property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
|
||||||
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
|
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct VideoPreferences
|
|||||||
property player_style : String
|
property player_style : String
|
||||||
property quality : String
|
property quality : String
|
||||||
property quality_dash : String
|
property quality_dash : String
|
||||||
|
property quality_sabr : String
|
||||||
property raw : Bool
|
property raw : Bool
|
||||||
property region : String?
|
property region : String?
|
||||||
property related_videos : Bool
|
property related_videos : Bool
|
||||||
@ -40,6 +41,7 @@ def process_video_params(query, preferences)
|
|||||||
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
|
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
|
||||||
quality = query["quality"]?
|
quality = query["quality"]?
|
||||||
quality_dash = query["quality_dash"]?
|
quality_dash = query["quality_dash"]?
|
||||||
|
quality_sabr = query["quality_sabr"]?
|
||||||
region = query["region"]?
|
region = query["region"]?
|
||||||
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
||||||
speed = query["speed"]?.try &.rchop("x").to_f?
|
speed = query["speed"]?.try &.rchop("x").to_f?
|
||||||
@ -63,6 +65,7 @@ def process_video_params(query, preferences)
|
|||||||
preferred_captions ||= preferences.captions
|
preferred_captions ||= preferences.captions
|
||||||
quality ||= preferences.quality
|
quality ||= preferences.quality
|
||||||
quality_dash ||= preferences.quality_dash
|
quality_dash ||= preferences.quality_dash
|
||||||
|
quality_sabr ||= preferences.quality_sabr
|
||||||
related_videos ||= preferences.related_videos.to_unsafe
|
related_videos ||= preferences.related_videos.to_unsafe
|
||||||
speed ||= preferences.speed
|
speed ||= preferences.speed
|
||||||
video_loop ||= preferences.video_loop.to_unsafe
|
video_loop ||= preferences.video_loop.to_unsafe
|
||||||
@ -84,6 +87,7 @@ def process_video_params(query, preferences)
|
|||||||
preferred_captions ||= CONFIG.default_user_preferences.captions
|
preferred_captions ||= CONFIG.default_user_preferences.captions
|
||||||
quality ||= CONFIG.default_user_preferences.quality
|
quality ||= CONFIG.default_user_preferences.quality
|
||||||
quality_dash ||= CONFIG.default_user_preferences.quality_dash
|
quality_dash ||= CONFIG.default_user_preferences.quality_dash
|
||||||
|
quality_sabr ||= CONFIG.default_user_preferences.quality_sabr
|
||||||
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
|
related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe
|
||||||
speed ||= CONFIG.default_user_preferences.speed
|
speed ||= CONFIG.default_user_preferences.speed
|
||||||
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
|
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
|
||||||
@ -145,6 +149,7 @@ def process_video_params(query, preferences)
|
|||||||
preferred_captions: preferred_captions,
|
preferred_captions: preferred_captions,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
quality_dash: quality_dash,
|
quality_dash: quality_dash,
|
||||||
|
quality_sabr: quality_sabr,
|
||||||
raw: raw,
|
raw: raw,
|
||||||
region: region,
|
region: region,
|
||||||
related_videos: related_videos,
|
related_videos: related_videos,
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
<%
|
<%
|
||||||
invidious_companion_check_id = invidious_companion_encrypt(video.id) if invidious_companion
|
invidious_companion_check_id = invidious_companion_encrypt(video.id) if invidious_companion
|
||||||
|
use_sabr = params.quality == "sabr"
|
||||||
%>
|
%>
|
||||||
|
<% if use_sabr %>
|
||||||
|
<div id="sabr-player-container" class="sabr-player-container"
|
||||||
|
data-video-id="<%= video.id %>"
|
||||||
|
data-autoplay="<%= params.autoplay %>"
|
||||||
|
data-video-loop="<%= params.video_loop %>"
|
||||||
|
data-quality-sabr="<%= params.quality_sabr %>">
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
|
<video style="outline:none;width:100%;background-color:#000" playsinline poster="<%= thumbnail %>"
|
||||||
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
|
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
|
||||||
preload="<% if params.preload %>auto<% else %>none<% end %>"
|
preload="<% if params.preload %>auto<% else %>none<% end %>"
|
||||||
@ -85,6 +94,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</video>
|
</video>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<script id="player_data" type="application/json">
|
<script id="player_data" type="application/json">
|
||||||
<%=
|
<%=
|
||||||
@ -93,8 +103,24 @@
|
|||||||
"title" => video.title,
|
"title" => video.title,
|
||||||
"description" => HTML.escape(video.short_description),
|
"description" => HTML.escape(video.short_description),
|
||||||
"thumbnail" => thumbnail,
|
"thumbnail" => thumbnail,
|
||||||
"preferred_caption_found" => !preferred_captions.empty?
|
"preferred_caption_found" => !preferred_captions.empty?,
|
||||||
|
"use_sabr" => use_sabr
|
||||||
}.to_pretty_json
|
}.to_pretty_json
|
||||||
%>
|
%>
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/player.js?v=<%= ASSET_COMMIT %>"></script>
|
|
||||||
|
<% if use_sabr %>
|
||||||
|
<!-- SABR Player dependencies -->
|
||||||
|
<link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>">
|
||||||
|
<link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>">
|
||||||
|
<script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_shaka_adapter.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
|
||||||
|
<script type="module" src="/js/sabr_loader.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<% else %>
|
||||||
|
<script src="/js/player.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@ -54,7 +54,7 @@
|
|||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
|
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
|
||||||
<select name="quality" id="quality">
|
<select name="quality" id="quality">
|
||||||
<% {"dash", "hd720", "medium", "small"}.each do |option| %>
|
<% {"dash", "sabr", "hd720", "medium", "small"}.each do |option| %>
|
||||||
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
|
<% if !(option == "dash" && CONFIG.disabled?("dash")) %>
|
||||||
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
|
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -73,6 +73,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div class="pure-control-group" id="sabr-codec-group" style="<%= preferences.quality == "sabr" ? "" : "display:none;" %>">
|
||||||
|
<label for="quality_sabr"><%= translate(locale, "preferences_quality_sabr_label") %></label>
|
||||||
|
<select name="quality_sabr" id="quality_sabr">
|
||||||
|
<% {"vp9", "av1", "h264"}.each do |option| %>
|
||||||
|
<option value="<%= option %>" <% if preferences.quality_sabr == option %> selected <% end %>><%= translate(locale, "preferences_quality_sabr_option_" + option) %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('quality').addEventListener('change', function() {
|
||||||
|
var sabrGroup = document.getElementById('sabr-codec-group');
|
||||||
|
if (this.value === 'sabr') {
|
||||||
|
sabrGroup.style.display = '';
|
||||||
|
} else {
|
||||||
|
sabrGroup.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
|
<label for="volume"><%= translate(locale, "preferences_volume_label") %></label>
|
||||||
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
|
<input name="volume" id="volume" data-onrange="update_volume_value" type="range" min="0" max="100" step="5" value="<%= preferences.volume %>">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user