Merge 13f094302392228d3db2a07ece67a6cf3db2b588 into d51a7a44ad91d2fa7d1330970a15a0d8f365f250

This commit is contained in:
Émilien (perso) 2026-01-24 13:04:42 +09:00 committed by GitHub
commit c6eb7b5024
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 5791 additions and 5 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@
/invidious
/sentry
/config/config.yml
node_modules/

259
assets/css/sabr_player.css Normal file
View 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;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
---
version: 3.2.0

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
---
version: 4.0.4

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
---
version: 4.16.4

View File

@ -0,0 +1,2 @@
---
version: 16.0.1

File diff suppressed because one or more lines are too long

340
assets/js/sabr_helpers.js Normal file
View 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
View 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
View File

@ -0,0 +1,32 @@
/**
* SABR Loader - ES module loader for SABR dependencies
*
* All dependencies are ES modules:
* - youtubei.js: Provides Innertube for YouTube API access
* - googlevideo: Provides SABR streaming adapter
* - bgutils-js: Provides BotGuard utilities
*/
// Import all ES modules
import Innertube from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { Platform } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import * as googlevideo from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
// Expose all SABR-related functions to window
window.Innertube = Innertube;
window.Platform = Platform;
window.Constants = Constants;
window.SabrStreamingAdapter = googlevideo.SabrStreamingAdapter;
window.SabrUmpProcessor = googlevideo.SabrUmpProcessor;
window.buildSabrFormat = googlevideo.buildSabrFormat;
window.FormatKeyUtils = googlevideo.FormatKeyUtils;
window.UmpUtils = googlevideo.UmpUtils;
window.SABR_CONSTANTS = googlevideo.SABR_CONSTANTS;
window.isGoogleVideoURL = googlevideo.isGoogleVideoURL;
window.BG = BG;
// Signal that all SABR libraries are loaded and ready
console.info('[SABR Loader]', 'All SABR libraries loaded');
window.dispatchEvent(new Event('sabr-libs-loaded'));

665
assets/js/sabr_player.js Normal file
View File

