mirror of
https://github.com/iv-org/invidious.git
synced 2026-01-28 15:58:30 -06:00
chore: very initial poc
This commit is contained in:
parent
5f84a5b353
commit
4775a14e22
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@
|
||||
/invidious
|
||||
/sentry
|
||||
/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'));
|
||||
663
assets/js/sabr_player.js
Normal file
663
assets/js/sabr_player.js
Normal file
@ -0,0 +1,663 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
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_quality_label": "Preferred video quality: ",
|
||||
"preferences_quality_option_dash": "DASH (adaptive quality)",
|
||||
"preferences_quality_option_sabr": "SABR (experimental)",
|
||||
"preferences_quality_option_hd720": "HD720",
|
||||
"preferences_quality_option_medium": "Medium",
|
||||
"preferences_quality_option_small": "Small",
|
||||
@ -96,6 +97,10 @@
|
||||
"preferences_quality_dash_option_360p": "360p",
|
||||
"preferences_quality_dash_option_240p": "240p",
|
||||
"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_comments_label": "Default comments: ",
|
||||
"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
|
||||
`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 quality : String = "dash"
|
||||
property quality_dash : String = "auto"
|
||||
property quality_sabr : String = "vp9"
|
||||
property default_home : String? = "Popular"
|
||||
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
||||
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
|
||||
# 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"] = {
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"connect-src 'self' https://*.googleapis.com https://*.youtube.com",
|
||||
"manifest-src 'self'",
|
||||
"media-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 ||= 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 ||= CONFIG.default_user_preferences.volume
|
||||
|
||||
@ -166,6 +169,7 @@ module Invidious::Routes::PreferencesRoute
|
||||
player_style: player_style,
|
||||
quality: quality,
|
||||
quality_dash: quality_dash,
|
||||
quality_sabr: quality_sabr,
|
||||
default_home: default_home,
|
||||
feed_menu: feed_menu,
|
||||
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_manifest_routes
|
||||
self.register_video_playback_routes
|
||||
self.register_proxy_routes
|
||||
self.register_companion_routes
|
||||
end
|
||||
|
||||
@ -224,6 +225,16 @@ module Invidious::Routing
|
||||
get "/vi/:id/:name", Routes::Images, :thumbnails
|
||||
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
|
||||
if CONFIG.invidious_companion.present?
|
||||
get "/companion/*", Routes::Companion, :get_companion
|
||||
|
||||
@ -43,6 +43,8 @@ struct Preferences
|
||||
property quality : String = CONFIG.default_user_preferences.quality
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
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 feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
|
||||
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
|
||||
|
||||
@ -14,6 +14,7 @@ struct VideoPreferences
|
||||
property player_style : String
|
||||
property quality : String
|
||||
property quality_dash : String
|
||||
property quality_sabr : String
|
||||
property raw : Bool
|
||||
property region : String?
|
||||
property related_videos : Bool
|
||||
@ -40,6 +41,7 @@ def process_video_params(query, preferences)
|
||||
preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase)
|
||||
quality = query["quality"]?
|
||||
quality_dash = query["quality_dash"]?
|
||||
quality_sabr = query["quality_sabr"]?
|
||||
region = query["region"]?
|
||||
related_videos = query["related_videos"]?.try { |q| (q == "true" || q == "1").to_unsafe }
|
||||
speed = query["speed"]?.try &.rchop("x").to_f?
|
||||
@ -63,6 +65,7 @@ def process_video_params(query, preferences)
|
||||
preferred_captions ||= preferences.captions
|
||||
quality ||= preferences.quality
|
||||
quality_dash ||= preferences.quality_dash
|
||||
quality_sabr ||= preferences.quality_sabr
|
||||
related_videos ||= preferences.related_videos.to_unsafe
|
||||
speed ||= preferences.speed
|
||||
video_loop ||= preferences.video_loop.to_unsafe
|
||||
@ -84,6 +87,7 @@ def process_video_params(query, preferences)
|
||||
preferred_captions ||= CONFIG.default_user_preferences.captions
|
||||
quality ||= CONFIG.default_user_preferences.quality
|
||||
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
|
||||
speed ||= CONFIG.default_user_preferences.speed
|
||||
video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe
|
||||
@ -145,6 +149,7 @@ def process_video_params(query, preferences)
|
||||
preferred_captions: preferred_captions,
|
||||
quality: quality,
|
||||
quality_dash: quality_dash,
|
||||
quality_sabr: quality_sabr,
|
||||
raw: raw,
|
||||
region: region,
|
||||
related_videos: related_videos,
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
<%
|
||||
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 %>"
|
||||
id="player" class="on-video_player video-js player-style-<%= params.player_style %>"
|
||||
preload="<% if params.preload %>auto<% else %>none<% end %>"
|
||||
@ -85,6 +94,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
</video>
|
||||
<% end %>
|
||||
|
||||
<script id="player_data" type="application/json">
|
||||
<%=
|
||||
@ -93,8 +103,24 @@
|
||||
"title" => video.title,
|
||||
"description" => HTML.escape(video.short_description),
|
||||
"thumbnail" => thumbnail,
|
||||
"preferred_caption_found" => !preferred_captions.empty?
|
||||
"preferred_caption_found" => !preferred_captions.empty?,
|
||||
"use_sabr" => use_sabr
|
||||
}.to_pretty_json
|
||||
%>
|
||||
</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">
|
||||
<label for="quality"><%= translate(locale, "preferences_quality_label") %></label>
|
||||
<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")) %>
|
||||
<option value="<%= option %>" <% if preferences.quality == option %> selected <% end %>><%= translate(locale, "preferences_quality_option_" + option) %></option>
|
||||
<% end %>
|
||||
@ -73,6 +73,26 @@
|
||||
</div>
|
||||
<% 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">
|
||||
<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 %>">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user