Merge 105a69d9eaf0a8d2fb7a6f4a99af43985119c4e8 into 5510657e806d06ff8de3692d5804211a00570cef

This commit is contained in:
Émilien (perso) 2026-07-24 06:04:11 +09:00 committed by GitHub
commit c5ade7c532
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 7302 additions and 61 deletions

1
.gitignore vendored
View File

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

252
assets/css/sabr_player.css Normal file
View File

@ -0,0 +1,252 @@
/**
* 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%);
}
/* Scope to the big centered play button only. The control-bar play button
shares the .shaka-play-button class, so an unscoped rule here oversizes it
(1.5em + 1.5em padding = 4.5em 54px vs the 48px control-bar siblings). */
.shaka-play-button-container .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-container .shaka-play-button:hover {
background-color: rgba(35, 35, 35, 0.9) !important;
}
/* Shaka 5 spinner uses full-container flex centering; do not override container position. */
/* 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-container .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: 5.1.10

View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,159 @@
// Port of FreeTube's EbmlParser.js
// Based on shaka-player's dash/ebml_parser.js
// Reads shaka from window.shaka (loaded via <script>).
'use strict';
(function () {
var shaka = window.shaka;
var ShakaError = shaka.util.Error;
var BufferUtils = shaka.util.BufferUtils;
var DYNAMIC_SIZES = [
[0xff],
[0x7f, 0xff],
[0x3f, 0xff, 0xff],
[0x1f, 0xff, 0xff, 0xff],
[0x0f, 0xff, 0xff, 0xff, 0xff],
[0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
[0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
[0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]
];
function getVintValue(vint) {
if ((vint.length === 8) && (vint[1] & 0xe0)) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.JS_INTEGER_OVERFLOW
);
}
var value = 0;
for (var i = 0; i < vint.length; i++) {
var item = vint[i];
if (i === 0) {
var mask = 0x1 << (8 - vint.length);
value = item & (mask - 1);
} else {
value = (256 * value) + item;
}
}
return value;
}
function isDynamicSizeValue(vint) {
for (var i = 0; i < DYNAMIC_SIZES.length; i++) {
if (BufferUtils.equal(vint, new Uint8Array(DYNAMIC_SIZES[i]))) {
return true;
}
}
return false;
}
function EbmlElement(id, dataView) {
this.id = id;
this._dataView = dataView;
}
EbmlElement.prototype.getOffset = function () {
return this._dataView.byteOffset;
};
EbmlElement.prototype.createParser = function () {
return new EbmlParser(this._dataView);
};
EbmlElement.prototype.getUint = function () {
if (this._dataView.byteLength > 8) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.EBML_OVERFLOW
);
}
if ((this._dataView.byteLength === 8) && (this._dataView.getUint8(0) & 0xe0)) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.JS_INTEGER_OVERFLOW
);
}
var value = 0;
for (var i = 0; i < this._dataView.byteLength; i++) {
value = (256 * value) + this._dataView.getUint8(i);
}
return value;
};
EbmlElement.prototype.getFloat = function () {
if (this._dataView.byteLength === 4) {
return this._dataView.getFloat32(0);
} else if (this._dataView.byteLength === 8) {
return this._dataView.getFloat64(0);
} else {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.EBML_BAD_FLOATING_POINT_SIZE
);
}
};
function EbmlParser(data) {
this._dataView = BufferUtils.toDataView(data);
this._reader = new shaka.util.DataViewReader(this._dataView, shaka.util.DataViewReader.Endianness.BIG_ENDIAN);
}
EbmlParser.prototype.hasMoreData = function () {
return this._reader.hasMoreData();
};
EbmlParser.prototype.parseElement = function () {
var id = this._parseId();
var vint = this._parseVint();
var size;
if (isDynamicSizeValue(vint)) {
size = this._dataView.byteLength - this._reader.getPosition();
} else {
size = getVintValue(vint);
}
var elementSize =
this._reader.getPosition() + size <= this._dataView.byteLength
? size
: this._dataView.byteLength - this._reader.getPosition();
var dataView = BufferUtils.toDataView(this._dataView, this._reader.getPosition(), elementSize);
this._reader.skip(elementSize);
return new EbmlElement(id, dataView);
};
EbmlParser.prototype._parseId = function () {
var vint = this._parseVint();
if (vint.length > 7) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.EBML_OVERFLOW
);
}
var id = 0;
for (var i = 0; i < vint.length; i++) {
id = (256 * id) + vint[i];
}
return id;
};
EbmlParser.prototype._parseVint = function () {
var position = this._reader.getPosition();
var firstByte = this._reader.readUint8();
if (firstByte === 0) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.MEDIA,
ShakaError.Code.EBML_OVERFLOW
);
}
var index = Math.floor(Math.log2(firstByte));
var numBytes = 8 - index;
this._reader.skip(numBytes - 1);
return BufferUtils.toUint8(this._dataView, position, numBytes);
};
window.EbmlParser = EbmlParser;
})();

389
assets/js/sabr_helpers.js Normal file
View File

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

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 protos, UMP reader/writer and utils
* - bgutils-js: Provides BotGuard utilities
*
* Exposes everything needed by sabr_scheme_plugin.js / sabr_manifest_parser.js
* onto window.googlevideo so the plain-script plugin can read them at call time.
*/
import Innertube, { Platform, Constants, YT } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
// youtubei.js
window.Innertube = Innertube;
window.Platform = Platform;
window.Constants = Constants;
// YT.VideoInfo: build a VideoInfo from a raw player response fetched via the
// onesie path (sabr_onesie.js) instead of innertube.getInfo().
window.YT = YT;
// googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
window.googlevideo = googlevideo;
// BotGuard
window.BG = BG;
console.info('[SABR Loader]', 'All SABR libraries loaded');
window.dispatchEvent(new Event('sabr-libs-loaded'));

View File