@ -0,0 +1,665 @@
/**
* SABR Player - Main player initialization for SABR streaming
* Ported from Kira project (https://github.com/LuanRT/kira)
*
* This module provides:
* - Innertube API initialization
* - Onesie config fetching
* - Shaka Player setup with SABR adapter
* - Video playback management
*/
'use strict';
var SABRPlayer = (function() {
// Constants
var VOLUME_KEY = 'youtube_player_volume';
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
var SAVE_POSITION_INTERVAL_MS = 5000;
var DEFAULT_ABR_CONFIG = {
enabled: true,
restrictions: { maxHeight: 480 },
switchInterval: 4,
useNetworkInformation: false
};
// State
var player = null;
var ui = null;
var sabrAdapter = null;
var videoElement = null;
var shakaContainer = null;
var currentVideoId = '';
var isLive = false;
var innertube = null;
var clientConfig = null;
var savePositionInterval = null;
var playbackWebPoToken = null;
var coldStartToken = null;
var playbackWebPoTokenContentBinding = null;
var playbackWebPoTokenCreationLock = false;
/**
* Get saved volume from localStorage
*/
function getSavedVolume() {
try {
var volume = localStorage.getItem(VOLUME_KEY);
return volume ? parseFloat(volume) : 1;
} catch (error) {
return 1;
}
}
/**
* Save volume to localStorage
*/
function saveVolume(volume) {
try {
localStorage.setItem(VOLUME_KEY, volume.toString());
} catch (error) {
// Ignore
}
}
/**
* Get all saved playback positions
*/
function getPlaybackPositions() {
try {
var positions = localStorage.getItem(PLAYBACK_POSITION_KEY);
return positions ? JSON.parse(positions) : {};
} catch (error) {
return {};
}
}
/**
* Save playback position
*/
function savePlaybackPosition(videoId, time) {
if (!videoId || time < 1) return;
try {
var positions = getPlaybackPositions();
positions[videoId] = time;
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
} catch (error) {
// Ignore
}
}
/**
* Get playback position for a video
*/
function getPlaybackPosition(videoId) {
var positions = getPlaybackPositions();
return positions[videoId] || 0;
}
/**
* Initialize Innertube API
*/
async function initInnertube() {
if (innertube) return innertube;
try {
console.info('[SABRPlayer]', 'Initializing InnerTube API');
// Check if Innertube is available from youtubei.js
if (typeof Innertube === 'undefined') {
throw new Error('youtubei.js not loaded');
}
// Set up Platform.shim.eval for URL deciphering (like Kira does)
// This is required because the browser bundle doesn't include Jinter
if (typeof Platform !== 'undefined' && Platform.shim) {
Platform.shim.eval = async function(data, env) {
var properties = [];
if (env.n) {
properties.push('n: exportedVars.nFunction("' + env.n + '")');
}
if (env.sig) {
properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
}
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
return new Function(code)();
};
console.info('[SABRPlayer]', 'Platform.shim.eval configured for URL deciphering');
} else {
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
}
// Create Innertube with proxy fetch for CSP compliance
innertube = await Innertube.create({
fetch: SABRHelpers.fetchWithProxy,
retrieve_player: true,
generate_session_locally: true
});
// Initialize BotGuard for PoToken generation
BotguardService.init().then(function() {
console.info('[SABRPlayer]', 'BotGuard client initialized');
}).catch(function(err) {
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
});
// Preload the redirector URL
try {
var redirectorResponse = await SABRHelpers.fetchWithProxy(
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1E5),
{ method: 'GET' }
);
var redirectorResponseUrl = await redirectorResponse.text();
if (redirectorResponseUrl.startsWith('https://')) {
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
}
} catch (e) {
console.warn('[SABRPlayer]', 'Failed to preload redirector URL', e);
}
return innertube;
} catch (error) {
console.error('[SABRPlayer]', 'Failed to initialize Innertube', error);
return null;
}
}
/**
* Fetch Onesie client config
*/
async function fetchOnesieConfig() {
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) {
return clientConfig;
}
// Try loading from cache
var cachedConfig = SABRHelpers.loadCachedClientConfig();
if (cachedConfig) {
clientConfig = cachedConfig;
return clientConfig;
}
try {
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
{
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version'
}
}
);
var tvConfigText = await tvConfigResponse.text();
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
// Helper to decode base64 to Uint8Array
function base64ToU8(base64) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
clientConfig = {
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
encryptedClientKey: base64ToU8(onesieHotConfig.encryptedClientKey),
onesieUstreamerConfig: base64ToU8(onesieHotConfig.onesieUstreamerConfig),
baseUrl: onesieHotConfig.baseUrl,
timestamp: Date.now()
};
SABRHelpers.saveCachedClientConfig(clientConfig);
return clientConfig;
} catch (error) {
console.error('[SABRPlayer]', 'Failed to fetch Onesie client config', error);
return null;
}
}
/**
* Mint content-bound PoToken
*/
async function mintContentWebPO() {
console.log('[SABRPlayer] mintContentWebPO called, binding:', playbackWebPoTokenContentBinding);
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) {
console.log('[SABRPlayer] mintContentWebPO skipped:', { binding: !!playbackWebPoTokenContentBinding, locked: playbackWebPoTokenCreationLock });
return;
}
playbackWebPoTokenCreationLock = true;
try {
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
if (!BotguardService.isInitialized()) {
console.log('[SABRPlayer] BotGuard not initialized, reinitializing...');
await BotguardService.reinit();
}
if (BotguardService.isInitialized()) {
playbackWebPoToken = await BotguardService.mintWebPoToken(
decodeURIComponent(playbackWebPoTokenContentBinding)
);
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
} else {
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
}
} catch (err) {
console.error('[SABRPlayer]', 'Error minting WebPO token', err);
} finally {
playbackWebPoTokenCreationLock = false;
}
}
/**
* Initialize Shaka Player
*/
async function initializeShakaPlayer(containerElement) {
if (!shaka.Player.isBrowserSupported()) {
throw new Error('Shaka Player is not supported in this browser');
}
// Install polyfills
shaka.polyfill.installAll();
shakaContainer = document.createElement('div');
shakaContainer.className = 'sabr-player-container';
shakaContainer.style.width = '100%';
shakaContainer.style.height = '100%';
videoElement = document.createElement('video');
videoElement.autoplay = true;
videoElement.style.width = '100%';
videoElement.style.height = '100%';
videoElement.style.backgroundColor = '#000';
shakaContainer.appendChild(videoElement);
containerElement.appendChild(shakaContainer);
player = new shaka.Player();
player.configure({
preferredAudioLanguage: 'en-US',
abr: DEFAULT_ABR_CONFIG,
streaming: {
bufferingGoal: 120,
rebufferingGoal: 0.01,
bufferBehind: 300,
retryParameters: {
maxAttempts: 8,
fuzzFactor: 0.5,
timeout: 30 * 1000
}
}
});
videoElement.volume = getSavedVolume();
videoElement.addEventListener('volumechange', function() {
saveVolume(videoElement.volume);
});
videoElement.addEventListener('playing', function() {
player.configure('abr.restrictions.maxHeight', Infinity);
});
videoElement.addEventListener('pause', function() {
if (currentVideoId) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
});
await player.attach(videoElement);
// Initialize UI if available
if (shaka.ui && shaka.ui.Overlay) {
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
ui.configure({
addBigPlayButton: true,
overflowMenuButtons: [
'captions',
'quality',
'language',
'playback_rate',
'loop',
'picture_in_picture'
]
});
}
return player;
}
/**
* Initialize SABR adapter
*/
async function initializeSabrAdapter() {
if (!player || !innertube) return;
// Check if SabrStreamingAdapter is available from googlevideo
if (typeof SabrStreamingAdapter === 'undefined') {
console.error('[SABRPlayer]', 'googlevideo library not loaded');
return;
}
// Create the player adapter
var playerAdapter = new ShakaPlayerAdapter();
// Use YouTube.js ANDROID client constants
var androidClient = Constants.CLIENTS.ANDROID;
sabrAdapter = new SabrStreamingAdapter({
playerAdapter: playerAdapter,
clientInfo: {
osName: 'Android',
osVersion: androidClient.OS_VERSION || '14',
clientName: 3, // ANDROID - Used exclusively for SABR streaming requests
clientVersion: androidClient.VERSION
}
});
sabrAdapter.onMintPoToken(async function() {
console.log('[SABRPlayer] onMintPoToken callback invoked');
if (!playbackWebPoToken) {
if (isLive) {
await mintContentWebPO();
} else {
mintContentWebPO(); // Don't block for VOD
}
}
var token = playbackWebPoToken || coldStartToken || '';
console.log('[SABRPlayer] Returning token:', token ? 'token present (' + token.substring(0, 20) + '...)' : 'empty');
return token;
});
sabrAdapter.attach(player);
return sabrAdapter;
}
/**
* Setup request filters for proxying
*/
async function setupRequestFilters() {
var networkingEngine = player?.getNetworkingEngine();
if (!networkingEngine) return;
var config = SABRHelpers.getProxyConfig();
networkingEngine.registerRequestFilter(async function(type, request) {
var url = new URL(request.uris[0]);
// Proxy googlevideo requests
if (url.host.endsWith('.googlevideo.com') || url.host.includes('youtube')) {
var newUrl = new URL(url.toString());
newUrl.searchParams.set('__host', url.host);
newUrl.host = config.PROXY_HOST;
newUrl.port = config.PROXY_PORT;
newUrl.protocol = config.PROXY_PROTOCOL + ':';
newUrl.pathname = '/proxy' + url.pathname;
// Add required headers for googlevideo requests to avoid 403
var proxyHeaders = [
['user-agent', navigator.userAgent],
['origin', 'https://www.youtube.com'],
['referer', 'https://www.youtube.com/']
];
// CRITICAL: Add content-type for POST requests (SABR videoplayback)
// This is REQUIRED for YouTube to accept the protobuf request body
if (request.body && request.body.byteLength > 0) {
proxyHeaders.push(['content-type', 'application/x-protobuf']);
console.log('[SABRPlayer] Adding content-type: application/x-protobuf for POST request');
}
// Add visitor ID if available from innertube session
if (innertube?.session?.context?.client?.visitorData) {
proxyHeaders.push(['x-goog-visitor-id', innertube.session.context.client.visitorData]);
}
// Add client name and version - Force ANDROID (3) for SABR requests
proxyHeaders.push(['x-youtube-client-name', '3']); // ANDROID
if (innertube?.session?.context?.client?.clientVersion) {
proxyHeaders.push(['x-youtube-client-version', innertube.session.context.client.clientVersion]);
}
newUrl.searchParams.set('__headers', JSON.stringify(proxyHeaders));
request.uris[0] = newUrl.toString();
}
});
}
/**
* Load a video
*/
async function loadVideo(videoId, containerElement, options) {
options = options || {};
currentVideoId = videoId;
playbackWebPoToken = null;
playbackWebPoTokenContentBinding = videoId;
try {
// Initialize components if needed
if (!innertube) {
innertube = await initInnertube();
if (!innertube) {
throw new Error('Failed to initialize Innertube');
}
}
if (!clientConfig) {
clientConfig = await fetchOnesieConfig();
}
if (!player) {
await initializeShakaPlayer(containerElement);
} else {
// Reset player configuration
player.configure('abr', DEFAULT_ABR_CONFIG);
}
if (!sabrAdapter) {
await initializeSabrAdapter();
}
await setupRequestFilters();
// Fetch video info using Innertube
var videoInfo = await innertube.getInfo(videoId);
if (!videoInfo || videoInfo.playability_status?.status !== 'OK') {
var reason = videoInfo?.playability_status?.reason || 'Unknown error';
throw new Error('Video unavailable: ' + reason);
}
isLive = !!videoInfo.basic_info?.is_live;
// Get streaming URL
var streamingData = videoInfo.streaming_data;
if (!streamingData) {
throw new Error('No streaming data available');
}
var manifestUri;
if (isLive) {
// For live streams, use HLS or DASH manifest
manifestUri = streamingData.hls_manifest_url || streamingData.dash_manifest_url;
} else {
// For VOD, generate DASH manifest from adaptive formats
if (sabrAdapter && streamingData.server_abr_streaming_url) {
// SABR mode - need to decipher the server_abr_streaming_url first
var sabrUrl = streamingData.server_abr_streaming_url;
// Decipher the URL if the player has a decipher function
if (innertube?.session?.player?.decipher) {
try {
sabrUrl = await innertube.session.player.decipher(sabrUrl);
console.log('[SABRPlayer] Deciphered streaming URL:', sabrUrl?.substring(0, 100) + '...');
} catch (decipherErr) {
console.error('[SABRPlayer] Failed to decipher URL:', decipherErr);
// Try to use the raw URL anyway
console.warn('[SABRPlayer] Trying raw URL instead');
}
} else {
console.warn('[SABRPlayer] Player decipher not available, using raw URL');
}
console.log('[SABRPlayer] Setting streaming URL:', sabrUrl);
sabrAdapter.setStreamingURL(sabrUrl);
// Build SABR formats
console.log('[SABRPlayer] Checking SABR format requirements:', {
buildSabrFormat: typeof buildSabrFormat,
adaptive_formats: !!streamingData.adaptive_formats,
formats_length: streamingData.adaptive_formats?.length
});
if (typeof buildSabrFormat !== 'undefined' && streamingData.adaptive_formats) {
console.log('[SABRPlayer] Building SABR formats from', streamingData.adaptive_formats.length, 'adaptive formats');
var sabrFormats = streamingData.adaptive_formats.map(function(fmt) {
return buildSabrFormat(fmt);
}).filter(function(format) {
return !format.xtags;
});
console.log('[SABRPlayer] Setting', sabrFormats.length, 'SABR formats on adapter');
sabrAdapter.setServerAbrFormats(sabrFormats);
console.log('[SABRPlayer] SABR formats set successfully');
} else {
console.warn('[SABRPlayer] buildSabrFormat not available or no adaptive formats', {
buildSabrFormat: typeof buildSabrFormat,
has_adaptive_formats: !!streamingData.adaptive_formats
});
}
// Set ustreamer config
var ustreamerConfig = videoInfo.player_config?.media_common_config?.media_ustreamer_request_config?.video_playback_ustreamer_config;
if (ustreamerConfig) {
sabrAdapter.setUstreamerConfig(ustreamerConfig);
}
}
// Generate DASH manifest
var dashManifest = await videoInfo.toDash({
manifest_options: {
is_sabr: !!sabrAdapter,
captions_format: 'vtt',
include_thumbnails: false
}
});
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
}
if (!manifestUri) {
throw new Error('Could not find a valid manifest URI');
}
// Determine start time
var startTime = options.startTime;
if (startTime === undefined && options.savePlayerPos !== false) {
startTime = getPlaybackPosition(videoId);
}
// Load the manifest
await player.load(manifestUri, isLive ? undefined : startTime);
// Start playback
videoElement.play().catch(function(err) {
if (err.name === 'NotAllowedError') {
console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
}
});
// Start saving position periodically
if (savePositionInterval) {
clearInterval(savePositionInterval);
}
savePositionInterval = setInterval(function() {
if (videoElement && currentVideoId && !videoElement.paused) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
}, SAVE_POSITION_INTERVAL_MS);
return {
player: player,
videoElement: videoElement,
videoInfo: videoInfo
};
} catch (error) {
console.error('[SABRPlayer]', 'Error loading video:', error);
throw error;
}
}
/**
* Dispose of the player
*/
async function dispose() {
if (savePositionInterval) {
clearInterval(savePositionInterval);
savePositionInterval = null;
}
if (videoElement && currentVideoId) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
if (sabrAdapter) {
sabrAdapter.dispose();
sabrAdapter = null;
}
if (player) {
await player.destroy();
player = null;
}
if (ui) {
ui.destroy();
ui = null;
}
if (shakaContainer && shakaContainer.parentNode) {
shakaContainer.parentNode.removeChild(shakaContainer);
}
videoElement = null;
shakaContainer = null;
currentVideoId = '';
}
/**
* Get current player instance
*/
function getPlayer() {
return player;
}
/**
* Get current video element
*/
function getVideoElement() {
return videoElement;
}
return {
loadVideo: loadVideo,
dispose: dispose,
getPlayer: getPlayer,
getVideoElement: getVideoElement,
initInnertube: initInnertube,
fetchOnesieConfig: fetchOnesieConfig
};
})();
// Export for use
window.SABRPlayer = SABRPlayer;

270
assets/js/sabr_potoken.js Normal file
View 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;

View 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;

View File

@ -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
View 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
View 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
View 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

View 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);
});

View File

@ -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`

View 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

View File

@ -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

View File

@ -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:",

View File

@ -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,

View 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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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>
<% 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 %>

View File

@ -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 %>">