mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-27 16:06:47 -05:00
Merge 105a69d9eaf0a8d2fb7a6f4a99af43985119c4e8 into 5510657e806d06ff8de3692d5804211a00570cef
This commit is contained in:
commit
c5ade7c532
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@
|
||||
/invidious
|
||||
/sentry
|
||||
/config/config.yml
|
||||
node_modules/
|
||||
252
assets/css/sabr_player.css
Normal file
252
assets/css/sabr_player.css
Normal 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;
|
||||
}
|
||||
2
assets/js/sabr/bgutils-js/bgutils.bundle.min.js
vendored
Normal file
2
assets/js/sabr/bgutils-js/bgutils.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/js/sabr/bgutils-js/versions.yml
Normal file
2
assets/js/sabr/bgutils-js/versions.yml
Normal file
@ -0,0 +1,2 @@
|
||||
---
|
||||
version: 3.2.0
|
||||
2
assets/js/sabr/googlevideo/googlevideo.bundle.min.js
vendored
Normal file
2
assets/js/sabr/googlevideo/googlevideo.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/js/sabr/googlevideo/versions.yml
Normal file
2
assets/js/sabr/googlevideo/versions.yml
Normal file
@ -0,0 +1,2 @@
|
||||
---
|
||||
version: 4.0.4
|
||||
30
assets/js/sabr/shaka-player/controls.css
Normal file
30
assets/js/sabr/shaka-player/controls.css
Normal file
File diff suppressed because one or more lines are too long
2231
assets/js/sabr/shaka-player/shaka-player.ui.js
Normal file
2231
assets/js/sabr/shaka-player/shaka-player.ui.js
Normal file
File diff suppressed because it is too large
Load Diff
2
assets/js/sabr/shaka-player/versions.yml
Normal file
2
assets/js/sabr/shaka-player/versions.yml
Normal file
@ -0,0 +1,2 @@
|
||||
---
|
||||
version: 5.1.10
|
||||
2
assets/js/sabr/youtubei.js/versions.yml
Normal file
2
assets/js/sabr/youtubei.js/versions.yml
Normal file
@ -0,0 +1,2 @@
|
||||
---
|
||||
version: 17.0.1
|
||||
81
assets/js/sabr/youtubei.js/youtubei.bundle.min.js
vendored
Normal file
81
assets/js/sabr/youtubei.js/youtubei.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
159
assets/js/sabr_ebml_parser.js
Normal file
159
assets/js/sabr_ebml_parser.js
Normal 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
389
assets/js/sabr_helpers.js
Normal 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
41
assets/js/sabr_init.js
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* SABR Init - Initialize SABR player when page loads
|
||||
* This file handles the SABR player initialization without inline scripts
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
(function() {
|
||||
// Wait for SABR libs to be loaded
|
||||
window.addEventListener('sabr-libs-loaded', async function() {
|
||||
var container = document.getElementById('sabr-player-container');
|
||||
if (!container) {
|
||||
console.error('[SABR]', 'Player container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
var videoId = container.dataset.videoId;
|
||||
var autoplay = container.dataset.autoplay === 'true';
|
||||
var videoLoop = container.dataset.videoLoop === 'true';
|
||||
var codecPref = container.dataset.qualitySabr || 'vp9';
|
||||
|
||||
try {
|
||||
var result = await SABRPlayer.loadVideo(videoId, container, {
|
||||
autoplay: autoplay,
|
||||
loop: videoLoop,
|
||||
codecPreference: codecPref
|
||||
});
|
||||
console.info('[SABR]', 'Video loaded successfully', result.videoInfo?.basic_info?.title);
|
||||
} catch (error) {
|
||||
console.error('[SABR]', 'Failed to load video:', error);
|
||||
// Show error message in container
|
||||
var errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'sabr-error-display';
|
||||
errorDiv.innerHTML = '<p>Failed to load video with SABR player.</p>' +
|
||||
'<p>' + error.message + '</p>' +
|
||||
'<p><a href="?quality=dash">Try DASH player instead</a></p>';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(errorDiv);
|
||||
}
|
||||
});
|
||||
})();
|
||||
32
assets/js/sabr_loader.js
Normal file
32
assets/js/sabr_loader.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* SABR Loader - ES module loader for SABR dependencies
|
||||
*
|
||||
* All dependencies are ES modules:
|
||||
* - youtubei.js: Provides Innertube for YouTube API access
|
||||
* - googlevideo: Provides SABR 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'));
|
||||
579
assets/js/sabr_manifest_parser.js
Normal file
579
assets/js/sabr_manifest_parser.js
Normal 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
112
assets/js/sabr_mp4_index.js
Normal 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
211
assets/js/sabr_onesie.js
Normal 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
648
assets/js/sabr_player.js
Normal 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
270
assets/js/sabr_potoken.js
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* BotGuard Service - PoToken generation for SABR streaming
|
||||
* Ported from Kira project (https://github.com/LuanRT/kira)
|
||||
*
|
||||
* This module handles:
|
||||
* - BotGuard challenge fetching and processing
|
||||
* - Integrity token generation
|
||||
* - WebPO minter creation for content-bound tokens
|
||||
* - Cold start token generation for quick fallback
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var BotguardService = (function() {
|
||||
var WAA_REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
|
||||
// Use the API key from bgutils-js/Kira which has access to Web Anti-Abuse API
|
||||
var GOOG_API_KEY = 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw';
|
||||
|
||||
var botguardClient = null;
|
||||
var initializationPromise = null;
|
||||
var integrityTokenBasedMinter = null;
|
||||
var bgChallenge = null;
|
||||
|
||||
/**
|
||||
* Build URL for BotGuard API calls (using YouTube endpoint, not googleapis.com)
|
||||
* @param {string} action - 'Create' or 'GenerateIT'
|
||||
* @param {boolean} useTrustedEnv
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildURL(action, useTrustedEnv) {
|
||||
// Use YouTube's endpoint instead of googleapis.com to avoid CORS issues
|
||||
var baseUrl = 'https://www.youtube.com/api/jnn/v1/';
|
||||
return baseUrl + action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with proxy support for CORS compliance
|
||||
* All external URLs must go through the Invidious proxy to avoid CORS issues
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function fetchWithProxy(url, options) {
|
||||
var parsedUrl = new URL(url);
|
||||
var host = parsedUrl.host;
|
||||
|
||||
// Build proxy URL with __host and __path parameters
|
||||
// We use __path instead of putting the path in the URL to avoid issues with special chars like $
|
||||
var proxyUrl = new URL('/proxy', window.location.origin);
|
||||
proxyUrl.searchParams.set('__host', host);
|
||||
proxyUrl.searchParams.set('__path', parsedUrl.pathname);
|
||||
|
||||
// Copy original query parameters
|
||||
parsedUrl.searchParams.forEach(function(value, key) {
|
||||
proxyUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
// Pass custom headers through __headers parameter
|
||||
if (options && options.headers) {
|
||||
var headersArray = [];
|
||||
for (var key in options.headers) {
|
||||
if (options.headers.hasOwnProperty(key)) {
|
||||
headersArray.push([key, options.headers[key]]);
|
||||
}
|
||||
}
|
||||
proxyUrl.searchParams.set('__headers', JSON.stringify(headersArray));
|
||||
}
|
||||
|
||||
// Make the proxied request (headers are passed through __headers param)
|
||||
return fetch(proxyUrl.toString(), {
|
||||
method: options?.method || 'GET',
|
||||
body: options?.body
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the BotGuard client
|
||||
* @returns {Promise<Object|undefined>}
|
||||
*/
|
||||
async function init() {
|
||||
if (initializationPromise) {
|
||||
return await initializationPromise;
|
||||
}
|
||||
return setup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal setup function
|
||||
* @returns {Promise<Object|undefined>}
|
||||
*/
|
||||
async function setup() {
|
||||
if (initializationPromise) {
|
||||
return await initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = _initBotguard();
|
||||
|
||||
try {
|
||||
botguardClient = await initializationPromise;
|
||||
return botguardClient;
|
||||
} finally {
|
||||
initializationPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal BotGuard initialization
|
||||
* @returns {Promise<Object|undefined>}
|
||||
*/
|
||||
async function _initBotguard() {
|
||||
// Check if BG (bgutils-js) is available
|
||||
if (typeof BG === 'undefined') {
|
||||
console.error('[BotguardService]', 'bgutils-js not loaded');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// First call (Create) uses direct fetch - no proxy needed
|
||||
var challengeResponse = await fetch(buildURL('Create', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([WAA_REQUEST_KEY])
|
||||
});
|
||||
|
||||
var challengeResponseData = await challengeResponse.json();
|
||||
bgChallenge = BG.Challenge.parseChallengeData(challengeResponseData);
|
||||
|
||||
if (!bgChallenge) {
|
||||
console.error('[BotguardService]', 'Failed to parse challenge data');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
var interpreterJavascript = bgChallenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||
|
||||
if (!interpreterJavascript) {
|
||||
console.error('[BotguardService]', 'Could not get interpreter javascript. Interpreter Hash:', bgChallenge.interpreterHash);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Inject the interpreter script if not already present
|
||||
if (!document.getElementById(bgChallenge.interpreterHash)) {
|
||||
var script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.id = bgChallenge.interpreterHash;
|
||||
script.textContent = interpreterJavascript;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Create the BotGuard client
|
||||
botguardClient = await BG.BotGuardClient.create({
|
||||
globalObj: globalThis,
|
||||
globalName: bgChallenge.globalName,
|
||||
program: bgChallenge.program
|
||||
});
|
||||
|
||||
// Generate integrity token and create WebPO minter
|
||||
if (bgChallenge) {
|
||||
var webPoSignalOutput = [];
|
||||
var botguardResponse = await botguardClient.snapshot({ webPoSignalOutput: webPoSignalOutput });
|
||||
|
||||
var integrityTokenResponse = await fetchWithProxy(buildURL('GenerateIT', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javascript/0.1'
|
||||
},
|
||||
body: JSON.stringify([WAA_REQUEST_KEY, botguardResponse])
|
||||
});
|
||||
|
||||
var integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
var integrityToken = integrityTokenResponseData[0];
|
||||
|
||||
if (!integrityToken) {
|
||||
console.error('[BotguardService]', 'Could not get integrity token. Interpreter Hash:', bgChallenge.interpreterHash);
|
||||
return botguardClient;
|
||||
}
|
||||
|
||||
integrityTokenBasedMinter = await BG.WebPoMinter.create({ integrityToken: integrityToken }, webPoSignalOutput);
|
||||
}
|
||||
|
||||
return botguardClient;
|
||||
} catch (error) {
|
||||
console.error('[BotguardService]', 'Error initializing BotGuard:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a cold start token (quick fallback)
|
||||
* @param {string} contentBinding - Content binding (usually video ID)
|
||||
* @returns {string}
|
||||
*/
|
||||
function mintColdStartToken(contentBinding) {
|
||||
if (typeof BG === 'undefined') {
|
||||
console.error('[BotguardService]', 'bgutils-js not loaded');
|
||||
return '';
|
||||
}
|
||||
return BG.PoToken.generateColdStartToken(contentBinding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if BotGuard is fully initialized
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInitialized() {
|
||||
return !!botguardClient && !!integrityTokenBasedMinter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a WebPO token for content binding
|
||||
* @param {string} contentBinding - Content binding (usually video ID)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function mintWebPoToken(contentBinding) {
|
||||
if (!integrityTokenBasedMinter) {
|
||||
throw new Error('WebPO minter not initialized');
|
||||
}
|
||||
return await integrityTokenBasedMinter.mintAsWebsafeString(contentBinding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the BotGuard client
|
||||
*/
|
||||
function dispose() {
|
||||
if (botguardClient && bgChallenge) {
|
||||
try {
|
||||
botguardClient.shutdown();
|
||||
} catch (e) {
|
||||
// Ignore shutdown errors
|
||||
}
|
||||
botguardClient = null;
|
||||
integrityTokenBasedMinter = null;
|
||||
|
||||
var script = document.getElementById(bgChallenge.interpreterHash);
|
||||
if (script) {
|
||||
script.remove();
|
||||
}
|
||||
bgChallenge = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize BotGuard
|
||||
* @returns {Promise<Object|undefined>}
|
||||
*/
|
||||
async function reinit() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
dispose();
|
||||
return setup();
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
mintColdStartToken: mintColdStartToken,
|
||||
mintWebPoToken: mintWebPoToken,
|
||||
isInitialized: isInitialized,
|
||||
dispose: dispose,
|
||||
reinit: reinit
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for use in other modules
|
||||
window.BotguardService = BotguardService;
|
||||
711
assets/js/sabr_scheme_plugin.js
Normal file
711
assets/js/sabr_scheme_plugin.js
Normal 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;
|
||||
})();
|
||||
194
assets/js/sabr_webm_index.js
Normal file
194
assets/js/sabr_webm_index.js
Normal 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;
|
||||
})();
|
||||
@ -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
2
mocks
@ -1 +1 @@
|
||||
Subproject commit b55d58dea94f7144ff0205857dfa70ec14eaa872
|
||||
Subproject commit 562192243e0348bd53bb85a5bf6d404109b21da0
|
||||
558
package-lock.json
generated
Normal file
558
package-lock.json
generated
Normal 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
16
package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "invidious-sabr",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bundle-sabr": "node scripts/bundle-sabr-libs.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"googlevideo": "^4.0.4",
|
||||
"youtubei.js": "^17.0.1",
|
||||
"bgutils-js": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.2"
|
||||
}
|
||||
}
|
||||
85
scripts/bundle-sabr-libs.js
Normal file
85
scripts/bundle-sabr-libs.js
Normal 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);
|
||||
});
|
||||
@ -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`
|
||||
|
||||
130
scripts/fetch-sabr-dependencies.cr
Normal file
130
scripts/fetch-sabr-dependencies.cr
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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,
|
||||
|
||||
243
src/invidious/routes/proxy.cr
Normal file
243
src/invidious/routes/proxy.cr
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = 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
|
||||
|
||||
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?),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
<% 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 %>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %>">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user