@ -0,0 +1,579 @@
// Port of FreeTube's SabrManifestParser.js
// Reads shaka from window.shaka, index parsers from window globals.
// Registers a manifest parser for the 'application/sabr+json' mime type.
// Exposes window.MANIFEST_TYPE_SABR.
'use strict';
(function () {
var shaka = window.shaka;
var NetworkingEngine = shaka.net.NetworkingEngine;
var MANIFEST_TYPE_SABR = 'application/sabr+json';
var CODECS_REGEX = /codecs="?([^"]+)"?/;
var VIDEO_CODEC_PRIORITIES = ['av01', 'vp09', 'vp9', 'avc1'];
function buildFormatId(format) {
return format.itag + '-' + (format.lastModified ?? '0') + '-' + (format.xtags ?? '');
}
function createVodMediaSegmentIndex(url, response, format, stream, duration) {
var mediaQuality = {
contentType: stream.type,
bandwidth: stream.bandwidth,
mimeType: stream.mimeType,
codecs: stream.codecs,
language: stream.language,
label: stream.label,
audioSamplingRate: stream.audioSamplingRate,
channelsCount: stream.channelsCount,
width: stream.width ?? null,
height: stream.height ?? null,
frameRate: stream.frameRate ?? null,
roles: stream.roles,
pixelAspectRatio: stream.pixelAspectRatio ?? null
};
var buffer = ArrayBuffer.isView(response.data) ? response.data.buffer : response.data;
var initData = buffer.slice(format.initRange.start, format.initRange.end + 1);
var indexData = buffer.slice(format.indexRange.start, format.indexRange.end + 1);
var initUrls = [url + '&init'];
var initSegmentReference = new shaka.media.InitSegmentReference(
function () { return initUrls; },
format.initRange.start,
format.initRange.end,
mediaQuality,
null,
initData,
null
);
initSegmentReference.mimeType = stream.mimeType;
initSegmentReference.codecs = stream.codecs;
var references;
if (stream.mimeType.endsWith('/webm')) {
references = window.parseWebmSegmentIndex(indexData, initData, url, initSegmentReference, 0, 0, duration);
} else {
references = window.parseMp4SegmentIndex(indexData, format.indexRange.start, url, initSegmentReference, 0, 0, duration);
}
for (var i = 0; i < references.length; i++) {
references[i].mimeType = stream.mimeType;
references[i].codecs = stream.codecs;
}
return new shaka.media.SegmentIndex(references);
}
async function createMediaSegmentIndex(format, stream, presentationTimeline, networkingEngine, fakeVideoFormatId) {
var url = 'sabr:' + stream.type + '?formatId=' + encodeURIComponent(stream.originalId);
if (fakeVideoFormatId) {
url += '&videoFormatId=' + encodeURIComponent(fakeVideoFormatId);
}
if (format.isDrc) {
url += '&drc';
} else if (format.isVoiceBoost) {
url += '&vb';
}
if (stream.type === 'video') {
var resolution = format.height || 360;
url += '&resolution=' + resolution;
}
var request = {
method: 'GET',
uris: [url + '&init'],
contentType: stream.type,
body: null,
headers: {},
allowCrossSiteCredentials: false,
retryParameters: NetworkingEngine.defaultRetryParameters(),
licenseRequestType: null,
sessionId: null,
drmInfo: null,
initData: null,
initDataType: null,
streamDataCallback: null
};
var response = await networkingEngine.request(
NetworkingEngine.RequestType.SEGMENT,
request,
{
stream: stream,
type: NetworkingEngine.AdvancedRequestType.INIT_SEGMENT
}
).promise;
return createVodMediaSegmentIndex(url, response, format, stream, presentationTimeline.getDuration());
}
function createAudioStream(format, id, hasDrcAudio, hasVoiceBoostAudio, presentationTimeline, networkingEngine, fakeVideoFormatId) {
var roles = [];
if (format.isDrc) {
roles.push('drc');
} else if (format.isVoiceBoost) {
roles.push('voice-boost');
} else if (format.isDubbed) {
roles.push('dubbed');
} else if (format.isAutoDubbed) {
roles.push('dubbed-auto');
} else if (format.isDescriptive) {
roles.push('descriptive');
} else if (format.isSecondary) {
roles.push('secondary');
} else if (format.isOriginal) {
roles.push('main');
}
var label = null;
if (format.label) {
if (format.isDrc) {
label = format.label + ' (Stable Volume)';
} else if (format.isVoiceBoost) {
label = format.label + ' (Voice Boost)';
} else {
label = format.label;
}
} else if (hasDrcAudio || hasVoiceBoostAudio) {
if (format.isDrc) {
label = 'Stable Volume';
} else if (format.isVoiceBoost) {
label = 'Voice Boost';
} else {
label = 'Original';
}
}
var stream = {
type: 'audio',
id: id,
originalId: buildFormatId(format),
mimeType: format.mimeType.split(';', 1)[0],
codecs: format.mimeType.match(CODECS_REGEX)[1],
fullMimeTypes: new Set([format.mimeType]),
bandwidth: format.bitrate,
audioSamplingRate: format.audioSampleRate ?? null,
channelsCount: format.audioChannels ?? null,
label: label,
language: format.language ?? 'und',
originalLanguage: format.language ?? null,
spatialAudio: format.spatialAudio,
roles: roles,
primary: roles.indexOf('main') !== -1,
segmentIndex: null,
createSegmentIndex: async function () {
if (stream.segmentIndex) return;
stream.segmentIndex = await createMediaSegmentIndex(format, stream, presentationTimeline, networkingEngine, fakeVideoFormatId);
},
closeSegmentIndex: function () {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
},
accessibilityPurpose: null,
closedCaptions: null,
drmInfos: [],
emsgSchemeIdUris: null,
encrypted: false,
external: false,
fastSwitching: false,
forced: false,
groupId: null,
isAudioMuxedInVideo: false,
keyIds: new Set(),
trickModeVideo: null
};
return stream;
}
function createVideoStream(format, id, presentationTimeline, networkingEngine) {
var colorGamut = format.colorPrimaries === 'BT2020' ? 'rec2020' : 'srgb';
var hdr = 'SDR';
if (format.colorTransferCharacteristics === 'SMPTEST2084') {
hdr = 'PQ';
} else if (format.colorTransferCharacteristics === 'ARIB_STD_B67') {
hdr = 'HLG';
}
var stream = {
type: 'video',
id: id,
originalId: buildFormatId(format),
mimeType: format.mimeType.split(';', 1)[0],
codecs: format.mimeType.match(CODECS_REGEX)[1],
fullMimeTypes: new Set([format.mimeType]),
bandwidth: format.bitrate,
width: format.width,
height: format.height,
frameRate: format.frameRate,
colorGamut: colorGamut,
hdr: hdr,
roles: [],
segmentIndex: null,
createSegmentIndex: async function () {
if (stream.segmentIndex) return;
stream.segmentIndex = await createMediaSegmentIndex(format, stream, presentationTimeline, networkingEngine);
},
closeSegmentIndex: function () {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
},
accessibilityPurpose: null,
audioSamplingRate: null,
channelsCount: null,
closedCaptions: null,
drmInfos: [],
emsgSchemeIdUris: null,
encrypted: false,
external: false,
fastSwitching: false,
forced: false,
groupId: null,
isAudioMuxedInVideo: false,
keyIds: new Set(),
label: null,
language: 'und',
originalLanguage: null,
primary: false,
spatialAudio: false,
trickModeVideo: null
};
return stream;
}
function createTextStreams(captions, presentationTimeline, currentId) {
var result = [];
for (var i = 0; i < captions.length; i++) {
var caption = captions[i];
var stream = {
type: 'text',
id: currentId++,
originalId: caption.id,
mimeType: caption.mimeType,
fullMimeTypes: new Set([caption.mimeType]),
label: caption.label,
language: caption.language,
originalLanguage: caption.language,
kind: 'captions',
segmentIndex: null,
createSegmentIndex: function () {
stream.segmentIndex = shaka.media.SegmentIndex.forSingleSegment(
0,
presentationTimeline.getDuration(),
[caption.url]
);
stream.segmentIndex.get(0).mimeType = caption.mimeType;
return Promise.resolve();
},
closeSegmentIndex: function () {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
},
accessibilityPurpose: null,
audioSamplingRate: null,
channelsCount: null,
closedCaptions: null,
codecs: '',
drmInfos: [],
emsgSchemeIdUris: null,
encrypted: false,
external: false,
fastSwitching: false,
forced: false,
groupId: null,
isAudioMuxedInVideo: false,
keyIds: new Set(),
primary: false,
roles: [],
spatialAudio: false,
trickModeVideo: null
};
result.push(stream);
}
return result;
}
function createImageStreams(storyboards, presentationTimeline, currentId) {
var result = [];
for (var i = 0; i < storyboards.length; i++) {
var storyboard = storyboards[i];
var tilesLayout = storyboard.columns + 'x' + storyboard.rows;
var stream = {
type: 'image',
id: currentId++,
mimeType: storyboard.mimeType,
fullMimeTypes: new Set([storyboard.mimeType]),
tilesLayout: tilesLayout,
width: storyboard.thumbnailWidth * storyboard.columns,
height: storyboard.thumbnailHeight * storyboard.rows,
segmentIndex: null,
createSegmentIndex: function () {
var duration = presentationTimeline.getDuration();
var interval = storyboard.interval > 0 ? storyboard.interval : duration / storyboard.thumbnailCount;
var segmentDuration = interval * storyboard.columns * storyboard.rows;
var references = [];
for (var j = 0; j < storyboard.storyboardCount; j++) {
var startTime = j * segmentDuration;
var endTime = Math.min(startTime + segmentDuration, duration);
var urls = [storyboard.templateUrl.replace('$M', String(j))];
var segmentReference = new shaka.media.SegmentReference(
startTime, endTime,
function () { return urls; },
0, null, null, 0, 0, Infinity, undefined, tilesLayout, interval
);
segmentReference.mimeType = storyboard.mimeType;
references.push(segmentReference);
}
stream.segmentIndex = new shaka.media.SegmentIndex(references);
return Promise.resolve();
},
closeSegmentIndex: function () {
if (stream.segmentIndex) {
stream.segmentIndex.release();
stream.segmentIndex = null;
}
},
accessibilityPurpose: null,
audioSamplingRate: null,
channelsCount: null,
closedCaptions: null,
codecs: '',
drmInfos: [],
emsgSchemeIdUris: null,
encrypted: false,
external: false,
fastSwitching: false,
forced: false,
groupId: null,
isAudioMuxedInVideo: false,
keyIds: new Set(),
label: null,
language: 'und',
originalId: null,
originalLanguage: null,
primary: false,
roles: [],
spatialAudio: false,
trickModeVideo: null
};
result.push(stream);
}
return result;
}
function createChapterStreams(chapters, currentId) {
if (chapters.length === 0) {
return [];
}
var references = [];
for (var i = 0; i < chapters.length; i++) {
var chapter = chapters[i];
var reference = new shaka.media.SegmentReference(
chapter.startSeconds, chapter.endSeconds,
function () { return []; },
0, null, null, 0, 0, Infinity
);
reference.setMetadata({
title: chapter.title,
images: chapter.thumbnail
? [{ url: chapter.thumbnail.url, width: chapter.thumbnail.width, height: chapter.thumbnail.height }]
: []
});
references.push(reference);
}
var stream = {
id: currentId,
originalId: null,
groupId: null,
createSegmentIndex: function () { return Promise.resolve(); },
segmentIndex: new shaka.media.SegmentIndex(references),
mimeType: 'text/plain',
codecs: '',
supplementalCodecs: '',
kind: '',
encrypted: false,
drmInfos: [],
keyIds: new Set(),
language: 'und',
originalLanguage: 'und',
label: null,
type: 'chapter',
primary: false,
trickModeVideo: null,
dependencyStream: null,
emsgSchemeIdUris: null,
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
accessibilityPurpose: null,
external: true,
fastSwitching: false,
fullMimeTypes: new Set(['text/plain']),
isAudioMuxedInVideo: false,
baseOriginalId: null
};
return [stream];
}
function SabrManifestParser() {
this._config = null;
}
SabrManifestParser.prototype.banLocation = function (_uri) {};
SabrManifestParser.prototype.configure = function (config, _isPreloadFn) {
this._config = config;
};
SabrManifestParser.prototype.onInitialVariantChosen = function (_variant) {};
SabrManifestParser.prototype.setMediaElement = function (_mediaElement) {};
SabrManifestParser.prototype.start = async function (uri, playerInterface) {
var filter = playerInterface.filter;
var networkingEngine = playerInterface.networkingEngine;
var uriPrefixLength = 5 + MANIFEST_TYPE_SABR.length + 1;
var manifestData = JSON.parse(decodeURIComponent(uri.slice(uriPrefixLength)));
var presentationTimeline = new shaka.media.PresentationTimeline(0, 0, true);
presentationTimeline.setStatic(true);
presentationTimeline.setSegmentAvailabilityDuration(Infinity);
presentationTimeline.lockStartTime();
presentationTimeline.setDuration(manifestData.duration);
var currentId = 0;
var audioStreams = [];
var videoStreams = [];
var hasDrcAudio = false;
var hasVoiceBoostAudio = false;
for (var i = 0; i < manifestData.formats.length; i++) {
if (manifestData.formats[i].isDrc) hasDrcAudio = true;
if (manifestData.formats[i].isVoiceBoost) hasVoiceBoostAudio = true;
}
var fakeVideoFormatId;
if (this._config.disableVideo) {
var worstVideoFormat = null;
for (var i2 = 0; i2 < manifestData.formats.length; i2++) {
var currentFormat = manifestData.formats[i2];
if (currentFormat.width === undefined) continue;
if (worstVideoFormat === null) {
worstVideoFormat = currentFormat;
} else if (currentFormat.bitrate < worstVideoFormat.bitrate) {
worstVideoFormat = currentFormat;
}
}
fakeVideoFormatId = worstVideoFormat ? buildFormatId(worstVideoFormat) : undefined;
}
for (var i3 = 0; i3 < manifestData.formats.length; i3++) {
var format = manifestData.formats[i3];
if (format.mimeType.indexOf('audio/') === 0) {
if (format.xtags === 'CgcKAnZiEgEx') {
// Workaround: https://github.com/LuanRT/googlevideo/issues/42
continue;
}
audioStreams.push(createAudioStream(format, currentId++, hasDrcAudio, hasVoiceBoostAudio, presentationTimeline, networkingEngine, fakeVideoFormatId));
} else if (!this._config.disableVideo) {
videoStreams.push(createVideoStream(format, currentId++, presentationTimeline, networkingEngine));
}
}
audioStreams.sort(function (a, b) { return b.bandwidth - a.bandwidth; });
if (!this._config.disableVideo) {
videoStreams.sort(function (a, b) {
return VIDEO_CODEC_PRIORITIES.findIndex(function (codec) { return a.codecs.indexOf(codec) === 0; }) -
VIDEO_CODEC_PRIORITIES.findIndex(function (codec) { return b.codecs.indexOf(codec) === 0; });
});
}
var variants = [];
var variantId = 0;
if (this._config.disableVideo) {
for (var i4 = 0; i4 < audioStreams.length; i4++) {
var stream = audioStreams[i4];
variants.push({
id: variantId++,
audio: stream,
bandwidth: stream.bandwidth,
language: stream.language,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
disabledUntilTime: 0,
primary: stream.primary,
video: null
});
}
} else {
for (var i5 = 0; i5 < audioStreams.length; i5++) {
for (var j = 0; j < videoStreams.length; j++) {
variants.push({
id: variantId++,
audio: audioStreams[i5],
video: videoStreams[j],
bandwidth: audioStreams[i5].bandwidth + videoStreams[j].bandwidth,
language: audioStreams[i5].language,
allowedByApplication: true,
allowedByKeySystem: true,
decodingInfos: [],
disabledUntilTime: 0,
primary: audioStreams[i5].primary
});
}
}
}
var textStreams = createTextStreams(manifestData.captions, presentationTimeline, currentId);
currentId += textStreams.length;
var imageStreams = createImageStreams(manifestData.storyboards, presentationTimeline, currentId);
currentId += imageStreams.length;
var chapterStreams = createChapterStreams(manifestData.chapters, currentId);
var manifest = {
type: 'SABR',
startTime: 0,
variants: variants,
textStreams: textStreams,
imageStreams: imageStreams,
chapterStreams: chapterStreams,
presentationTimeline: presentationTimeline,
gapCount: 0,
ignoreManifestTimestampsInSegmentsMode: false,
isLowLatency: false,
nextUrl: null,
offlineSessionIds: [],
periodCount: 1,
sequenceMode: false,
serviceDescription: null
};
await filter(manifest);
return manifest;
};
SabrManifestParser.prototype.stop = function () {
this._config = null;
return Promise.resolve();
};
// Register unconditionally so the data: application/sabr+json manifest works.
shaka.media.ManifestParser.registerParserByMime(MANIFEST_TYPE_SABR, function () { return new SabrManifestParser(); });
window.MANIFEST_TYPE_SABR = MANIFEST_TYPE_SABR;
})();

112
assets/js/sabr_mp4_index.js Normal file
View File

@ -0,0 +1,112 @@
// Port of FreeTube's Mp4SegmentIndexParser.js
// Based on shaka-player's dash/mp4_segment_index_parser.js
// Reads shaka from window.shaka (loaded via <script>).
'use strict';
(function () {
var shaka = window.shaka;
var ShakaError = shaka.util.Error;
var SeverityCritical = ShakaError.Severity.CRITICAL;
var CategoryMedia = ShakaError.Category.MEDIA;
function parseSIDX(sidxOffset, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd, uri, box) {
var references = [];
box.reader.skip(4);
var timescale = box.reader.readUint32();
if (timescale === 0) {
console.error('[parseMp4SegmentIndex] Invalid timescale.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.MP4_SIDX_INVALID_TIMESCALE);
}
var earliestPresentationTime;
var firstOffset;
if (box.version === 0) {
earliestPresentationTime = box.reader.readUint32();
firstOffset = box.reader.readUint32();
} else {
earliestPresentationTime = box.reader.readUint64();
firstOffset = box.reader.readUint64();
}
box.reader.skip(2);
var referenceCount = box.reader.readUint16();
var unscaledStartTime = earliestPresentationTime;
var startByte = sidxOffset + box.size + firstOffset;
for (var i = 0; i < referenceCount; i++) {
var chunk = box.reader.readUint32();
var referenceType = (chunk & 0x80000000) >>> 31;
var referenceSize = chunk & 0x7FFFFFFF;
var subsegmentDuration = box.reader.readUint32();
box.reader.skip(4);
if (referenceType === 1) {
console.error('[parseMp4SegmentIndex] Hierarchical SIDXs are not supported.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.MP4_SIDX_TYPE_NOT_SUPPORTED);
}
var nativeStartTime = unscaledStartTime / timescale;
var nativeEndTime = (unscaledStartTime + subsegmentDuration) / timescale;
// NOTE: must be `let` (block-scoped). With `var` the closure below
// captures the function-scoped binding, so every SegmentReference would
// return the LAST segment's URL (wrong startTimeMs/sq -> SABR serves no media).
let uris = [uri + '&startTimeMs=' + Math.round((nativeStartTime + timestampOffset) * 1000) + '&sq=' + (i + 1)];
references.push(
new shaka.media.SegmentReference(
nativeStartTime + timestampOffset,
nativeEndTime + timestampOffset,
function () { return uris; },
startByte,
startByte + referenceSize - 1,
initSegmentReference,
timestampOffset,
appendWindowStart,
appendWindowEnd
)
);
unscaledStartTime += subsegmentDuration;
startByte += referenceSize;
}
box.parser.stop();
return references;
}
function parseMp4SegmentIndex(sidxData, sidxOffset, uri, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd) {
var references;
var parser = new shaka.util.Mp4Parser()
.fullBox('sidx', function (box) {
references = parseSIDX(
sidxOffset,
initSegmentReference,
timestampOffset,
appendWindowStart,
appendWindowEnd,
uri,
box
);
});
if (sidxData) {
parser.parse(sidxData);
}
if (references) {
return references;
} else {
console.error('[parseMp4SegmentIndex] Invalid box type, expected "sidx".');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.MP4_SIDX_WRONG_BOX_TYPE);
}
}
window.parseMp4SegmentIndex = parseMp4SegmentIndex;
})();

211
assets/js/sabr_onesie.js Normal file
View File

@ -0,0 +1,211 @@
/**
* SABR Onesie - fetch the YouTube player response through an encrypted Onesie
* request (WEB client), instead of a plain /youtubei/v1/player call.
*
* Why: a normal player request is bound to the IP that made it, so the
* server_abr_streaming_url it returns only works from that same IP. An Onesie
* request is proxied by YouTube's "trusted bandaid", so the player response
* (and the SABR streaming URL within it) is not tied to our proxy's egress IP.
* Subsequent media is still pulled with SABR (see sabr_scheme_plugin.js) - this
* module only replaces how the player response itself is obtained.
*
* Adapted from googlevideo/examples/onesie-request/main.ts and the WEB-client
* variant in invidious-secret-companion (override/onesiePlayerReq.ts).
*
* Reads googlevideo protos/ump/utils from window.googlevideo, crypto helpers
* from window.SABRHelpers, and Constants from window.Constants.
* Exposes window.fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig).
*/
'use strict';
(function () {
// Public WEB player API key used for the inner player request.
var PLAYER_API_KEY = 'AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8';
function gv() { return window.googlevideo; }
// Concatenate a UMP part's chunks into a single Uint8Array.
function partBytes(part) {
var chunks = part.data.chunks;
if (chunks.length === 1) return chunks[0];
return gv().utils.concatenateChunks(chunks);
}
// Ensure we have a googlevideo redirector base (e.g. https://rrX---snXXX.googlevideo.com).
// Reuses the value preloaded into localStorage by SABRPlayer.initInnertube.
async function getRedirectorBase() {
var stored = null;
try { stored = localStorage.getItem(SABRHelpers.REDIRECTOR_STORAGE_KEY); } catch (e) {}
if (!stored || !stored.startsWith('https://')) {
var resp = await SABRHelpers.fetchWithProxy(
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1e5),
{ method: 'GET' }
);
stored = (await resp.text()).trim();
if (stored.startsWith('https://')) {
try { localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, stored); } catch (e) {}
}
}
if (!stored.startsWith('https://')) throw new Error('Invalid redirector response');
return stored.split('/initplayback')[0];
}
// Build the encrypted Onesie request body + the hex-encoded video id.
async function prepareOnesieRequest(innertube, videoId, poToken, clientConfig) {
var protos = gv().protos;
var base64ToU8 = gv().utils.base64ToU8;
// Shallow clone of the session context, deep-copying only `client` since
// that's all we mutate (force WEB client - matches the secret-companion).
var ctx = Object.assign({}, innertube.session.context);
ctx.client = Object.assign({}, innertube.session.context.client, {
clientName: Constants.CLIENTS.WEB.NAME,
clientVersion: Constants.CLIENTS.WEB.VERSION
});
var playerRequestJson = {
context: ctx,
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
lactMilliseconds: '-1',
signatureTimestamp: innertube.session.player && innertube.session.player.signature_timestamp
}
},
videoId: videoId,
racyCheckOk: true,
contentCheckOk: true
};
if (poToken) {
playerRequestJson.serviceIntegrityDimensions = { poToken: poToken };
}
var headers = [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'User-Agent', value: ctx.client.userAgent },
{ name: 'X-Goog-Visitor-Id', value: ctx.client.visitorData }
];
var onesieInnertubeRequest = protos.OnesieInnertubeRequest.encode({
url: 'https://youtubei.googleapis.com/youtubei/v1/player?key=' + PLAYER_API_KEY,
headers: headers,
body: JSON.stringify(playerRequestJson),
proxiedByTrustedBandaid: true,
skipResponseEncryption: true
}).finish();
var enc = await SABRHelpers.encryptRequest(clientConfig.clientKeyData, onesieInnertubeRequest);
var body = protos.OnesieRequest.encode({
urls: [],
innertubeRequest: {
enableCompression: true,
encryptedClientKey: clientConfig.encryptedClientKey,
encryptedOnesieInnertubeRequest: enc.encrypted,
hmac: enc.hmac,
iv: enc.iv,
useJsonformatterToParsePlayerResponse: false,
serializeResponseAsJson: true
},
streamerContext: {
sabrContexts: [],
unsentSabrContexts: [],
poToken: poToken ? base64ToU8(poToken) : undefined,
playbackCookie: undefined,
clientInfo: {
clientName: parseInt(Constants.CLIENT_NAME_IDS[Constants.CLIENTS.WEB.NAME], 10),
clientVersion: Constants.CLIENTS.WEB.VERSION
}
},
bufferedRanges: [],
onesieUstreamerConfig: clientConfig.onesieUstreamerConfig
}).finish();
// The `id` query param is the hex of the (base64-decoded) video id.
var videoIdBytes = base64ToU8(videoId);
var hex = [];
for (var i = 0; i < videoIdBytes.length; i++) {
hex.push(videoIdBytes[i].toString(16).padStart(2, '0'));
}
return { body: body, encodedVideoId: hex.join('') };
}
/**
* Fetch and decode the player response JSON via Onesie.
* @returns {Promise<Object>} the raw /player response JSON
*/
async function fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig) {
if (!clientConfig) throw new Error('Onesie client config not available');
var protos = gv().protos;
var ump = gv().ump;
var base = await getRedirectorBase();
var prepared = await prepareOnesieRequest(innertube, videoId, poToken, clientConfig);
var url = base + clientConfig.baseUrl +
'&id=' + prepared.encodedVideoId +
'&cmo:sensitive_content=yes' +
'&opr=1' + // Onesie Playback Request
'&osts=0' + // Onesie Start Time Seconds
'&por=1' +
'&rn=0';
var resp = await SABRHelpers.fetchWithProxy(url, {
method: 'POST',
headers: { 'accept': '*/*', 'content-type': 'application/octet-stream' },
body: prepared.body
});
var buffer = new Uint8Array(await resp.arrayBuffer());
// Parse the UMP stream: collect ONESIE_HEADER parts and attach their data.
var onesie = [];
new ump.UmpReader(new ump.CompositeBuffer([buffer])).read(function (part) {
if (part.type === protos.UMPPartId.ONESIE_HEADER) {
onesie.push(protos.OnesieHeader.decode(partBytes(part)));
} else if (part.type === protos.UMPPartId.ONESIE_DATA) {
if (onesie.length) onesie[onesie.length - 1].data = partBytes(part);
} else if (part.type === protos.UMPPartId.SABR_ERROR) {
try { console.error('[SABROnesie] SABR_ERROR', protos.SabrError.decode(partBytes(part))); } catch (e) {}
}
});
var header = null;
for (var i = 0; i < onesie.length; i++) {
if (onesie[i].type === protos.OnesieHeaderType.ONESIE_PLAYER_RESPONSE) { header = onesie[i]; break; }
}
if (!header) throw new Error('Onesie player response not found');
if (!header.cryptoParams) throw new Error('Onesie crypto params not found');
var responseData = header.data;
// Decompress (gzip) if requested.
if (responseData && header.cryptoParams.compressionType === protos.CompressionType.GZIP) {
var ds = new DecompressionStream('gzip');
var stream = new Blob([responseData]).stream().pipeThrough(ds);
responseData = new Uint8Array(await new Response(stream).arrayBuffer());
}
// Decrypt only if the response was encrypted (skipResponseEncryption=true means it usually isn't).
var iv = header.cryptoParams.iv;
var hmac = header.cryptoParams.hmac;
var decrypted = (hmac && hmac.length && iv && iv.length)
? await SABRHelpers.decryptResponse(iv, hmac, responseData, clientConfig.clientKeyData)
: responseData;
var innerResponse = protos.OnesieInnertubeResponse.decode(decrypted);
if (innerResponse.onesieProxyStatus !== protos.OnesieProxyStatus.OK) {
throw new Error('Onesie proxy status not OK (' + innerResponse.onesieProxyStatus + ')');
}
if (innerResponse.httpStatus !== 200) {
throw new Error('Onesie player HTTP status ' + innerResponse.httpStatus);
}
return JSON.parse(new TextDecoder().decode(innerResponse.body));
}
window.fetchOnesiePlayerResponse = fetchOnesiePlayerResponse;
})();

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

@ -0,0 +1,648 @@
/**
* SABR Player - Main player initialization for SABR streaming
*
* Re-engineered (from the kira-based POC) to use FreeTube's SABR engine:
* - Builds a data:application/sabr+json manifest from the youtube.js VideoInfo
* - Registers FreeTube's sabr: networking scheme (sabr_scheme_plugin.js)
* - Drives Shaka 5 via the application/sabr+json manifest parser
*
* PoToken is minted browser-side via bgutils-js BotGuard and proxied through
* the Invidious /proxy route for CSP compliance.
*/
'use strict';
var SABRPlayer = (function () {
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 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;
var sabrStream = null; // handle returned by setupSabrScheme
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
var reloadCount = 0; // cap reloads to prevent infinite loop on blocked/throttled videos
var MAX_RELOADS = 3;
function getSavedVolume() {
try {
var v = localStorage.getItem(VOLUME_KEY);
return v ? parseFloat(v) : 1;
} catch (e) { return 1; }
}
function saveVolume(volume) {
try { localStorage.setItem(VOLUME_KEY, volume.toString()); } catch (e) {}
}
function getPlaybackPositions() {
try {
var p = localStorage.getItem(PLAYBACK_POSITION_KEY);
return p ? JSON.parse(p) : {};
} catch (e) { return {}; }
}
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 (e) {}
}
function getPlaybackPosition(videoId) {
var positions = getPlaybackPositions();
return positions[videoId] || 0;
}
function base64ToU8(base64) {
// base64ToU8 from googlevideo handles websafe, but for client config we need raw.
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;
}
async function initInnertube() {
if (innertube) return innertube;
try {
console.info('[SABRPlayer]', 'Initializing InnerTube API');
if (typeof Innertube === 'undefined') throw new Error('youtubei.js not loaded');
// Set up Platform.shim.eval for URL deciphering (browser bundle lacks 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');
}
innertube = await Innertube.create({
fetch: SABRHelpers.fetchWithProxy,
retrieve_player: true,
generate_session_locally: true
});
// Kick off BotGuard init (don't block player setup).
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.indexOf('https://') === 0) {
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;
}
}
async function fetchOnesieConfig() {
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) return clientConfig;
var cached = SABRHelpers.loadCachedClientConfig();
if (cached) { clientConfig = cached; 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;
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;
}
}
async function mintContentWebPO() {
if (!playbackWebPoTokenContentBinding || 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()) {
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;
}
}
async function initializeShakaPlayer(containerElement, listenMode) {
if (!shaka.Player.isBrowserSupported()) {
throw new Error('Shaka Player is not supported in this browser');
}
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({
abr: DEFAULT_ABR_CONFIG,
streaming: {
bufferingGoal: 120,
rebufferingGoal: 0.01,
bufferBehind: 300,
retryParameters: { maxAttempts: 8, fuzzFactor: 0.5, timeout: 30 * 1000 }
},
manifest: {
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
disableVideo: !!listenMode
}
});
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);
if (shaka.ui && shaka.ui.Overlay) {
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
ui.configure({
overflowMenuButtons: ['captions', 'quality', 'language', 'playback_rate', 'loop', 'picture_in_picture']
});
}
return player;
}
// Route non-sabr: requests (captions, storyboards, image tiles) through the
// Invidious /proxy so they satisfy CSP. The sabr: scheme is handled
// separately by setupSabrScheme and also routes through /proxy internally.
function setupRequestFilters() {
var networkingEngine = player && player.getNetworkingEngine ? player.getNetworkingEngine() : null;
if (!networkingEngine) return;
networkingEngine.registerRequestFilter(function (type, request) {
var uri = request.uris[0];
if (!uri) return;
// sabr: is owned by the SABR scheme plugin; never rewrite it here.
if (uri.indexOf('sabr:') === 0) return;
try {
var url = new URL(uri);
} catch (e) { return; }
var isGoogleVideo = url.hostname.endsWith('.googlevideo.com') || url.hostname.indexOf('googlevideo') !== -1;
var isYouTube = url.hostname.endsWith('.youtube.com');
if (!isGoogleVideo && !isYouTube) return;
// Reuse the shared proxy helper so __host/__headers are set consistently.
var proxied = SABRHelpers.proxyUrl(url, request.headers || {});
proxied.searchParams.set('alr', 'yes');
request.uris[0] = proxied.toString();
});
}
// Map a youtube.js Format to the SabrManifest "formats" entry shape expected
// by sabr_manifest_parser.js (port of FreeTube's SabrManifestParser).
function mapFormatToManifestEntry(fmt) {
var audioTrack = fmt.audio_track || null;
var colorInfo = fmt.color_info || null;
return {
itag: fmt.itag,
lastModified: fmt.last_modified_ms,
mimeType: fmt.mime_type,
xtags: fmt.xtags,
bitrate: fmt.bitrate,
initRange: fmt.init_range,
indexRange: fmt.index_range,
width: fmt.width,
height: fmt.height,
frameRate: fmt.fps,
quality: fmt.quality,
language: fmt.language,
audioSampleRate: fmt.audio_sample_rate,
audioChannels: fmt.audio_channels,
isDrc: fmt.is_drc,
isVoiceBoost: fmt.is_vb,
isOriginal: fmt.is_original,
isDubbed: fmt.is_dubbed,
isAutoDubbed: fmt.is_auto_dubbed,
isDescriptive: fmt.is_descriptive,
isSecondary: fmt.is_secondary,
spatialAudio: !!(fmt.spatial_audio_type),
label: audioTrack ? audioTrack.display_name : undefined,
colorTransferCharacteristics: colorInfo ? colorInfo.transfer_characteristics : undefined,
colorPrimaries: colorInfo ? colorInfo.primaries : undefined
};
}
function buildCaptions(videoInfo) {
var out = [];
var tracks = videoInfo.captions && videoInfo.captions.caption_tracks;
if (!tracks) return out;
for (var i = 0; i < tracks.length; i++) {
var c = tracks[i];
var url;
try {
url = new URL(c.base_url);
url.searchParams.set('fmt', 'vtt');
url = url.toString();
} catch (e) {
url = c.base_url;
}
out.push({
id: c.vss_id,
url: url,
label: c.name ? c.name.text : (c.language_code || ''),
language: c.language_code || 'und',
mimeType: 'text/vtt'
});
}
return out;
}
function buildStoryboards(videoInfo) {
var out = [];
var sb = videoInfo.storyboards;
if (!sb || sb.type !== 'PlayerStoryboardSpec') return out;
var boards = sb.boards || [];
// Pick the highest-res storyboard (matches FreeTube behaviour).
var board = boards.length ? boards[boards.length - 1] : null;
if (!board) return out;
out.push({
templateUrl: board.template_url,
mimeType: 'image/webp',
columns: board.columns,
rows: board.rows,
thumbnailCount: board.thumbnail_count,
thumbnailWidth: board.thumbnail_width,
thumbnailHeight: board.thumbnail_height,
storyboardCount: board.storyboard_count,
interval: board.interval > 0 ? board.interval / 1000 : 0
});
return out;
}
function buildChapters(videoInfo) {
var out = [];
var ch = videoInfo.chapters;
if (!ch || !ch.get) return out;
try {
var list = ch.get();
if (!list || !list.length) return out;
for (var i = 0; i < list.length; i++) {
var c = list[i];
out.push({
title: c.title || '',
startSeconds: c.start || 0,
endSeconds: c.end || (i + 1 < list.length ? (list[i + 1].start || 0) : 0)
});
}
} catch (e) {
// chapters not available for this video - harmless
}
return out;
}
// Build a data:application/sabr+json manifest URI from a youtube.js VideoInfo.
// Port of FreeTube's Watch.js#createLocalSabrManifest.
function buildSabrManifest(videoInfo, poToken, clientInfo) {
var streamingData = videoInfo.streaming_data;
if (!streamingData || !streamingData.server_abr_streaming_url || !streamingData.adaptive_formats) {
return null;
}
var url = new URL(streamingData.server_abr_streaming_url);
url.searchParams.set('alr', 'yes');
if (videoInfo.cpn) url.searchParams.set('cpn', videoInfo.cpn);
var sabrData = {
url: url.toString(),
poToken: poToken || '',
ustreamerConfig: videoInfo.player_config &&
videoInfo.player_config.media_common_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config.video_playback_ustreamer_config || '',
clientInfo: clientInfo
};
SABRPlayer._lastSabrData = sabrData;
var adaptiveFormats = streamingData.adaptive_formats;
var duration = Infinity;
for (var i = 0; i < adaptiveFormats.length; i++) {
var d = adaptiveFormats[i].approx_duration_ms;
if (typeof d === 'number' && d < duration) duration = d;
}
if (!isFinite(duration)) duration = (videoInfo.basic_info && videoInfo.basic_info.duration) ? videoInfo.basic_info.duration * 1000 : 0;
duration = duration / 1000;
var formats = [];
for (var j = 0; j < adaptiveFormats.length; j++) {
formats.push(mapFormatToManifestEntry(adaptiveFormats[j]));
}
var sabrManifest = {
duration: duration,
formats: formats,
captions: buildCaptions(videoInfo),
storyboards: buildStoryboards(videoInfo),
chapters: buildChapters(videoInfo)
};
return 'data:' + window.MANIFEST_TYPE_SABR + ',' + encodeURIComponent(JSON.stringify(sabrManifest));
}
function getPlayerWidth() { return videoElement ? videoElement.clientWidth : 1280; }
function getPlayerHeight() { return videoElement ? videoElement.clientHeight : 720; }
function getPlayer() { return player; }
function getManifest() { return sabrManifest; }
function wireSabrStream(loadFn) {
if (!sabrStream) return;
sabrStream.onBackoffRequested(function (info) {
var ms = info && info.backoffMs ? info.backoffMs : 0;
console.warn('[SABRPlayer] SABR backoff requested:', ms, 'ms');
var toast = document.querySelector('.sabr-backoff-toast');
if (!toast && shakaContainer) {
toast = document.createElement('div');
toast.className = 'sabr-backoff-toast';
toast.style.cssText = 'position:absolute;bottom:48px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;padding:6px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:10';
shakaContainer.appendChild(toast);
}
if (toast) {
toast.textContent = 'SABR throttled by YouTube, retrying in ' + (ms / 1000).toFixed(1) + 's…';
clearTimeout(toast._t);
toast._t = setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, Math.max(ms + 500, 2000));
}
});
sabrStream.onReloadOnce(function () {
reloadCount++;
if (reloadCount >= MAX_RELOADS) {
console.error('[SABRPlayer] SABR reload limit reached; giving up on video', currentVideoId);
if (shakaContainer) {
var errDiv = document.createElement('div');
errDiv.className = 'sabr-error-display';
errDiv.innerHTML = '<p>This video is currently unavailable via SABR.</p>' +
'<p>YouTube is throttling this request.</p>' +
'<p><a href="?quality=dash">Try DASH player instead</a></p>';
shakaContainer.appendChild(errDiv);
}
return;
}
console.warn('[SABRPlayer] SABR reload requested by server; re-loading video (attempt ' + reloadCount + '/' + MAX_RELOADS + ')');
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, currentLoadOptions || {});
});
}
async function loadVideo(videoId, containerElement, options) {
options = options || {};
if (videoId !== currentVideoId) reloadCount = 0;
currentVideoId = videoId;
currentLoadOptions = options;
playbackWebPoToken = null;
playbackWebPoTokenContentBinding = videoId;
try {
// Start Shaka player init (DOM + polyfills) in parallel with network init.
var shakaPromise;
if (!player) {
shakaPromise = initializeShakaPlayer(containerElement, options.listen);
} else {
player.configure('abr', DEFAULT_ABR_CONFIG);
player.configure('manifest.disableVideo', !!options.listen);
shakaPromise = Promise.resolve();
}
// Network init chain: Innertube → Onesie config → PoToken.
var netPromise = (async function () {
if (!innertube) {
innertube = await initInnertube();
if (!innertube) throw new Error('Failed to initialize Innertube');
}
if (!clientConfig) {
await fetchOnesieConfig();
}
// Mint a content-bound PoToken before getInfo so streaming_data includes
// the server_abr_streaming_url and so the SABR requests authenticate.
try { await mintContentWebPO(); } catch (e) { console.warn('[SABRPlayer] poToken mint failed, continuing', e); }
})();
await Promise.all([shakaPromise, netPromise]);
var poToken = playbackWebPoToken || coldStartToken || '';
setupRequestFilters();
// Fetch the player response through Onesie (WEB client) so the SABR
// streaming URL isn't bound to the proxy's egress IP. Falls back to a
// plain getInfo() if the onesie path is unavailable. Media still flows
// over SABR (sabr_scheme_plugin.js) either way.
var videoInfo;
try {
if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) {
throw new Error('Onesie support not loaded');
}
var playerResponse = await window.fetchOnesiePlayerResponse(innertube, videoId, poToken || undefined, clientConfig);
videoInfo = new window.YT.VideoInfo(
[{ success: true, status_code: 200, data: playerResponse }],
innertube.actions,
SABRHelpers.generateRandomString(16)
);
console.info('[SABRPlayer]', 'Player response fetched via Onesie (WEB)');
} catch (onesieErr) {
console.warn('[SABRPlayer]', 'Onesie player request failed, falling back to getInfo:', onesieErr);
videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
}
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
throw new Error('Video unavailable: ' + reason);
}
isLive = !!(videoInfo.basic_info && videoInfo.basic_info.is_live);
var streamingData = videoInfo.streaming_data;
if (!streamingData) throw new Error('No streaming data available');
// Derive clientInfo from the innertube session (matches FreeTube's local.js).
var ctxClient = innertube.session && innertube.session.context && innertube.session.context.client;
var clientName = ctxClient ? ctxClient.clientName : 'ANDROID';
var clientInfo = {
clientName: (typeof Constants !== 'undefined' && Constants.CLIENT_NAME_IDS && Constants.CLIENT_NAME_IDS[clientName]) || 3,
clientVersion: ctxClient ? ctxClient.clientVersion : (Constants.CLIENTS && Constants.CLIENTS.ANDROID ? Constants.CLIENTS.ANDROID.VERSION : '19.09.37'),
osName: ctxClient ? ctxClient.osName : 'Android',
osVersion: ctxClient ? ctxClient.osVersion : '14'
};
var manifestUri;
if (isLive) {
// Live: don't route through the SABR parser yet; use HLS/DASH.
manifestUri = streamingData.hls_manifest_url || streamingData.dash_manifest_url;
if (streamingData.hls_manifest_url) {
if (innertube.session && innertube.session.player && innertube.session.player.decipher) {
try { streamingData.hls_manifest_url = await innertube.session.player.decipher(streamingData.hls_manifest_url); } catch (e) {}
}
manifestUri = streamingData.hls_manifest_url;
}
} else {
if (streamingData.server_abr_streaming_url && innertube.session && innertube.session.player && innertube.session.player.decipher) {
try {
streamingData.server_abr_streaming_url = await innertube.session.player.decipher(streamingData.server_abr_streaming_url);
} catch (e) {
console.warn('[SABRPlayer] Failed to decipher server_abr_streaming_url', e);
}
}
manifestUri = buildSabrManifest(videoInfo, poToken, clientInfo);
if (!manifestUri) {
// No SABR URL available - fall back to DASH.
var dashManifest = await videoInfo.toDash({ manifest_options: { 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');
// Register the sabr: scheme before loading (idempotent re-registration is fine).
if (!isLive && window.setupSabrScheme && SABRPlayer._lastSabrData) {
if (sabrStream) {
try { sabrStream.cleanup(); } catch (e) {}
}
sabrStream = window.setupSabrScheme(SABRPlayer._lastSabrData, getPlayer, getManifest, getPlayerWidth, getPlayerHeight);
wireSabrStream(loadVideo);
}
// Capture the parsed manifest once Shaka has loaded it, so the sabr:
// scheme plugin can read variant/segment indices from it.
player.addEventListener('loaded', function () {
if (typeof player.getManifest === 'function') {
sabrManifest = player.getManifest();
}
});
var startTime = options.startTime;
if (startTime === undefined && options.savePlayerPos !== false) {
startTime = getPlaybackPosition(videoId);
}
var mimeType = (!isLive && manifestUri.indexOf('data:' + window.MANIFEST_TYPE_SABR) === 0)
? window.MANIFEST_TYPE_SABR
: undefined;
await player.load(manifestUri, isLive ? undefined : startTime, mimeType);
videoElement.play().catch(function (err) {
if (err.name === 'NotAllowedError') console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
});
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;
}
}
async function dispose() {
if (savePositionInterval) { clearInterval(savePositionInterval); savePositionInterval = null; }
if (videoElement && currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
if (sabrStream) { try { sabrStream.cleanup(); } catch (e) {} sabrStream = 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 = '';
sabrManifest = null;
}
function getPlayerInstance() { return player; }
function getVideoElement() { return videoElement; }
return {
loadVideo: loadVideo,
dispose: dispose,
getPlayer: getPlayerInstance,
getVideoElement: getVideoElement,
initInnertube: initInnertube,
fetchOnesieConfig: fetchOnesieConfig
};
})();
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,711 @@
// Port of FreeTube's SabrSchemePlugin.js
// Registers the 'sabr' networking scheme with Shaka.
// Reads shaka from window.shaka, googlevideo symbols from window.googlevideo.
// Invidious is browser-sandboxed, so outbound fetches are routed through
// SABRHelpers.fetchWithProxy (the Crystal /proxy route) instead of hitting
// googlevideo directly.
// Fixes the latent SABR_REDIRECT bug (write sabrStreamState.sabrUrl, not currentState.sabrUrl).
// Exports window.setupSabrScheme.
'use strict';
(function () {
// Lazily resolve dependencies at call time so load order is flexible.
function shaka() { return window.shaka; }
function gv() { return window.googlevideo; }
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
function formatIdFromString(str) {
var parts = str.split('-');
return {
itag: parseInt(parts[0], 10),
lastModified: parts[1],
xtags: parts[2]
};
}
function createBufferedRange(formatId, buffered, segmentIndex) {
var endSegmentIndex = segmentIndex.find(buffered.end);
if (endSegmentIndex == null) {
endSegmentIndex = segmentIndex.getNumReferences() - 1;
}
return {
formatId: formatId,
startTimeMs: String(Math.round(buffered.start * 1000)),
durationMs: String(Math.round((buffered.end - buffered.start) * 1000)),
startSegmentIndex: segmentIndex.find(buffered.start),
endSegmentIndex: endSegmentIndex
};
}
function createFullBufferRange(formatId) {
var MAX_INT32_VALUE = gv().utils.MAX_INT32_VALUE;
return {
formatId: formatId,
durationMs: MAX_INT32_VALUE,
startTimeMs: '0',
startSegmentIndex: parseInt(MAX_INT32_VALUE, 10),
endSegmentIndex: parseInt(MAX_INT32_VALUE, 10),
timeRange: {
durationTicks: MAX_INT32_VALUE,
startTicks: '0',
timescale: 1000
}
};
}
function fillBufferedRanges(player, manifest, audioFormatsActive, streamIsVideo, streamIsAudio, bufferedRanges, activeVariant) {
var bufferedInfo = player.getBufferedInfo();
if (bufferedInfo.audio.length === 0 && bufferedInfo.video.length === 0) return;
var activeManifestVariant;
if (audioFormatsActive) {
activeManifestVariant = manifest.variants.find(function (variant) {
return variant.audio.originalId === activeVariant.originalAudioId;
});
} else {
activeManifestVariant = manifest.variants.find(function (variant) {
return variant.audio.originalId === activeVariant.originalAudioId &&
variant.video.originalId === activeVariant.originalVideoId;
});
}
var audioFormatId = formatIdFromString(activeVariant.originalAudioId);
var audioSegmentIndex = activeManifestVariant.audio.segmentIndex;
if (streamIsVideo) {
bufferedRanges.push(createFullBufferRange(audioFormatId));
} else {
for (var i = 0; i < bufferedInfo.audio.length; i++) {
bufferedRanges.push(createBufferedRange(audioFormatId, bufferedInfo.audio[i], audioSegmentIndex));
}
}
var videoFormatId;
var videoSegmentIndex;
if (streamIsAudio && bufferedInfo.video.length > 0) {
videoFormatId = formatIdFromString(activeVariant.originalVideoId);
bufferedRanges.push(createFullBufferRange(videoFormatId));
} else {
for (var j = 0; j < bufferedInfo.video.length; j++) {
var buffered = bufferedInfo.video[j];
if (!videoFormatId) {
videoFormatId = formatIdFromString(activeVariant.originalVideoId);
}
if (!videoSegmentIndex) {
videoSegmentIndex = activeManifestVariant.video.segmentIndex;
}
bufferedRanges.push(createBufferedRange(videoFormatId, buffered, videoSegmentIndex));
}
}
}
function createCacheResponse(ShakaAbortableOperation, uri, request, data) {
return ShakaAbortableOperation.completed({
data: data,
fromCache: true,
headers: {},
originalRequest: request,
originalUri: uri,
uri: uri
});
}
function createRecoverableNetworkError(code) {
var ShakaError = shaka().util.Error;
var args = Array.prototype.slice.call(arguments, 1);
return new ShakaError(ShakaError.Severity.RECOVERABLE, ShakaError.Category.NETWORK, code, args);
}
function prepareSabrContexts(sabrStreamState) {
var sabrContexts = [];
var unsentSabrContexts = [];
sabrStreamState.sabrContexts.forEach(function (ctxUpdate) {
if (sabrStreamState.activeSabrContextTypes.has(ctxUpdate.type)) {
sabrContexts.push(ctxUpdate);
} else {
unsentSabrContexts.push(ctxUpdate.type);
}
});
return { sabrContexts: sabrContexts, unsentSabrContexts: unsentSabrContexts };
}
function decodePart(part, decoder) {
if (!part.data.chunks.length) return undefined;
try {
var concatenateChunks = gv().utils.concatenateChunks;
var chunk = part.data.chunks.length === 1 ? part.data.chunks[0] : concatenateChunks(part.data.chunks);
return decoder.decode(chunk);
} catch (e) {
return undefined;
}
}
function createTimeoutController(callback, timeoutMs) {
return {
_timeout: setTimeout(callback, timeoutMs),
_resetCount: 0,
resetTimeoutOnce: function () {
if (this._resetCount > 0) return;
this.clearTimeout();
this._timeout = setTimeout(callback, timeoutMs);
this._resetCount++;
},
clearTimeout: function () {
clearTimeout(this._timeout);
}
};
}
// Wrap the youtube/googlevideo fetch URL through the Invidious /proxy route,
// forwarding the UMP headers youtube needs. Returns a fetch-compatible RequestInit.
function proxyFetch(url, requestInit) {
var headers = new Headers(requestInit.headers || {});
// Ensure these headers survive the proxy hop.
if (!headers.has('x-youtube-client-name')) {
headers.set('x-youtube-client-name', '3');
}
return SABRHelpers.fetchWithProxy(url, Object.assign({}, requestInit, { headers: headers }));
}
async function doRequest(operationInputs, currentState) {
var ShakaError = shaka().util.Error;
var protos = gv().protos;
var ump = gv().ump;
var utils = gv().utils;
var UmpReader = ump.UmpReader;
var CompositeBuffer = ump.CompositeBuffer;
var UMPPartId = protos.UMPPartId;
var VideoPlaybackAbrRequest = protos.VideoPlaybackAbrRequest;
var StreamProtectionStatus = protos.StreamProtectionStatus;
var SabrError = protos.SabrError;
var SabrRedirect = protos.SabrRedirect;
var MediaHeader = protos.MediaHeader;
var SabrContextSendingPolicy = protos.SabrContextSendingPolicy;
var SabrContextUpdate = protos.SabrContextUpdate;
var SabrContextWritePolicy = protos.SabrContextWritePolicy;
var NextRequestPolicy = protos.NextRequestPolicy;
var PlaybackCookie = protos.PlaybackCookie;
var ReloadPlaybackContext = protos.ReloadPlaybackContext;
var response;
var chunkedDataBuffer = null;
var responseDataChunks = [];
var segmentComplete = false;
var shouldRetry = false;
var shouldRetryDueToNextRequestPolicy = false;
var invalidPoToken = false;
var error;
if (currentState.sabrStreamState.playerReloadRequested) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
}
try {
var shouldReloadDueToBackoffLoop = false;
if ((currentState.sabrStreamState.nextRequestPolicy?.backoffTimeMs || 0) > 0) {
var currentBackoffTimeMs = currentState.sabrStreamState.nextRequestPolicy.backoffTimeMs;
currentState.eventEmitter.emit('backoff-requested', { backoffMs: currentBackoffTimeMs });
await new Promise(function (resolve, reject) {
setTimeout(resolve, currentBackoffTimeMs);
currentState.abortController.signal.addEventListener('abort', reject);
});
currentState.timeoutController?.resetTimeoutOnce();
currentState.cumulativeBackOffTimeMs += currentState.sabrStreamState.nextRequestPolicy.backoffTimeMs;
currentState.cumulativeBackOffRequested += 1;
var timeoutMs = operationInputs.request.retryParameters.timeout;
if (currentState.cumulativeBackOffRequested >= 3 || (timeoutMs > 0 && timeoutMs <= (currentState.cumulativeBackOffTimeMs + currentBackoffTimeMs))) {
shouldReloadDueToBackoffLoop = true;
}
}
if (shouldReloadDueToBackoffLoop || currentState.cumulativeRetryDueToNextRequestPolicy >= 100) {
currentState.sabrStreamState.playerReloadRequested = true;
if (!currentState.abortController.signal.aborted) {
currentState.abortController.abort();
currentState.eventEmitter.emit('reload');
}
}
var sabrURL = new URL(currentState.sabrStreamState.sabrUrl);
sabrURL.searchParams.set('rn', String(currentState.sabrStreamState.requestNumber++));
// Stream through the Invidious proxy (CSP + CORS), honoring requestInit headers/body.
response = await proxyFetch(sabrURL.toString(), currentState.requestInit);
operationInputs.headersReceived({});
var fmtId = formatIdFromString(operationInputs.formatIdString);
var itag = fmtId.itag;
var lastModified = fmtId.lastModified;
var xtags = fmtId.xtags;
var mediaHeaderId;
var reader = response.body.getReader();
var readObj = await reader.read();
while (!readObj.done && !currentState.abortStatus.finished) {
if (chunkedDataBuffer) {
chunkedDataBuffer.append(readObj.value);
} else {
chunkedDataBuffer = new CompositeBuffer([readObj.value]);
}
var remainingData = new UmpReader(chunkedDataBuffer).read(function (part) {
switch (part.type) {
case UMPPartId.STREAM_PROTECTION_STATUS: {
var streamProtectionStatus = decodePart(part, StreamProtectionStatus);
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
invalidPoToken = true;
}
break;
}
case UMPPartId.SABR_ERROR: {
var sabrError = decodePart(part, SabrError);
if (!sabrError) break;
error = 'SABR Error: type: ' + sabrError.type + ', code: ' + sabrError.code;
break;
}
case UMPPartId.SABR_REDIRECT: {
var sabrRedirect = decodePart(part, SabrRedirect);
if (!sabrRedirect) break;
// BUGFIX (vs FreeTube): the read site reads sabrStreamState.sabrUrl,
// so write there, not currentState.sabrUrl.
currentState.sabrStreamState.sabrUrl = sabrRedirect.url;
shouldRetry = true;
break;
}
case UMPPartId.MEDIA_HEADER: {
if (mediaHeaderId === undefined) {
var mediaHeader = decodePart(part, MediaHeader);
if (!mediaHeader) break;
if (
mediaHeader.formatId.itag === itag &&
mediaHeader.formatId.lastModified === lastModified &&
mediaHeader.formatId.xtags === xtags
) {
if (operationInputs.isInit && mediaHeader.isInitSeg) {
mediaHeaderId = mediaHeader.headerId;
} else if (!operationInputs.isInit && mediaHeader.sequenceNumber === operationInputs.sequenceNumber) {
mediaHeaderId = mediaHeader.headerId;
}
}
}
break;
}
case UMPPartId.MEDIA: {
if (mediaHeaderId === part.data.getUint8(0)) {
var split = part.data.split(1);
var remaining = split.remainingBuffer;
for (var k = 0; k < remaining.chunks.length; k++) {
responseDataChunks.push(remaining.chunks[k]);
}
}
break;
}
case UMPPartId.MEDIA_END: {
if (mediaHeaderId === part.data.getUint8(0)) {
segmentComplete = true;
currentState.abortStatus.finished = true;
currentState.abortController.abort();
}
break;
}
case UMPPartId.NEXT_REQUEST_POLICY: {
var nextRequestPolicy = decodePart(part, NextRequestPolicy);
shouldRetry = true;
shouldRetryDueToNextRequestPolicy = true;
currentState.sabrStreamState.nextRequestPolicy = nextRequestPolicy;
currentState.abrRequest.streamerContext.playbackCookie = nextRequestPolicy?.playbackCookie ? PlaybackCookie.encode(nextRequestPolicy.playbackCookie).finish() : undefined;
currentState.abrRequest.streamerContext.backoffTimeMs = nextRequestPolicy?.backoffTimeMs;
break;
}
case UMPPartId.FORMAT_INITIALIZATION_METADATA: {
break;
}
case UMPPartId.SABR_CONTEXT_UPDATE: {
var sabrContextUpdate = decodePart(part, SabrContextUpdate);
if (!sabrContextUpdate) break;
if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
if (
sabrContextUpdate.writePolicy === SabrContextWritePolicy.KEEP_EXISTING &&
currentState.sabrStreamState.sabrContexts.has(sabrContextUpdate.type)
) {
break;
}
currentState.sabrStreamState.sabrContexts.set(sabrContextUpdate.type, sabrContextUpdate);
if (sabrContextUpdate.sendByDefault) {
currentState.sabrStreamState.activeSabrContextTypes.add(sabrContextUpdate.type);
}
}
break;
}
case UMPPartId.SABR_CONTEXT_SENDING_POLICY: {
var sabrContextSendingPolicy = decodePart(part, SabrContextSendingPolicy);
if (!sabrContextSendingPolicy) break;
for (var i = 0; i < sabrContextSendingPolicy.startPolicy.length; i++) {
var startPolicy = sabrContextSendingPolicy.startPolicy[i];
if (!currentState.sabrStreamState.activeSabrContextTypes.has(startPolicy)) {
currentState.sabrStreamState.activeSabrContextTypes.add(startPolicy);
}
}
for (var j = 0; j < sabrContextSendingPolicy.stopPolicy.length; j++) {
var stopPolicy = sabrContextSendingPolicy.stopPolicy[j];
if (currentState.sabrStreamState.activeSabrContextTypes.has(stopPolicy)) {
currentState.sabrStreamState.activeSabrContextTypes.delete(stopPolicy);
}
}
for (var m = 0; m < sabrContextSendingPolicy.discardPolicy.length; m++) {
var discardPolicy = sabrContextSendingPolicy.discardPolicy[m];
if (currentState.sabrStreamState.sabrContexts.has(discardPolicy)) {
currentState.sabrStreamState.sabrContexts.delete(discardPolicy);
}
}
break;
}
case UMPPartId.RELOAD_PLAYER_RESPONSE: {
var reloadPlaybackContext = decodePart(part, ReloadPlaybackContext);
if (!reloadPlaybackContext) break;
currentState.sabrStreamState.playerReloadRequested = true;
if (!currentState.abortController.signal.aborted) {
currentState.abortController.abort();
currentState.eventEmitter.emit('reload');
}
break;
}
default: {
break;
}
}
});
if (!currentState.abortStatus.finished) {
if (remainingData) {
chunkedDataBuffer = remainingData.data;
} else {
chunkedDataBuffer = null;
}
readObj = await reader.read();
}
}
} catch (err) {
if (currentState.abortStatus.cancelled) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
} else if (currentState.abortStatus.timedOut) {
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
} else if (!currentState.abortStatus.finished) {
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, err, operationInputs.requestType);
}
}
if (currentState.abortStatus.cancelled) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
} else if (currentState.abortStatus.timedOut) {
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
}
if (responseDataChunks.length > 0 && segmentComplete) {
var concatenateChunks = utils.concatenateChunks;
var data = concatenateChunks(responseDataChunks);
if (operationInputs.isInit) {
currentState.initDataCache.set(operationInputs.formatIdString, data);
}
return {
uri: operationInputs.uri,
originalUri: operationInputs.uri,
data: data,
status: response.status,
headers: {},
fromCache: false,
originalRequest: operationInputs.request
};
} else if (shouldRetry) {
if (shouldRetryDueToNextRequestPolicy) {
currentState.cumulativeRetryDueToNextRequestPolicy += 1;
}
var prepared = prepareSabrContexts(currentState.sabrStreamState);
currentState.abrRequest.streamerContext.sabrContexts = prepared.sabrContexts;
currentState.abrRequest.streamerContext.unsentSabrContexts = prepared.unsentSabrContexts;
var body;
try {
body = VideoPlaybackAbrRequest.encode(currentState.abrRequest).finish();
} catch (e) {
console.error('Invalid VideoPlaybackAbrRequest data', currentState.abrRequest);
throw e;
}
currentState.requestInit = {
body: body,
method: 'POST',
headers: {
'content-type': 'application/x-protobuf',
'accept-encoding': 'identity',
'accept': 'application/vnd.yt-ump'
},
signal: currentState.abortController.signal
};
currentState.abortStatus.timedOut = false;
currentState.abortStatus.finished = false;
return doRequest(operationInputs, currentState);
} else if (invalidPoToken) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.NETWORK,
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Invalid PO token'),
operationInputs.requestType
);
} else if (error) {
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, new Error(error), operationInputs.requestType);
} else if (responseDataChunks.length > 0 && !segmentComplete) {
throw createRecoverableNetworkError(
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Incomplete segment, missing MEDIA_END part'),
operationInputs.requestType
);
} else if (response.status === 200) {
throw createRecoverableNetworkError(
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Empty response, this should not happen'),
operationInputs.requestType
);
} else {
var severity = response.status === 401 || response.status === 403
? ShakaError.Severity.CRITICAL
: ShakaError.Severity.RECOVERABLE;
throw new ShakaError(
severity,
ShakaError.Category.NETWORK,
ShakaError.Code.BAD_HTTP_STATUS,
operationInputs.uri,
response.status,
'',
{},
operationInputs.requestType,
operationInputs.uri
);
}
}
function setupSabrScheme(sabrData, getPlayer, getManifest, getWidth, getHeight) {
var ShakaAbortableOperation = shaka().util.AbortableOperation;
var ShakaError = shaka().util.Error;
var protos = gv().protos;
var utils = gv().utils;
var base64ToU8 = utils.base64ToU8;
var VideoPlaybackAbrRequest = protos.VideoPlaybackAbrRequest;
var PlaybackCookie = protos.PlaybackCookie;
var EventEmitterLike = utils.EventEmitterLike;
var eventEmitter = new EventEmitterLike();
var initDataCache = new Map();
var poToken = base64ToU8(sabrData.poToken);
var videoPlaybackUstreamerConfig = base64ToU8(sabrData.ustreamerConfig);
var clientInfo = deepCopy(sabrData.clientInfo);
var sabrStreamState = {
sabrUrl: sabrData.url,
activeSabrContextTypes: new Set(),
sabrContexts: new Map(),
nextRequestPolicy: undefined,
playerReloadRequested: false,
requestNumber: 0
};
shaka().net.NetworkingEngine.registerScheme('sabr', function (uri, request, requestType, _progressUpdated, headersReceived, _config) {
var player = getPlayer();
if (player == null) {
return new ShakaAbortableOperation(Promise.resolve());
}
var isAudioOnly = player.isAudioOnly();
var url = new URL(request.uris[0]);
var isInit = url.searchParams.has('init');
var formatIdString = url.searchParams.get('formatId');
if (isInit && initDataCache.has(formatIdString)) {
return createCacheResponse(ShakaAbortableOperation, uri, request, initDataCache.get(formatIdString));
}
var variantTracks = player.getVariantTracks();
var activeVariant = null;
for (var i = 0; i < variantTracks.length; i++) {
if (variantTracks[i].active) { activeVariant = variantTracks[i]; break; }
}
var streamIsAudio = url.pathname === 'audio';
var streamIsVideo = url.pathname === 'video';
var audioFormatId;
var videoFormatId;
if (streamIsAudio) {
audioFormatId = formatIdFromString(formatIdString);
if (isAudioOnly) {
videoFormatId = formatIdFromString(url.searchParams.get('videoFormatId'));
} else {
videoFormatId = formatIdFromString((activeVariant || variantTracks[0]).originalVideoId);
}
} else if (streamIsVideo) {
videoFormatId = formatIdFromString(formatIdString);
if (activeVariant) {
audioFormatId = formatIdFromString(activeVariant.originalAudioId);
} else {
var candidates = variantTracks.filter(function (track) {
return track.audioRoles.indexOf('main') !== -1;
});
var probableAudioFormat = candidates.reduce(function (previous, current) {
return current.audioBandwidth >= previous.audioBandwidth ? current : previous;
}, candidates[0]);
audioFormatId = formatIdFromString(probableAudioFormat.originalAudioId);
}
}
var bufferedRanges = [];
if (!isInit && activeVariant) {
fillBufferedRanges(player, getManifest(), isAudioOnly, streamIsVideo, streamIsAudio, bufferedRanges, activeVariant);
}
var playerTimeMs = '0';
if (url.searchParams.has('startTimeMs')) {
playerTimeMs = url.searchParams.get('startTimeMs');
}
var drcEnabled = url.searchParams.has('drc') || !!(activeVariant && activeVariant.audioRoles.indexOf('drc') !== -1);
var enableVoiceBoost = url.searchParams.has('vb') || !!(activeVariant && activeVariant.audioRoles.indexOf('vb') !== -1);
var resolution = streamIsVideo ? parseInt(url.searchParams.get('resolution'), 10) : undefined;
var prepared = prepareSabrContexts(sabrStreamState);
var requestData = {
clientAbrState: {
bandwidthEstimate: String(Math.round(player.getStats().estimatedBandwidth)),
timeSinceLastManualFormatSelectionMs: streamIsVideo ? '0' : undefined,
stickyResolution: resolution,
lastManualSelectedResolution: resolution,
playbackRate: player.getPlaybackRate(),
enabledTrackTypesBitfield: streamIsAudio ? 1 : 0,
drcEnabled: drcEnabled,
enableVoiceBoost: enableVoiceBoost,
playerTimeMs: playerTimeMs,
clientViewportWidth: getWidth(),
clientViewportHeight: getHeight(),
clientViewportIsFlexible: false
},
preferredAudioFormatIds: [audioFormatId],
preferredVideoFormatIds: [videoFormatId],
preferredSubtitleFormatIds: [],
selectedFormatIds: isInit ? [] : [audioFormatId, videoFormatId],
bufferedRanges: bufferedRanges,
streamerContext: {
poToken: poToken,
clientInfo: clientInfo,
sabrContexts: prepared.sabrContexts,
unsentSabrContexts: prepared.unsentSabrContexts,
playbackCookie: sabrStreamState.nextRequestPolicy?.playbackCookie ? PlaybackCookie.encode(sabrStreamState.nextRequestPolicy.playbackCookie).finish() : undefined
},
field1000: [],
videoPlaybackUstreamerConfig: videoPlaybackUstreamerConfig
};
var body;
try {
body = VideoPlaybackAbrRequest.encode(requestData).finish();
} catch (e) {
console.error('Invalid VideoPlaybackAbrRequest data', requestData);
throw e;
}
var sequenceNumber = parseInt(url.searchParams.get('sq'), 10);
var opInputs = {
uri: uri,
request: request,
requestType: requestType,
headersReceived: headersReceived,
formatIdString: formatIdString,
isInit: isInit,
sequenceNumber: isNaN(sequenceNumber) ? undefined : sequenceNumber
};
var abortController = new AbortController();
var init = {
body: body,
method: 'POST',
headers: {
'content-type': 'application/x-protobuf',
'accept-encoding': 'identity',
'accept': 'application/vnd.yt-ump'
},
signal: abortController.signal
};
var abortStatus = { cancelled: false, timedOut: false, finished: false };
var timeoutMs = request.retryParameters.timeout;
var timeoutController = null;
if (timeoutMs) {
timeoutController = createTimeoutController(function () {
abortStatus.timedOut = true;
abortController.abort();
}, timeoutMs);
}
var currentState = {
initDataCache: initDataCache,
abrRequest: requestData,
requestInit: init,
abortStatus: abortStatus,
abortController: abortController,
sabrStreamState: sabrStreamState,
timeoutController: timeoutController,
eventEmitter: eventEmitter,
cumulativeBackOffTimeMs: 0,
cumulativeBackOffRequested: 0,
cumulativeRetryDueToNextRequestPolicy: 0
};
var pendingRequest = doRequest(opInputs, currentState);
var op = new ShakaAbortableOperation(pendingRequest, function () {
abortStatus.cancelled = true;
abortController.abort();
return Promise.resolve();
});
if (timeoutController) {
op.finally(function () {
timeoutController.clearTimeout();
});
}
return op;
});
function cleanup() {
shaka().net.NetworkingEngine.unregisterScheme('sabr');
initDataCache.clear();
}
return {
onBackoffRequested: function (callback) {
eventEmitter.on('backoff-requested', callback);
},
onReloadOnce: function (callback) {
eventEmitter.once('reload', callback);
},
cleanup: cleanup
};
}
window.setupSabrScheme = setupSabrScheme;
})();

View File

@ -0,0 +1,194 @@
// Port of FreeTube's WebmSegmentIndexParser.js
// Based on shaka-player's dash/webm_segment_index_parser.js
// Reads shaka from window.shaka (loaded via <script>) and EbmlParser from window.EbmlParser.
'use strict';
(function () {
var shaka = window.shaka;
var ShakaError = shaka.util.Error;
var SeverityCritical = ShakaError.Severity.CRITICAL;
var CategoryMedia = ShakaError.Category.MEDIA;
var EBML_ID = 0x1a45dfa3;
var SEGMENT_ID = 0x18538067;
var INFO_ID = 0x1549a966;
var TIMECODE_SCALE_ID = 0x2ad7b1;
var DURATION_ID = 0x4489;
var CUES_ID = 0x1c53bb6b;
var CUE_POINT_ID = 0xbb;
var CUE_TIME_ID = 0xb3;
var CUE_TRACK_POSITIONS_ID = 0xb7;
var CUE_CLUSTER_POSITION = 0xf1;
function parseInfo(infoElement) {
var parser = infoElement.createParser();
var timecodeScaleNanoseconds = 1000000;
var durationScale = null;
while (parser.hasMoreData()) {
var elem = parser.parseElement();
if (elem.id === TIMECODE_SCALE_ID) {
timecodeScaleNanoseconds = elem.getUint();
} else if (elem.id === DURATION_ID) {
durationScale = elem.getFloat();
}
}
if (durationScale == null) {
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_DURATION_ELEMENT_MISSING);
}
var timecodeScale = timecodeScaleNanoseconds / 1000000000;
var durationSeconds = durationScale * timecodeScale;
return { timecodeScale: timecodeScale, duration: durationSeconds };
}
function parseSegment(segmentElement) {
var parser = segmentElement.createParser();
var infoElement = null;
while (parser.hasMoreData()) {
var elem = parser.parseElement();
if (elem.id !== INFO_ID) continue;
infoElement = elem;
break;
}
if (!infoElement) {
console.error('[parseWebmSegmentIndex] Not an Info element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_INFO_ELEMENT_MISSING);
}
return parseInfo(infoElement);
}
function parseWebmContainer(initData) {
var parser = new EbmlParser(initData);
var ebmlElement = parser.parseElement();
if (ebmlElement.id !== EBML_ID) {
console.error('Not an EBML element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_EBML_HEADER_ELEMENT_MISSING);
}
var segmentElement = parser.parseElement();
if (segmentElement.id !== SEGMENT_ID) {
console.error('[parseWebmSegmentIndex] Not a Segment element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_SEGMENT_ELEMENT_MISSING);
}
var segmentOffset = segmentElement.getOffset();
var segmentInfo = parseSegment(segmentElement);
return {
segmentOffset: segmentOffset,
timecodeScale: segmentInfo.timecodeScale,
duration: segmentInfo.duration
};
}
function parseCuePoint(cuePointElement) {
var parser = cuePointElement.createParser();
var cueTimeElement = parser.parseElement();
if (cueTimeElement.id !== CUE_TIME_ID) {
console.warn('[parseWebmSegmentIndex] Not a CueTime element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_CUE_TIME_ELEMENT_MISSING);
}
var unscaledTime = cueTimeElement.getUint();
var cueTrackPositionsElement = parser.parseElement();
if (cueTrackPositionsElement.id !== CUE_TRACK_POSITIONS_ID) {
console.warn('[parseWebmSegmentIndex] Not a CueTrackPositions element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_CUE_TRACK_POSITIONS_ELEMENT_MISSING);
}
var cueTrackParser = cueTrackPositionsElement.createParser();
var relativeOffset = 0;
while (cueTrackParser.hasMoreData()) {
var elem = cueTrackParser.parseElement();
if (elem.id !== CUE_CLUSTER_POSITION) continue;
relativeOffset = elem.getUint();
break;
}
return { unscaledTime: unscaledTime, relativeOffset: relativeOffset };
}
function parseCues(cuesElement, segmentOffset, timecodeScale, duration, uri, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd) {
var references = [];
var parser = cuesElement.createParser();
var lastTime = null;
var lastOffset = null;
var sq = 1;
while (parser.hasMoreData()) {
var elem = parser.parseElement();
if (elem.id !== CUE_POINT_ID) continue;
var tuple = parseCuePoint(elem);
if (!tuple) continue;
var currentTime = timecodeScale * tuple.unscaledTime;
var currentOffset = segmentOffset + tuple.relativeOffset;
if (lastTime != null) {
// NOTE: must be `let` (block-scoped). With `var` the closure below
// captures the function-scoped binding, so every SegmentReference would
// return the LAST segment's URL (wrong startTimeMs/sq -> SABR serves no media).
let uris1 = [uri + '&startTimeMs=' + Math.round((lastTime + timestampOffset) * 1000) + '&sq=' + (sq++)];
references.push(
new shaka.media.SegmentReference(
lastTime + timestampOffset,
currentTime + timestampOffset,
function () { return uris1; },
lastOffset,
currentOffset - 1,
initSegmentReference,
timestampOffset,
appendWindowStart,
appendWindowEnd
)
);
}
lastTime = currentTime;
lastOffset = currentOffset;
}
if (lastTime != null) {
var uris2 = [uri + '&startTimeMs=' + Math.round((lastTime + timestampOffset) * 1000) + '&sq=' + sq];
references.push(
new shaka.media.SegmentReference(
lastTime + timestampOffset,
duration + timestampOffset,
function () { return uris2; },
lastOffset,
null,
initSegmentReference,
timestampOffset,
appendWindowStart,
appendWindowEnd
)
);
}
return references;
}
function parseWebmSegmentIndex(cuesData, initData, uri, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd) {
var tuple = parseWebmContainer(initData);
var parser = new EbmlParser(cuesData);
var cuesElement = parser.parseElement();
if (cuesElement.id !== CUES_ID) {
console.error('[parseWebmSegmentIndex] Not a Cues element.');
throw new ShakaError(SeverityCritical, CategoryMedia, ShakaError.Code.WEBM_CUES_ELEMENT_MISSING);
}
return parseCues(
cuesElement,
tuple.segmentOffset,
tuple.timecodeScale,
tuple.duration,
uri,
initSegmentReference,
timestampOffset,
appendWindowStart,
appendWindowEnd
);
}
window.parseWebmSegmentIndex = parseWebmSegmentIndex;
})();

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",
@ -508,5 +513,6 @@
"timeline_parse_error_placeholder_heading": "Unable to parse item",
"timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:",
"timeline_parse_error_show_technical_details": "Show technical details",
"dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator."
"dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator.",
"error_from_youtube_unplayable": "Video unplayable due to an error from YouTube:"
}

2
mocks

@ -1 +1 @@
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872
Subproject commit 562192243e0348bd53bb85a5bf6d404109b21da0

558
package-lock.json generated Normal file
View File

@ -0,0 +1,558 @@
{
"name": "invidious-sabr",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "invidious-sabr",
"dependencies": {
"bgutils-js": "^3.2.0",
"googlevideo": "^4.0.4",
"youtubei.js": "^17.0.1"
},
"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/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"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": "17.2.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.2.0.tgz",
"integrity": "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"fflate": "^0.8.2",
"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": "^17.0.1",
"bgutils-js": "^3.2.0"
},
"devDependencies": {
"esbuild": "^0.27.2"
}
}

View File

@ -0,0 +1,85 @@
#!/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 exposes the googlevideo namespaces the
// SABR scheme plugin / manifest parser need (ump, utils, protos) as a single
// `googlevideo` object, so sabr_loader.js can do window.googlevideo = googlevideo.
// sabr-streaming-adapter is no longer used (we ported FreeTube's sabr: scheme plugin).
const googlevideoEntryContent = `
import * as utils from 'googlevideo/utils';
import * as ump from 'googlevideo/ump';
import * as protos from 'googlevideo/protos';
export const googlevideo = { utils, ump, protos };
// Also re-export flat for convenience / debugging on window.
export { utils, ump, protos };
`;
const googlevideoEntryPath = path.join(__dirname, 'temp-googlevideo-entry.js');
fs.writeFileSync(googlevideoEntryPath, googlevideoEntryContent);
const commonEsbuildOptions = {
bundle: true,
format: 'esm',
minify: true,
sourcemap: false,
external: [],
nodePaths: [nodeModules],
platform: 'browser',
define: {
'process.env.NODE_ENV': '"production"',
'process.env.SUPPORTS_LOCAL_API': 'true'
}
};
// Bundle googlevideo with all exports
esbuild.build({
...commonEsbuildOptions,
entryPoints: [googlevideoEntryPath],
outfile: path.join(outputDir, 'googlevideo/googlevideo.bundle.min.js'),
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);
});
// youtubei.js is fetched as a pre-built browser bundle from jsDelivr by
// scripts/fetch-sabr-dependencies.cr (not bundled here), to avoid re-bundling
// a large lib and clobbering the pre-built artifact.
// Bundle bgutils-js
esbuild.build({
...commonEsbuildOptions,
entryPoints: [path.join(nodeModules, 'bgutils-js/dist/index.js')],
outfile: path.join(outputDir, 'bgutils-js/bgutils.bundle.min.js'),
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,130 @@
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" => "5.1.10",
"files" => [
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/controls.css", "dest" => "controls.css"},
]
},
# googlevideo + bgutils-js are bundled locally from npm via scripts/bundle-sabr-libs.js
# (no esm.sh fetch). youtubei.js is also bundled locally from npm, but we keep a
# pre-built jsDelivr copy as the esbuild entry for reproducibility.
"youtubei.js" => {
"version" => "17.0.1",
"files" => [
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@17.0.1/bundle/browser.js", "dest" => "youtubei.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
# Note: googlevideo and bgutils-js are now bundled locally from npm via
# scripts/bundle-sabr-libs.js (esbuild, platform: 'browser', with
# process.env.NODE_ENV defined to 'production'). No esm.sh fetch or
# post-patching is required.

View File

@ -163,4 +163,61 @@ Spectator.describe "parse_video_info" do
expect(info["authorVerified"].as_bool).to be_false
expect(info["subCountText"].as_s).to eq("3.11K")
end
it "parses a video without videoDetails (fallback to /next data)" do
_player = load_mock("video/no-videodetails.player")
_next = load_mock("video/regular_no-description.next")
raw_data = _player.merge!(_next)
info = Invidious::Videos::Parser.parse_video_info("no-videodetails-id", raw_data)
expect(typeof(info)).to eq(Hash(String, JSON::Any))
# Title falls back to videoPrimaryInfoRenderer
expect(info["title"].as_s).to eq("Chris Rea - Auberge")
# Views from videoPrimaryInfoRenderer
expect(info["views"].as_i).to eq(14_324_584)
# Likes still work (from videoPrimaryInfoRenderer buttons)
expect(info["likes"].as_i).to eq(35_870)
# Length from microformat
expect(info["lengthSeconds"].as_i).to eq(283_i64)
# Published from microformat, or dateText fallback
expect(info["published"].as_s).to eq("2012-05-21T00:00:00Z")
# Author from videoOwnerRenderer
expect(info["author"].as_s).to eq("ChrisReaVideos")
expect(info["ucid"].as_s).to eq("UC_5q6nWPbD30-y6oiWF_oNA")
# Family friendly defaults to true when missing
expect(info["isFamilyFriendly"].as_bool).to be_true
# isListed defaults to true when no badges
expect(info["isListed"].as_bool).to be_true
# Description
expect(info["descriptionHtml"].as_s).to eq("")
# Related videos still work from /next
expect(info["relatedVideos"].as_a.size).to eq(20)
end
end
Spectator.describe "Video error accessors" do
it "returns nil for a subreason stored as JSON null" do
# Some error paths store `subreason` as a JSON `null`; the accessor must
# not raise a TypeCastError on it.
info = {
"reason" => JSON::Any.new("Video unavailable"),
"subreason" => JSON::Any.new(nil),
} of String => JSON::Any
video = Video.new({id: "dQw4w9WgXcQ", info: info, updated: Time.utc})
expect(video.reason).to eq("Video unavailable")
expect(video.subreason).to be_nil
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

@ -43,13 +43,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' " + COMPANION_CSP.companion_urls,
"connect-src 'self' https://*.googleapis.com https://*.youtube.com " + COMPANION_CSP.companion_urls,
"manifest-src 'self'",
"media-src 'self' blob: " + COMPANION_CSP.companion_urls,
"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
@ -170,6 +173,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,243 @@
# 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
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
end
end
rescue ex
# Ignore malformed headers
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
custom_headers.each do |key, values|
value = values.is_a?(Array) ? values.first : values.to_s
request_headers[key] = value
end
# 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
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"]
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"
end
if !request_headers["Referer"]?
request_headers["Referer"] = "https://www.youtube.com/"
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"
end
# The SABR scheme plugin reads UMP parts incrementally from the response
# stream, so make sure the upstream sends us raw bytes (no gzip/deflate).
if !request_headers["Accept-Encoding"]?
request_headers["Accept-Encoding"] = "identity"
end
# Copy authorization if present
if auth = env.request.headers["Authorization"]?
request_headers["Authorization"] = auth
end
# Make the proxied request, streaming the upstream body straight to the
# client instead of buffering response.body. This is required so the SABR
# scheme plugin can read UMP parts incrementally and abort a fetch
# mid-stream (backoff / reload / seek).
begin
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
client.connect_timeout = 10.seconds
client.read_timeout = 30.seconds
# Don't let HTTP::Client advertise its own Accept-Encoding; we forward the
# plugin's identity header and stream raw bytes.
client.compress = false
method = env.request.method
case method
when "GET", "POST"
# Build a raw request so we control streaming + binary POST body.
request = HTTP::Request.new(method, target_url.request_target, request_headers)
if method == "POST"
body_io = env.request.body
body_bytes : Bytes? = nil
if body_io
body_bytes = body_io.getb_to_end
end
if body_bytes
request.body = body_bytes
request.content_length = body_bytes.size
end
end
client.exec(request) do |response|
env.response.status_code = response.status_code
CONTENT_HEADERS.each do |header|
if value = response.headers[header]?
env.response.headers[header] = value
end
end
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"
# Stream the upstream body through without buffering, so the client's
# UmpReader can consume parts as they arrive and so backoff/abort
# actually cancels the transfer.
begin
IO.copy(response.body_io, env.response.output)
env.response.output.flush
rescue ex : IO::Error
# Client disconnected mid-stream (seek / quality change / abort).
# This is expected, just stop copying.
end
end
else
env.response.status_code = 405
return "Method not allowed"
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

@ -130,7 +130,7 @@ module Invidious::Routes::Watch
audio_streams = video.audio_streams
# Videos that are a premiere do not have audio streams.
if video.premiere_timestamp.nil?
if video.premiere_timestamp.nil? && video.reason.nil?
# Older videos may not have audio sources available.
# We redirect here so they're not unplayable
if audio_streams.empty? && !video.live_now
@ -162,7 +162,7 @@ module Invidious::Routes::Watch
thumbnail = "/vi/#{video.id}/maxres.jpg"
if params.raw
if params.raw && video.reason.nil?
if params.listen
url = audio_streams[0]["url"].as_s

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
@ -227,6 +228,16 @@ module Invidious::Routing
get "/tvfilm_banner/:id/:name", Routes::Images, :tvfilm_banner_image
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

@ -15,7 +15,7 @@ struct Video
# NOTE: don't forget to bump this number if any change is made to
# the `params` structure in videos/parser.cr!!!
#
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
property id : String
@ -179,7 +179,13 @@ struct Video
end
def reason : String?
info["reason"]?.try &.as_s
# Use `as_s?` so a JSON `null` (stored by some error paths) yields `nil`
# instead of raising a TypeCastError.
info["reason"]?.try &.as_s?
end
def subreason : String?
info["subreason"]?.try &.as_s?
end
def music : Array(VideoMusic)
@ -325,26 +331,10 @@ rescue DB::Error
end
def fetch_video(id, region)
# `extract_video_info` always returns a hash (it raises `NotFoundException`
# for genuinely unavailable videos), so there is no nil case to handle here.
info = Invidious::Videos::Parser.extract_video_info(video_id: id)
if info.nil?
raise InfoException.new("Invidious companion is not available. \
Video playback cannot continue. \
If you are the administrator of this instance, install Invidious companion \
following the installation instructions \
<a href=\"https://docs.invidious.io/installation/\">https://docs.invidious.io/installation/</a>")
end
if reason = info["reason"]?
if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "")
elsif !reason.as_s.starts_with? "Premieres"
# dont error when it's a premiere.
# we already parsed most of the data and display the premiere date
raise InfoException.new(reason.as_s || "")
end
end
video = Video.new({
id: id,
info: info,

View File

@ -55,30 +55,73 @@ module Invidious::Videos::Parser
}
end
# Builds the minimal "error" info hash returned when a video can't be
# played but we still want to render the watch page. `subreason` is only
# included when present, so it never gets stored as a JSON `null`.
private def self.error_video_info(reason : String?, subreason : String?) : Hash(String, JSON::Any)
params = {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
} of String => JSON::Any
params["reason"] = JSON::Any.new(reason) if reason
params["subreason"] = JSON::Any.new(subreason) if subreason
params
end
def extract_video_info(video_id : String)
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id)
player_response = nil
begin
player_response = YoutubeAPI.player(video_id: video_id)
rescue ex
LOGGER.debug("extract_video_info: player endpoint failed for #{video_id}: #{ex.message}")
end
if player_response.nil?
return nil
# Player endpoint failed (e.g. no companion). Try to get data from /next only.
begin
player_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
rescue ex
LOGGER.debug("extract_video_info: /next also failed for #{video_id}: #{ex.message}")
raise NotFoundException.new("Video unavailable")
end
begin
params = self.parse_video_info(video_id, player_response)
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
# We have metadata from /next but no server-side streaming data (no
# companion). Don't treat this as a hard error: the watch page still
# renders, and for quality=sabr the browser SABR player fetches the
# stream itself via Innertube + browser-side PoToken. An informational
# subreason is surfaced for non-SABR playback (which needs companion or
# the local streams proxy).
return params
rescue ex
LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}")
raise NotFoundException.new("Video unavailable")
end
end
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
if playability_status != "OK"
subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
reason = subreason.try &.[]?("simpleText").try &.as_s
reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("")
reason ||= player_response.dig("playabilityStatus", "reason").as_s
reason = player_response.dig?("playabilityStatus", "reason").try &.as_s
reason ||= player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "reason", "simpleText").try &.as_s
# Stop here if video is not a scheduled livestream or
# for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help
if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) ||
playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails")
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new(reason),
}
subreason_main = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason")
subreason = subreason_main.try &.["simpleText"]?.try &.as_s
subreason ||= subreason_main.try &.["runs"]?.try &.as_a.map(&.["text"]).join("")
# These strings come straight from YouTube and are rendered as raw HTML
# on the watch/embed pages, so escape them to avoid HTML injection.
reason = HTML.escape(reason) if reason
subreason = HTML.escape(subreason) if subreason
# Stop here if the video is genuinely unavailable (deleted, removed or
# nonexistent): YouTube returns status "ERROR" for these regardless of
# the (localized) reason text, and there is nothing to extract from the
# /next endpoint. For other statuses (UNPLAYABLE, LOGIN_REQUIRED,
# LIVE_STREAM_OFFLINE, ...) we continue and extract as much as possible.
if playability_status == "ERROR"
raise NotFoundException.new(reason || "Video unavailable")
end
elsif video_id != player_response.dig?("videoDetails", "videoId")
# YouTube may return a different video player response than expected.
@ -89,25 +132,37 @@ module Invidious::Videos::Parser
# wrong video means that we should count it as a failure.
Helpers.get_playback_statistic["totalRequests"] += 1
return {
"version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64),
"reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>"),
}
return error_video_info("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. <a href=\"https://github.com/iv-org/invidious/issues/3822\">Click here for more info about the issue.</a>", nil)
else
reason = nil
end
# Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
# Fetch the /next endpoint for additional data.
begin
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
# Remove the microformat returned by the /next endpoint on some videos
# to prevent player_response microformat from being overwritten.
next_response.delete("microformat")
player_response = player_response.merge(next_response)
rescue ex
# If we're in an error state and /next fails, return what we have
if reason
LOGGER.debug("extract_video_info: /next failed for #{video_id}: #{ex.message}")
return error_video_info(reason, subreason)
end
raise ex
end
params = self.parse_video_info(video_id, player_response)
begin
params = self.parse_video_info(video_id, player_response)
rescue ex : BrokenTubeException
if reason
return error_video_info(reason, subreason)
end
raise ex
end
params["reason"] = JSON::Any.new(reason) if reason
params["subreason"] = JSON::Any.new(subreason) if subreason
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]?
@ -177,16 +232,20 @@ module Invidious::Videos::Parser
end
video_details = player_response.dig?("videoDetails")
video_details ||= {} of String => JSON::Any
if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer"))
microformat = {} of String => JSON::Any
end
raise BrokenTubeException.new("videoDetails") if !video_details
# Basic video infos
title = video_details["title"]?.try &.as_s
title ||= extract_text(
video_primary_renderer
.try &.dig?("title")
)
# We have to try to extract viewCount from videoPrimaryInfoRenderer first,
# then from videoDetails, as the latter is "0" for livestreams (we want
# to get the amount of viewers watching).
@ -197,11 +256,31 @@ module Invidious::Videos::Parser
views_txt ||= video_details["viewCount"]?.try &.as_s || ""
views = views_txt.gsub(/\D/, "").to_i64?
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"])
length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]?)
.try &.as_s.to_i64
published = microformat["publishDate"]?
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc
.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) }
if published.nil?
# Fall back to the human-readable date shown on the watch page (e.g.
# "Started streaming 3 hours ago" or "Premiered Jan 1, 2024").
published_txt = video_primary_renderer
.try &.dig?("dateText", "simpleText").try &.as_s
date_regex = /(\w{3} \d{1,2}, \d{4})$/
published =
if published_txt.nil?
Time.utc
elsif published_txt.includes?("ago")
decode_date(published_txt.lchop("Started streaming "))
elsif match = published_txt.match(date_regex)
Time.parse(match[0], "%b %-d, %Y", Time::Location::UTC)
else
Time.utc
end
end
premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp")
.try { |t| Time.parse_rfc3339(t.as_s) }
@ -216,7 +295,9 @@ module Invidious::Videos::Parser
live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow")
.try &.as_bool
live_now ||= video_details.dig?("isLive").try &.as_bool || false
live_now = video_details.dig?("isLive").try &.as_bool if live_now.nil?
live_now = video_primary_renderer.try &.dig?("viewCount", "videoViewCountRenderer", "isLive").try &.as_bool if live_now.nil?
live_now ||= false
post_live_dvr = video_details.dig?("isPostLiveDvr")
.try &.as_bool || false
@ -227,8 +308,19 @@ module Invidious::Videos::Parser
.try &.as_a.map &.as_s || [] of String
allow_ratings = video_details["allowRatings"]?.try &.as_bool
# When the microformat is missing (e.g. /next-only data) we can't know for
# sure, so optimistically assume the video is family safe.
family_friendly = microformat["isFamilySafe"]?.try &.as_bool
family_friendly = true if family_friendly.nil?
is_listed = video_details["isCrawlable"]?.try &.as_bool
if is_listed.nil?
# Without videoDetails, infer from the "Unlisted" badge if present.
if video_badges = video_primary_renderer.try &.dig?("badges")
is_listed = !has_unlisted_badge?(video_badges)
else
is_listed = true
end
end
is_upcoming = video_details["isUpcoming"]?.try &.as_bool
keywords = video_details["keywords"]?
@ -394,6 +486,9 @@ module Invidious::Videos::Parser
subs_text = author_info["subscriberCountText"]?
.try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }
.try &.as_s.split(" ", 2)[0]
author ||= extract_text(author_info.dig?("title"))
ucid ||= author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s
end
# Return data
@ -428,7 +523,7 @@ module Invidious::Videos::Parser
# Description
"description" => JSON::Any.new(description || ""),
"descriptionHtml" => JSON::Any.new(description_html || "<p></p>"),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || nil),
"shortDescription" => JSON::Any.new(short_description.try &.as_s || ""),
# Video metadata
"genre" => JSON::Any.new(genre.try &.as_s || ""),
"genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?),

View File

@ -17,6 +17,7 @@ module Invidious::Videos
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
@ -43,6 +44,7 @@ module Invidious::Videos
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?
@ -66,6 +68,7 @@ module Invidious::Videos
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
@ -87,6 +90,7 @@ module Invidious::Videos
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
@ -148,6 +152,7 @@ module Invidious::Videos
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,33 @@
"title" => video.title,
"description" => HTML.escape(video.short_description),
"thumbnail" => thumbnail,
"preferred_caption_found" => !preferred_captions.empty?
"preferred_caption_found" => !preferred_captions.empty?,
"use_sabr" => use_sabr
}.to_pretty_json
%>
</script>
<script src="/js/player.js?v=<%= ASSET_COMMIT %>"></script>
<% if use_sabr %>
<!-- SABR Player dependencies -->
<link rel="preconnect" href="https://redirector.googlevideo.com">
<link rel="preconnect" href="https://www.youtube.com">
<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 defer src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
<!-- SABR engine ports (FreeTube): plain scripts reading shaka/googlevideo from window globals -->
<script defer src="/js/sabr_ebml_parser.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_mp4_index.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_webm_index.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_manifest_parser.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_scheme_plugin.js?v=<%= ASSET_COMMIT %>"></script>
<!-- SABR orchestrator + helpers -->
<script defer src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_onesie.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
<script defer src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
<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

@ -31,7 +31,15 @@
%>
</script>
<% if video.reason.nil? %>
<%= rendered "components/player" %>
<% else %>
<p>
<%= I18n.translate(preferences.locale, "error_from_youtube_unplayable") %>
<strong><%= video.reason %></strong>
<% if video.subreason %><br/><%= video.subreason %><% end %>
</p>
<% end %>
<script src="/js/embed.js?v=<%= ASSET_COMMIT %>"></script>
</body>
</html>

View File

@ -54,7 +54,7 @@
<div class="pure-control-group">
<label for="quality"><%= I18n.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 %>><%= I18n.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"><%= I18n.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 %>><%= I18n.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"><%= I18n.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 %>">

View File

@ -71,11 +71,22 @@ we're going to need to do it here in order to allow for translations.
%>
</script>
<% if video.reason.nil? %>
<div id="player-container" class="h-box">
<%= rendered "components/player" %>
</div>
<% end %>
<div class="h-box">
<% if video.reason %>
<h3>
<%= I18n.translate(locale, "error_from_youtube_unplayable") %> <%= video.reason %>
</h3>
<% if video.subreason %>
<p><%= video.subreason %></p>
<% end %>
<%= error_redirect_helper(env) %>
<% end %>
<h1>
<%= title %>
<% if params.listen %>
@ -95,11 +106,7 @@ we're going to need to do it here in order to allow for translations.
</h3>
<% end %>
<% if video.reason %>
<h3>
<%= video.reason %>
</h3>
<% elsif video.premiere_timestamp.try &.> Time.utc %>
<% if video.premiere_timestamp.try &.> Time.utc %>
<h3>
<%= video.premiere_timestamp.try { |t| I18n.translate(locale, "Premieres in `x`", recode_date((t - Time.utc).ago, locale)) } %>
</h3>

View File

@ -68,6 +68,12 @@ rescue ex
return false
end
def has_unlisted_badge?(badges : JSON::Any?)
return badges.try &.as_a.any? { |badge|
badge.dig?("metadataBadgeRenderer", "icon", "iconType").try &.as_s == "PRIVACY_UNLISTED"
} || false
end
# This function extracts SearchVideo items from a Category.
# Categories are commonly returned in search results and trending pages.
def extract_category(category : Category) : Array(SearchVideo)

View File

@ -466,6 +466,8 @@ module YoutubeAPI
if CONFIG.invidious_companion.present?
return self._post_invidious_companion("/youtubei/v1/player", data)
else
# Without Invidious Companion the player endpoint is unavailable.
# Returning nil lets the caller fall back to the /next endpoint.
return nil
end
end