mirror of
https://github.com/iv-org/invidious.git
synced 2026-07-28 00:16:47 -05:00
Re-engineer SABR: port FreeTube sabr: scheme + manifest parser (Shaka 5.1.10), companion-optional watch page, no esm.sh
This commit is contained in:
parent
a1d0a3ceb2
commit
6ba2fe79f8
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,2 @@
|
|||||||
---
|
---
|
||||||
version: 4.16.4
|
version: 5.1.10
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
---
|
---
|
||||||
version: 16.0.1
|
version: 17.0.1
|
||||||
|
|||||||
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;
|
||||||
|
})();
|
||||||
@ -3,30 +3,27 @@
|
|||||||
*
|
*
|
||||||
* All dependencies are ES modules:
|
* All dependencies are ES modules:
|
||||||
* - youtubei.js: Provides Innertube for YouTube API access
|
* - youtubei.js: Provides Innertube for YouTube API access
|
||||||
* - googlevideo: Provides SABR streaming adapter
|
* - googlevideo: Provides SABR protos, UMP reader/writer and utils
|
||||||
* - bgutils-js: Provides BotGuard utilities
|
* - 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 all ES modules
|
import Innertube, { Platform, Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
||||||
import Innertube from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
|
||||||
import { Platform } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
|
||||||
import { Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
|
|
||||||
import * as googlevideo from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
|
|
||||||
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
|
||||||
|
|
||||||
// Expose all SABR-related functions to window
|
// youtubei.js
|
||||||
window.Innertube = Innertube;
|
window.Innertube = Innertube;
|
||||||
window.Platform = Platform;
|
window.Platform = Platform;
|
||||||
window.Constants = Constants;
|
window.Constants = Constants;
|
||||||
window.SabrStreamingAdapter = googlevideo.SabrStreamingAdapter;
|
|
||||||
window.SabrUmpProcessor = googlevideo.SabrUmpProcessor;
|
// googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
|
||||||
window.buildSabrFormat = googlevideo.buildSabrFormat;
|
window.googlevideo = googlevideo;
|
||||||
window.FormatKeyUtils = googlevideo.FormatKeyUtils;
|
|
||||||
window.UmpUtils = googlevideo.UmpUtils;
|
// BotGuard
|
||||||
window.SABR_CONSTANTS = googlevideo.SABR_CONSTANTS;
|
|
||||||
window.isGoogleVideoURL = googlevideo.isGoogleVideoURL;
|
|
||||||
window.BG = BG;
|
window.BG = BG;
|
||||||
|
|
||||||
// Signal that all SABR libraries are loaded and ready
|
|
||||||
console.info('[SABR Loader]', 'All SABR libraries loaded');
|
console.info('[SABR Loader]', 'All SABR libraries loaded');
|
||||||
window.dispatchEvent(new Event('sabr-libs-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;
|
||||||
|
})();
|
||||||
109
assets/js/sabr_mp4_index.js
Normal file
109
assets/js/sabr_mp4_index.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
var 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;
|
||||||
|
})();
|
||||||
@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* SABR Player - Main player initialization for SABR streaming
|
* SABR Player - Main player initialization for SABR streaming
|
||||||
* Ported from Kira project (https://github.com/LuanRT/kira)
|
|
||||||
*
|
*
|
||||||
* This module provides:
|
* Re-engineered (from the kira-based POC) to use FreeTube's SABR engine:
|
||||||
* - Innertube API initialization
|
* - Builds a data:application/sabr+json manifest from the youtube.js VideoInfo
|
||||||
* - Onesie config fetching
|
* - Registers FreeTube's sabr: networking scheme (sabr_scheme_plugin.js)
|
||||||
* - Shaka Player setup with SABR adapter
|
* - Drives Shaka 5 via the application/sabr+json manifest parser
|
||||||
* - Video playback management
|
*
|
||||||
|
* PoToken is minted browser-side via bgutils-js BotGuard and proxied through
|
||||||
|
* the Invidious /proxy route for CSP compliance.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var SABRPlayer = (function() {
|
var SABRPlayer = (function () {
|
||||||
// Constants
|
|
||||||
var VOLUME_KEY = 'youtube_player_volume';
|
var VOLUME_KEY = 'youtube_player_volume';
|
||||||
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
|
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
|
||||||
var SAVE_POSITION_INTERVAL_MS = 5000;
|
var SAVE_POSITION_INTERVAL_MS = 5000;
|
||||||
@ -27,7 +27,6 @@ var SABRPlayer = (function() {
|
|||||||
// State
|
// State
|
||||||
var player = null;
|
var player = null;
|
||||||
var ui = null;
|
var ui = null;
|
||||||
var sabrAdapter = null;
|
|
||||||
var videoElement = null;
|
var videoElement = null;
|
||||||
var shakaContainer = null;
|
var shakaContainer = null;
|
||||||
var currentVideoId = '';
|
var currentVideoId = '';
|
||||||
@ -40,91 +39,58 @@ var SABRPlayer = (function() {
|
|||||||
var playbackWebPoTokenContentBinding = null;
|
var playbackWebPoTokenContentBinding = null;
|
||||||
var playbackWebPoTokenCreationLock = false;
|
var playbackWebPoTokenCreationLock = false;
|
||||||
|
|
||||||
/**
|
var sabrStream = null; // handle returned by setupSabrScheme
|
||||||
* Get saved volume from localStorage
|
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
|
||||||
*/
|
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
|
||||||
|
|
||||||
function getSavedVolume() {
|
function getSavedVolume() {
|
||||||
try {
|
try {
|
||||||
var volume = localStorage.getItem(VOLUME_KEY);
|
var v = localStorage.getItem(VOLUME_KEY);
|
||||||
return volume ? parseFloat(volume) : 1;
|
return v ? parseFloat(v) : 1;
|
||||||
} catch (error) {
|
} catch (e) { return 1; }
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save volume to localStorage
|
|
||||||
*/
|
|
||||||
function saveVolume(volume) {
|
function saveVolume(volume) {
|
||||||
try {
|
try { localStorage.setItem(VOLUME_KEY, volume.toString()); } catch (e) {}
|
||||||
localStorage.setItem(VOLUME_KEY, volume.toString());
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all saved playback positions
|
|
||||||
*/
|
|
||||||
function getPlaybackPositions() {
|
function getPlaybackPositions() {
|
||||||
try {
|
try {
|
||||||
var positions = localStorage.getItem(PLAYBACK_POSITION_KEY);
|
var p = localStorage.getItem(PLAYBACK_POSITION_KEY);
|
||||||
return positions ? JSON.parse(positions) : {};
|
return p ? JSON.parse(p) : {};
|
||||||
} catch (error) {
|
} catch (e) { return {}; }
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save playback position
|
|
||||||
*/
|
|
||||||
function savePlaybackPosition(videoId, time) {
|
function savePlaybackPosition(videoId, time) {
|
||||||
if (!videoId || time < 1) return;
|
if (!videoId || time < 1) return;
|
||||||
try {
|
try {
|
||||||
var positions = getPlaybackPositions();
|
var positions = getPlaybackPositions();
|
||||||
positions[videoId] = time;
|
positions[videoId] = time;
|
||||||
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
|
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
|
||||||
} catch (error) {
|
} catch (e) {}
|
||||||
// Ignore
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get playback position for a video
|
|
||||||
*/
|
|
||||||
function getPlaybackPosition(videoId) {
|
function getPlaybackPosition(videoId) {
|
||||||
var positions = getPlaybackPositions();
|
var positions = getPlaybackPositions();
|
||||||
return positions[videoId] || 0;
|
return positions[videoId] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function base64ToU8(base64) {
|
||||||
* Initialize Innertube API
|
// 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() {
|
async function initInnertube() {
|
||||||
if (innertube) return innertube;
|
if (innertube) return innertube;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.info('[SABRPlayer]', 'Initializing InnerTube API');
|
console.info('[SABRPlayer]', 'Initializing InnerTube API');
|
||||||
|
if (typeof Innertube === 'undefined') throw new Error('youtubei.js not loaded');
|
||||||
|
|
||||||
// Check if Innertube is available from youtubei.js
|
// Set up Platform.shim.eval for URL deciphering (browser bundle lacks Jinter).
|
||||||
if (typeof Innertube === 'undefined') {
|
|
||||||
throw new Error('youtubei.js not loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up Platform.shim.eval for URL deciphering (like Kira does)
|
|
||||||
// This is required because the browser bundle doesn't include Jinter
|
|
||||||
if (typeof Platform !== 'undefined' && Platform.shim) {
|
if (typeof Platform !== 'undefined' && Platform.shim) {
|
||||||
Platform.shim.eval = async function(data, env) {
|
Platform.shim.eval = async function (data, env) {
|
||||||
var properties = [];
|
var properties = [];
|
||||||
|
if (env.n) properties.push('n: exportedVars.nFunction("' + env.n + '")');
|
||||||
if (env.n) {
|
if (env.sig) properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
|
||||||
properties.push('n: exportedVars.nFunction("' + env.n + '")');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.sig) {
|
|
||||||
properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
|
|
||||||
}
|
|
||||||
|
|
||||||
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
|
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
|
||||||
return new Function(code)();
|
return new Function(code)();
|
||||||
};
|
};
|
||||||
@ -133,29 +99,27 @@ var SABRPlayer = (function() {
|
|||||||
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
|
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Innertube with proxy fetch for CSP compliance
|
|
||||||
innertube = await Innertube.create({
|
innertube = await Innertube.create({
|
||||||
fetch: SABRHelpers.fetchWithProxy,
|
fetch: SABRHelpers.fetchWithProxy,
|
||||||
retrieve_player: true,
|
retrieve_player: true,
|
||||||
generate_session_locally: true
|
generate_session_locally: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize BotGuard for PoToken generation
|
// Kick off BotGuard init (don't block player setup).
|
||||||
BotguardService.init().then(function() {
|
BotguardService.init().then(function () {
|
||||||
console.info('[SABRPlayer]', 'BotGuard client initialized');
|
console.info('[SABRPlayer]', 'BotGuard client initialized');
|
||||||
}).catch(function(err) {
|
}).catch(function (err) {
|
||||||
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
|
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preload the redirector URL
|
// Preload the redirector URL.
|
||||||
try {
|
try {
|
||||||
var redirectorResponse = await SABRHelpers.fetchWithProxy(
|
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),
|
'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' }
|
{ method: 'GET' }
|
||||||
);
|
);
|
||||||
var redirectorResponseUrl = await redirectorResponse.text();
|
var redirectorResponseUrl = await redirectorResponse.text();
|
||||||
|
if (redirectorResponseUrl.indexOf('https://') === 0) {
|
||||||
if (redirectorResponseUrl.startsWith('https://')) {
|
|
||||||
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
|
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -169,47 +133,23 @@ var SABRPlayer = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch Onesie client config
|
|
||||||
*/
|
|
||||||
async function fetchOnesieConfig() {
|
async function fetchOnesieConfig() {
|
||||||
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) {
|
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) return clientConfig;
|
||||||
return clientConfig;
|
var cached = SABRHelpers.loadCachedClientConfig();
|
||||||
}
|
if (cached) { clientConfig = cached; return clientConfig; }
|
||||||
|
|
||||||
// Try loading from cache
|
|
||||||
var cachedConfig = SABRHelpers.loadCachedClientConfig();
|
|
||||||
if (cachedConfig) {
|
|
||||||
clientConfig = cachedConfig;
|
|
||||||
return clientConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
|
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
|
||||||
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
|
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: { 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' }
|
||||||
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
var tvConfigText = await tvConfigResponse.text();
|
var tvConfigText = await tvConfigResponse.text();
|
||||||
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
|
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
|
||||||
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
|
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
|
||||||
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
|
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
|
||||||
|
|
||||||
// Helper to decode base64 to Uint8Array
|
|
||||||
function base64ToU8(base64) {
|
|
||||||
var binary = atob(base64);
|
|
||||||
var bytes = new Uint8Array(binary.length);
|
|
||||||
for (var i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
clientConfig = {
|
clientConfig = {
|
||||||
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
|
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
|
||||||
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
|
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
|
||||||
@ -218,7 +158,6 @@ var SABRPlayer = (function() {
|
|||||||
baseUrl: onesieHotConfig.baseUrl,
|
baseUrl: onesieHotConfig.baseUrl,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
SABRHelpers.saveCachedClientConfig(clientConfig);
|
SABRHelpers.saveCachedClientConfig(clientConfig);
|
||||||
return clientConfig;
|
return clientConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -227,30 +166,18 @@ var SABRPlayer = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mint content-bound PoToken
|
|
||||||
*/
|
|
||||||
async function mintContentWebPO() {
|
async function mintContentWebPO() {
|
||||||
console.log('[SABRPlayer] mintContentWebPO called, binding:', playbackWebPoTokenContentBinding);
|
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) return;
|
||||||
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) {
|
|
||||||
console.log('[SABRPlayer] mintContentWebPO skipped:', { binding: !!playbackWebPoTokenContentBinding, locked: playbackWebPoTokenCreationLock });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
playbackWebPoTokenCreationLock = true;
|
playbackWebPoTokenCreationLock = true;
|
||||||
try {
|
try {
|
||||||
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
|
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
|
||||||
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
|
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
|
||||||
|
|
||||||
if (!BotguardService.isInitialized()) {
|
if (!BotguardService.isInitialized()) {
|
||||||
console.log('[SABRPlayer] BotGuard not initialized, reinitializing...');
|
|
||||||
await BotguardService.reinit();
|
await BotguardService.reinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BotguardService.isInitialized()) {
|
if (BotguardService.isInitialized()) {
|
||||||
playbackWebPoToken = await BotguardService.mintWebPoToken(
|
playbackWebPoToken = await BotguardService.mintWebPoToken(decodeURIComponent(playbackWebPoTokenContentBinding));
|
||||||
decodeURIComponent(playbackWebPoTokenContentBinding)
|
|
||||||
);
|
|
||||||
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
|
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
|
||||||
} else {
|
} else {
|
||||||
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
|
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
|
||||||
@ -262,15 +189,10 @@ var SABRPlayer = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function initializeShakaPlayer(containerElement, listenMode) {
|
||||||
* Initialize Shaka Player
|
|
||||||
*/
|
|
||||||
async function initializeShakaPlayer(containerElement) {
|
|
||||||
if (!shaka.Player.isBrowserSupported()) {
|
if (!shaka.Player.isBrowserSupported()) {
|
||||||
throw new Error('Shaka Player is not supported in this browser');
|
throw new Error('Shaka Player is not supported in this browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install polyfills
|
|
||||||
shaka.polyfill.installAll();
|
shaka.polyfill.installAll();
|
||||||
|
|
||||||
shakaContainer = document.createElement('div');
|
shakaContainer = document.createElement('div');
|
||||||
@ -288,7 +210,6 @@ var SABRPlayer = (function() {
|
|||||||
containerElement.appendChild(shakaContainer);
|
containerElement.appendChild(shakaContainer);
|
||||||
|
|
||||||
player = new shaka.Player();
|
player = new shaka.Player();
|
||||||
|
|
||||||
player.configure({
|
player.configure({
|
||||||
preferredAudioLanguage: 'en-US',
|
preferredAudioLanguage: 'en-US',
|
||||||
abr: DEFAULT_ABR_CONFIG,
|
abr: DEFAULT_ABR_CONFIG,
|
||||||
@ -296,370 +217,390 @@ var SABRPlayer = (function() {
|
|||||||
bufferingGoal: 120,
|
bufferingGoal: 120,
|
||||||
rebufferingGoal: 0.01,
|
rebufferingGoal: 0.01,
|
||||||
bufferBehind: 300,
|
bufferBehind: 300,
|
||||||
retryParameters: {
|
retryParameters: { maxAttempts: 8, fuzzFactor: 0.5, timeout: 30 * 1000 }
|
||||||
maxAttempts: 8,
|
},
|
||||||
fuzzFactor: 0.5,
|
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
|
||||||
timeout: 30 * 1000
|
disableVideo: !!listenMode
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
videoElement.volume = getSavedVolume();
|
videoElement.volume = getSavedVolume();
|
||||||
videoElement.addEventListener('volumechange', function() {
|
videoElement.addEventListener('volumechange', function () { saveVolume(videoElement.volume); });
|
||||||
saveVolume(videoElement.volume);
|
videoElement.addEventListener('playing', function () {
|
||||||
});
|
|
||||||
videoElement.addEventListener('playing', function() {
|
|
||||||
player.configure('abr.restrictions.maxHeight', Infinity);
|
player.configure('abr.restrictions.maxHeight', Infinity);
|
||||||
});
|
});
|
||||||
videoElement.addEventListener('pause', function() {
|
videoElement.addEventListener('pause', function () {
|
||||||
if (currentVideoId) {
|
if (currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await player.attach(videoElement);
|
await player.attach(videoElement);
|
||||||
|
|
||||||
// Initialize UI if available
|
|
||||||
if (shaka.ui && shaka.ui.Overlay) {
|
if (shaka.ui && shaka.ui.Overlay) {
|
||||||
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
|
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
|
||||||
ui.configure({
|
ui.configure({
|
||||||
addBigPlayButton: true,
|
addBigPlayButton: true,
|
||||||
overflowMenuButtons: [
|
overflowMenuButtons: ['captions', 'quality', 'language', 'playback_rate', 'loop', 'picture_in_picture']
|
||||||
'captions',
|
|
||||||
'quality',
|
|
||||||
'language',
|
|
||||||
'playback_rate',
|
|
||||||
'loop',
|
|
||||||
'picture_in_picture'
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return player;
|
return player;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Route non-sabr: requests (captions, storyboards, image tiles) through the
|
||||||
* Initialize SABR adapter
|
// Invidious /proxy so they satisfy CSP. The sabr: scheme is handled
|
||||||
*/
|
// separately by setupSabrScheme and also routes through /proxy internally.
|
||||||
async function initializeSabrAdapter() {
|
function setupRequestFilters() {
|
||||||
if (!player || !innertube) return;
|
var networkingEngine = player && player.getNetworkingEngine ? player.getNetworkingEngine() : null;
|
||||||
|
|
||||||
// Check if SabrStreamingAdapter is available from googlevideo
|
|
||||||
if (typeof SabrStreamingAdapter === 'undefined') {
|
|
||||||
console.error('[SABRPlayer]', 'googlevideo library not loaded');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the player adapter
|
|
||||||
var playerAdapter = new ShakaPlayerAdapter();
|
|
||||||
|
|
||||||
// Use YouTube.js ANDROID client constants
|
|
||||||
var androidClient = Constants.CLIENTS.ANDROID;
|
|
||||||
|
|
||||||
sabrAdapter = new SabrStreamingAdapter({
|
|
||||||
playerAdapter: playerAdapter,
|
|
||||||
clientInfo: {
|
|
||||||
osName: 'Android',
|
|
||||||
osVersion: androidClient.OS_VERSION || '14',
|
|
||||||
clientName: 3, // ANDROID - Used exclusively for SABR streaming requests
|
|
||||||
clientVersion: androidClient.VERSION
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sabrAdapter.onMintPoToken(async function() {
|
|
||||||
console.log('[SABRPlayer] onMintPoToken callback invoked');
|
|
||||||
if (!playbackWebPoToken) {
|
|
||||||
if (isLive) {
|
|
||||||
await mintContentWebPO();
|
|
||||||
} else {
|
|
||||||
mintContentWebPO(); // Don't block for VOD
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var token = playbackWebPoToken || coldStartToken || '';
|
|
||||||
console.log('[SABRPlayer] Returning token:', token ? 'token present (' + token.substring(0, 20) + '...)' : 'empty');
|
|
||||||
return token;
|
|
||||||
});
|
|
||||||
|
|
||||||
sabrAdapter.attach(player);
|
|
||||||
return sabrAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup request filters for proxying
|
|
||||||
*/
|
|
||||||
async function setupRequestFilters() {
|
|
||||||
var networkingEngine = player?.getNetworkingEngine();
|
|
||||||
if (!networkingEngine) return;
|
if (!networkingEngine) return;
|
||||||
|
|
||||||
var config = SABRHelpers.getProxyConfig();
|
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; }
|
||||||
|
|
||||||
networkingEngine.registerRequestFilter(async function(type, request) {
|
var isGoogleVideo = url.hostname.endsWith('.googlevideo.com') || url.hostname.indexOf('googlevideo') !== -1;
|
||||||
var url = new URL(request.uris[0]);
|
var isYouTube = url.hostname.endsWith('.youtube.com');
|
||||||
|
if (!isGoogleVideo && !isYouTube) return;
|
||||||
|
|
||||||
// Proxy googlevideo requests
|
// Reuse the shared proxy helper so __host/__headers are set consistently.
|
||||||
if (url.host.endsWith('.googlevideo.com') || url.host.includes('youtube')) {
|
var proxied = SABRHelpers.proxyUrl(url, request.headers || {});
|
||||||
var newUrl = new URL(url.toString());
|
proxied.searchParams.set('alr', 'yes');
|
||||||
newUrl.searchParams.set('__host', url.host);
|
request.uris[0] = proxied.toString();
|
||||||
newUrl.host = config.PROXY_HOST;
|
});
|
||||||
newUrl.port = config.PROXY_PORT;
|
}
|
||||||
newUrl.protocol = config.PROXY_PROTOCOL + ':';
|
|
||||||
newUrl.pathname = '/proxy' + url.pathname;
|
// Map a youtube.js Format to the SabrManifest "formats" entry shape expected
|
||||||
|
// by sabr_manifest_parser.js (port of FreeTube's SabrManifestParser).
|
||||||
// Add required headers for googlevideo requests to avoid 403
|
function mapFormatToManifestEntry(fmt) {
|
||||||
var proxyHeaders = [
|
var audioTrack = fmt.audio_track || null;
|
||||||
['user-agent', navigator.userAgent],
|
var colorInfo = fmt.color_info || null;
|
||||||
['origin', 'https://www.youtube.com'],
|
return {
|
||||||
['referer', 'https://www.youtube.com/']
|
itag: fmt.itag,
|
||||||
];
|
lastModified: fmt.last_modified_ms,
|
||||||
|
mimeType: fmt.mime_type,
|
||||||
// CRITICAL: Add content-type for POST requests (SABR videoplayback)
|
xtags: fmt.xtags,
|
||||||
// This is REQUIRED for YouTube to accept the protobuf request body
|
bitrate: fmt.bitrate,
|
||||||
if (request.body && request.body.byteLength > 0) {
|
initRange: fmt.init_range,
|
||||||
proxyHeaders.push(['content-type', 'application/x-protobuf']);
|
indexRange: fmt.index_range,
|
||||||
console.log('[SABRPlayer] Adding content-type: application/x-protobuf for POST request');
|
width: fmt.width,
|
||||||
}
|
height: fmt.height,
|
||||||
|
frameRate: fmt.fps,
|
||||||
// Add visitor ID if available from innertube session
|
quality: fmt.quality,
|
||||||
if (innertube?.session?.context?.client?.visitorData) {
|
language: fmt.language,
|
||||||
proxyHeaders.push(['x-goog-visitor-id', innertube.session.context.client.visitorData]);
|
audioSampleRate: fmt.audio_sample_rate,
|
||||||
}
|
audioChannels: fmt.audio_channels,
|
||||||
|
isDrc: fmt.is_drc,
|
||||||
// Add client name and version - Force ANDROID (3) for SABR requests
|
isVoiceBoost: fmt.is_vb,
|
||||||
proxyHeaders.push(['x-youtube-client-name', '3']); // ANDROID
|
isOriginal: fmt.is_original,
|
||||||
if (innertube?.session?.context?.client?.clientVersion) {
|
isDubbed: fmt.is_dubbed,
|
||||||
proxyHeaders.push(['x-youtube-client-version', innertube.session.context.client.clientVersion]);
|
isAutoDubbed: fmt.is_auto_dubbed,
|
||||||
}
|
isDescriptive: fmt.is_descriptive,
|
||||||
|
isSecondary: fmt.is_secondary,
|
||||||
newUrl.searchParams.set('__headers', JSON.stringify(proxyHeaders));
|
spatialAudio: !!(fmt.spatial_audio_type),
|
||||||
|
label: audioTrack ? audioTrack.display_name : undefined,
|
||||||
request.uris[0] = newUrl.toString();
|
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 () {
|
||||||
|
console.warn('[SABRPlayer] SABR reload requested by server; re-loading video');
|
||||||
|
if (currentVideoId && loadFn) loadFn(currentVideoId, shakaContainer, currentLoadOptions || {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a video
|
|
||||||
*/
|
|
||||||
async function loadVideo(videoId, containerElement, options) {
|
async function loadVideo(videoId, containerElement, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
currentVideoId = videoId;
|
currentVideoId = videoId;
|
||||||
|
currentLoadOptions = options;
|
||||||
playbackWebPoToken = null;
|
playbackWebPoToken = null;
|
||||||
playbackWebPoTokenContentBinding = videoId;
|
playbackWebPoTokenContentBinding = videoId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize components if needed
|
|
||||||
if (!innertube) {
|
if (!innertube) {
|
||||||
innertube = await initInnertube();
|
innertube = await initInnertube();
|
||||||
if (!innertube) {
|
if (!innertube) throw new Error('Failed to initialize Innertube');
|
||||||
throw new Error('Failed to initialize Innertube');
|
|
||||||
}
|
}
|
||||||
|
if (!clientConfig) {
|
||||||
|
await fetchOnesieConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientConfig) {
|
// Mint a content-bound PoToken before getInfo so streaming_data includes
|
||||||
clientConfig = await fetchOnesieConfig();
|
// the server_abr_streaming_url and so the SABR requests authenticate.
|
||||||
}
|
try { await mintContentWebPO(); } catch (e) { console.warn('[SABRPlayer] poToken mint failed, continuing', e); }
|
||||||
|
var poToken = playbackWebPoToken || coldStartToken || '';
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
await initializeShakaPlayer(containerElement);
|
await initializeShakaPlayer(containerElement, options.listen);
|
||||||
} else {
|
} else {
|
||||||
// Reset player configuration
|
|
||||||
player.configure('abr', DEFAULT_ABR_CONFIG);
|
player.configure('abr', DEFAULT_ABR_CONFIG);
|
||||||
|
player.configure('disableVideo', !!options.listen);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sabrAdapter) {
|
setupRequestFilters();
|
||||||
await initializeSabrAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
await setupRequestFilters();
|
var videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
|
||||||
|
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
|
||||||
// Fetch video info using Innertube
|
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
|
||||||
var videoInfo = await innertube.getInfo(videoId);
|
|
||||||
|
|
||||||
if (!videoInfo || videoInfo.playability_status?.status !== 'OK') {
|
|
||||||
var reason = videoInfo?.playability_status?.reason || 'Unknown error';
|
|
||||||
throw new Error('Video unavailable: ' + reason);
|
throw new Error('Video unavailable: ' + reason);
|
||||||
}
|
}
|
||||||
|
isLive = !!(videoInfo.basic_info && videoInfo.basic_info.is_live);
|
||||||
|
|
||||||
isLive = !!videoInfo.basic_info?.is_live;
|
|
||||||
|
|
||||||
// Get streaming URL
|
|
||||||
var streamingData = videoInfo.streaming_data;
|
var streamingData = videoInfo.streaming_data;
|
||||||
if (!streamingData) {
|
if (!streamingData) throw new Error('No streaming data available');
|
||||||
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;
|
var manifestUri;
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
// For live streams, use HLS or DASH manifest
|
// Live: don't route through the SABR parser yet; use HLS/DASH.
|
||||||
manifestUri = streamingData.hls_manifest_url || streamingData.dash_manifest_url;
|
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 {
|
} else {
|
||||||
// For VOD, generate DASH manifest from adaptive formats
|
if (streamingData.server_abr_streaming_url && innertube.session && innertube.session.player && innertube.session.player.decipher) {
|
||||||
if (sabrAdapter && streamingData.server_abr_streaming_url) {
|
|
||||||
// SABR mode - need to decipher the server_abr_streaming_url first
|
|
||||||
var sabrUrl = streamingData.server_abr_streaming_url;
|
|
||||||
|
|
||||||
// Decipher the URL if the player has a decipher function
|
|
||||||
if (innertube?.session?.player?.decipher) {
|
|
||||||
try {
|
try {
|
||||||
sabrUrl = await innertube.session.player.decipher(sabrUrl);
|
streamingData.server_abr_streaming_url = await innertube.session.player.decipher(streamingData.server_abr_streaming_url);
|
||||||
console.log('[SABRPlayer] Deciphered streaming URL:', sabrUrl?.substring(0, 100) + '...');
|
} catch (e) {
|
||||||
} catch (decipherErr) {
|
console.warn('[SABRPlayer] Failed to decipher server_abr_streaming_url', e);
|
||||||
console.error('[SABRPlayer] Failed to decipher URL:', decipherErr);
|
|
||||||
// Try to use the raw URL anyway
|
|
||||||
console.warn('[SABRPlayer] Trying raw URL instead');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[SABRPlayer] Player decipher not available, using raw URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[SABRPlayer] Setting streaming URL:', sabrUrl);
|
|
||||||
sabrAdapter.setStreamingURL(sabrUrl);
|
|
||||||
|
|
||||||
// Build SABR formats
|
|
||||||
console.log('[SABRPlayer] Checking SABR format requirements:', {
|
|
||||||
buildSabrFormat: typeof buildSabrFormat,
|
|
||||||
adaptive_formats: !!streamingData.adaptive_formats,
|
|
||||||
formats_length: streamingData.adaptive_formats?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof buildSabrFormat !== 'undefined' && streamingData.adaptive_formats) {
|
|
||||||
console.log('[SABRPlayer] Building SABR formats from', streamingData.adaptive_formats.length, 'adaptive formats');
|
|
||||||
var sabrFormats = streamingData.adaptive_formats.map(function(fmt) {
|
|
||||||
return buildSabrFormat(fmt);
|
|
||||||
}).filter(function(format) {
|
|
||||||
return !format.xtags;
|
|
||||||
});
|
|
||||||
console.log('[SABRPlayer] Setting', sabrFormats.length, 'SABR formats on adapter');
|
|
||||||
sabrAdapter.setServerAbrFormats(sabrFormats);
|
|
||||||
console.log('[SABRPlayer] SABR formats set successfully');
|
|
||||||
} else {
|
|
||||||
console.warn('[SABRPlayer] buildSabrFormat not available or no adaptive formats', {
|
|
||||||
buildSabrFormat: typeof buildSabrFormat,
|
|
||||||
has_adaptive_formats: !!streamingData.adaptive_formats
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ustreamer config
|
|
||||||
var ustreamerConfig = videoInfo.player_config?.media_common_config?.media_ustreamer_request_config?.video_playback_ustreamer_config;
|
|
||||||
if (ustreamerConfig) {
|
|
||||||
sabrAdapter.setUstreamerConfig(ustreamerConfig);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate DASH manifest
|
manifestUri = buildSabrManifest(videoInfo, poToken, clientInfo);
|
||||||
var dashManifest = await videoInfo.toDash({
|
if (!manifestUri) {
|
||||||
manifest_options: {
|
// No SABR URL available - fall back to DASH.
|
||||||
is_sabr: !!sabrAdapter,
|
var dashManifest = await videoInfo.toDash({ manifest_options: { captions_format: 'vtt', include_thumbnails: false } });
|
||||||
captions_format: 'vtt',
|
|
||||||
include_thumbnails: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
|
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!manifestUri) {
|
|
||||||
throw new Error('Could not find a valid manifest URI');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine start time
|
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;
|
var startTime = options.startTime;
|
||||||
if (startTime === undefined && options.savePlayerPos !== false) {
|
if (startTime === undefined && options.savePlayerPos !== false) {
|
||||||
startTime = getPlaybackPosition(videoId);
|
startTime = getPlaybackPosition(videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the manifest
|
var mimeType = (!isLive && manifestUri.indexOf('data:' + window.MANIFEST_TYPE_SABR) === 0)
|
||||||
await player.load(manifestUri, isLive ? undefined : startTime);
|
? window.MANIFEST_TYPE_SABR
|
||||||
|
: undefined;
|
||||||
|
await player.load(manifestUri, isLive ? undefined : startTime, mimeType);
|
||||||
|
|
||||||
// Start playback
|
videoElement.play().catch(function (err) {
|
||||||
videoElement.play().catch(function(err) {
|
if (err.name === 'NotAllowedError') console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
|
||||||
if (err.name === 'NotAllowedError') {
|
|
||||||
console.warn('[SABRPlayer]', 'Autoplay was prevented by the browser');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start saving position periodically
|
if (savePositionInterval) clearInterval(savePositionInterval);
|
||||||
if (savePositionInterval) {
|
savePositionInterval = setInterval(function () {
|
||||||
clearInterval(savePositionInterval);
|
|
||||||
}
|
|
||||||
savePositionInterval = setInterval(function() {
|
|
||||||
if (videoElement && currentVideoId && !videoElement.paused) {
|
if (videoElement && currentVideoId && !videoElement.paused) {
|
||||||
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
}
|
}
|
||||||
}, SAVE_POSITION_INTERVAL_MS);
|
}, SAVE_POSITION_INTERVAL_MS);
|
||||||
|
|
||||||
return {
|
return { player: player, videoElement: videoElement, videoInfo: videoInfo };
|
||||||
player: player,
|
|
||||||
videoElement: videoElement,
|
|
||||||
videoInfo: videoInfo
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SABRPlayer]', 'Error loading video:', error);
|
console.error('[SABRPlayer]', 'Error loading video:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose of the player
|
|
||||||
*/
|
|
||||||
async function dispose() {
|
async function dispose() {
|
||||||
if (savePositionInterval) {
|
if (savePositionInterval) { clearInterval(savePositionInterval); savePositionInterval = null; }
|
||||||
clearInterval(savePositionInterval);
|
if (videoElement && currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
||||||
savePositionInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoElement && currentVideoId) {
|
if (sabrStream) { try { sabrStream.cleanup(); } catch (e) {} sabrStream = null; }
|
||||||
savePlaybackPosition(currentVideoId, videoElement.currentTime);
|
if (player) { await player.destroy(); player = null; }
|
||||||
}
|
if (ui) { ui.destroy(); ui = null; }
|
||||||
|
if (shakaContainer && shakaContainer.parentNode) shakaContainer.parentNode.removeChild(shakaContainer);
|
||||||
if (sabrAdapter) {
|
|
||||||
sabrAdapter.dispose();
|
|
||||||
sabrAdapter = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player) {
|
|
||||||
await player.destroy();
|
|
||||||
player = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ui) {
|
|
||||||
ui.destroy();
|
|
||||||
ui = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shakaContainer && shakaContainer.parentNode) {
|
|
||||||
shakaContainer.parentNode.removeChild(shakaContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
videoElement = null;
|
videoElement = null;
|
||||||
shakaContainer = null;
|
shakaContainer = null;
|
||||||
currentVideoId = '';
|
currentVideoId = '';
|
||||||
|
sabrManifest = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getPlayerInstance() { return player; }
|
||||||
* Get current player instance
|
function getVideoElement() { return videoElement; }
|
||||||
*/
|
|
||||||
function getPlayer() {
|
|
||||||
return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current video element
|
|
||||||
*/
|
|
||||||
function getVideoElement() {
|
|
||||||
return videoElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadVideo: loadVideo,
|
loadVideo: loadVideo,
|
||||||
dispose: dispose,
|
dispose: dispose,
|
||||||
getPlayer: getPlayer,
|
getPlayer: getPlayerInstance,
|
||||||
getVideoElement: getVideoElement,
|
getVideoElement: getVideoElement,
|
||||||
initInnertube: initInnertube,
|
initInnertube: initInnertube,
|
||||||
fetchOnesieConfig: fetchOnesieConfig
|
fetchOnesieConfig: fetchOnesieConfig
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Export for use
|
|
||||||
window.SABRPlayer = SABRPlayer;
|
window.SABRPlayer = SABRPlayer;
|
||||||
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;
|
||||||
|
})();
|
||||||
@ -1,736 +0,0 @@
|
|||||||
/**
|
|
||||||
* SABR Shaka Player Adapter
|
|
||||||
* Ported from Kira project (https://github.com/LuanRT/kira)
|
|
||||||
*
|
|
||||||
* This module provides the ShakaPlayerAdapter class that implements
|
|
||||||
* the SabrPlayerAdapter interface for use with the SABR streaming adapter.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var ShakaPlayerAdapter = (function() {
|
|
||||||
/**
|
|
||||||
* Convert object to Map
|
|
||||||
* @param {Object} object
|
|
||||||
* @returns {Map}
|
|
||||||
*/
|
|
||||||
function asMap(object) {
|
|
||||||
var map = new Map();
|
|
||||||
for (var key in object) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
|
||||||
map.set(key, object[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Headers to plain object
|
|
||||||
* @param {Headers} headers
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function headersToGenericObject(headers) {
|
|
||||||
var headersObj = {};
|
|
||||||
headers.forEach(function(value, key) {
|
|
||||||
headersObj[key.trim()] = value;
|
|
||||||
});
|
|
||||||
return headersObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Shaka response object
|
|
||||||
* @param {Object} headers
|
|
||||||
* @param {BufferSource} data
|
|
||||||
* @param {number} status
|
|
||||||
* @param {string} uri
|
|
||||||
* @param {string} responseURL
|
|
||||||
* @param {Object} request
|
|
||||||
* @param {number} requestType
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
function makeResponse(headers, data, status, uri, responseURL, request, requestType) {
|
|
||||||
if (status >= 200 && status <= 299 && status !== 202) {
|
|
||||||
return {
|
|
||||||
uri: responseURL || uri,
|
|
||||||
originalUri: uri,
|
|
||||||
data: data,
|
|
||||||
status: status,
|
|
||||||
headers: headers,
|
|
||||||
originalRequest: request,
|
|
||||||
fromCache: !!headers['x-shaka-from-cache']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseText = null;
|
|
||||||
try {
|
|
||||||
responseText = shaka.util.StringUtils.fromBytesAutoDetect(data);
|
|
||||||
} catch (e) { /* no-op */ }
|
|
||||||
|
|
||||||
var severity = (status === 401 || status === 403)
|
|
||||||
? shaka.util.Error.Severity.CRITICAL
|
|
||||||
: shaka.util.Error.Severity.RECOVERABLE;
|
|
||||||
|
|
||||||
throw new shaka.util.Error(
|
|
||||||
severity,
|
|
||||||
shaka.util.Error.Category.NETWORK,
|
|
||||||
shaka.util.Error.Code.BAD_HTTP_STATUS,
|
|
||||||
uri,
|
|
||||||
status,
|
|
||||||
responseText,
|
|
||||||
headers,
|
|
||||||
requestType,
|
|
||||||
responseURL || uri
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a recoverable Shaka error
|
|
||||||
* @param {string} message
|
|
||||||
* @param {Object} info
|
|
||||||
* @returns {shaka.util.Error}
|
|
||||||
*/
|
|
||||||
function createRecoverableError(message, info) {
|
|
||||||
return new shaka.util.Error(
|
|
||||||
shaka.util.Error.Severity.RECOVERABLE,
|
|
||||||
shaka.util.Error.Category.NETWORK,
|
|
||||||
shaka.util.Error.Code.HTTP_ERROR,
|
|
||||||
message,
|
|
||||||
{ info: info }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL is a Google Video URL
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function isGoogleVideoURL(url) {
|
|
||||||
try {
|
|
||||||
var urlObj = new URL(url);
|
|
||||||
return urlObj.hostname.endsWith('.googlevideo.com') ||
|
|
||||||
urlObj.hostname.endsWith('.youtube.com') ||
|
|
||||||
urlObj.hostname.includes('googlevideo');
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ShakaPlayerAdapter class implementing SabrPlayerAdapter interface
|
|
||||||
*/
|
|
||||||
function ShakaPlayerAdapter() {
|
|
||||||
this.player = null;
|
|
||||||
this.requestMetadataManager = null;
|
|
||||||
this.cacheManager = null;
|
|
||||||
this.abortController = null;
|
|
||||||
this.requestFilter = null;
|
|
||||||
this.responseFilter = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the adapter with a Shaka player instance
|
|
||||||
* @param {shaka.Player} player
|
|
||||||
* @param {RequestMetadataManager} requestMetadataManager
|
|
||||||
* @param {CacheManager} cacheManager
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.initialize = function(player, requestMetadataManager, cacheManager) {
|
|
||||||
console.log('[ShakaPlayerAdapter] initialize() called', { player: !!player, requestMetadataManager: !!requestMetadataManager, cacheManager: !!cacheManager });
|
|
||||||
var self = this;
|
|
||||||
this.player = player;
|
|
||||||
this.requestMetadataManager = requestMetadataManager;
|
|
||||||
this.cacheManager = cacheManager;
|
|
||||||
|
|
||||||
var networkingEngine = shaka.net.NetworkingEngine;
|
|
||||||
var schemes = ['http', 'https'];
|
|
||||||
console.log('[ShakaPlayerAdapter] Registering schemes:', schemes);
|
|
||||||
|
|
||||||
if (!shaka.net.HttpFetchPlugin.isSupported()) {
|
|
||||||
throw new Error('The Fetch API is not supported in this browser.');
|
|
||||||
}
|
|
||||||
|
|
||||||
schemes.forEach(function(scheme) {
|
|
||||||
console.log('[ShakaPlayerAdapter] Registering scheme:', scheme);
|
|
||||||
networkingEngine.registerScheme(
|
|
||||||
scheme,
|
|
||||||
self.parseRequest.bind(self),
|
|
||||||
networkingEngine.PluginPriority.PREFERRED
|
|
||||||
);
|
|
||||||
});
|
|
||||||
console.log('[ShakaPlayerAdapter] Initialization complete');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and handle a network request
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.parseRequest = function(
|
|
||||||
uri, request, requestType, progressUpdated, headersReceived, config
|
|
||||||
) {
|
|
||||||
var self = this;
|
|
||||||
var headers = new Headers();
|
|
||||||
asMap(request.headers).forEach(function(value, key) {
|
|
||||||
headers.append(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
var controller = new AbortController();
|
|
||||||
this.abortController = controller;
|
|
||||||
|
|
||||||
var init = {
|
|
||||||
body: request.body || undefined,
|
|
||||||
headers: headers,
|
|
||||||
method: request.method,
|
|
||||||
signal: this.abortController.signal,
|
|
||||||
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
var abortStatus = { canceled: false, timedOut: false };
|
|
||||||
var minBytes = config.minBytesForProgressEvents || 0;
|
|
||||||
|
|
||||||
var pendingRequest = this.doRequest(
|
|
||||||
uri, request, requestType, init, controller,
|
|
||||||
abortStatus, progressUpdated, headersReceived, minBytes
|
|
||||||
);
|
|
||||||
|
|
||||||
var operation = new shaka.util.AbortableOperation(
|
|
||||||
pendingRequest,
|
|
||||||
function() {
|
|
||||||
abortStatus.canceled = true;
|
|
||||||
controller.abort();
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
var timeoutMs = request.retryParameters.timeout;
|
|
||||||
if (timeoutMs) {
|
|
||||||
var timer = new shaka.util.Timer(function() {
|
|
||||||
abortStatus.timedOut = true;
|
|
||||||
controller.abort();
|
|
||||||
console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
|
|
||||||
});
|
|
||||||
timer.tickAfter(timeoutMs / 1000);
|
|
||||||
operation.finally(function() { timer.stop(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
return operation;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle cached request
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.handleCachedRequest = async function(
|
|
||||||
requestMetadata, uri, request, progressUpdated, headersReceived, requestType
|
|
||||||
) {
|
|
||||||
if (!requestMetadata.byteRange || !this.cacheManager) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if FormatKeyUtils is available
|
|
||||||
if (typeof FormatKeyUtils === 'undefined' || !FormatKeyUtils.createSegmentCacheKeyFromMetadata) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
|
|
||||||
|
|
||||||
var arrayBuffer = requestMetadata.isInit
|
|
||||||
? this.cacheManager.getInitSegment(segmentKey)?.buffer
|
|
||||||
: this.cacheManager.getSegment(segmentKey)?.buffer;
|
|
||||||
|
|
||||||
if (!arrayBuffer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestMetadata.isInit) {
|
|
||||||
arrayBuffer = arrayBuffer.slice(
|
|
||||||
requestMetadata.byteRange.start,
|
|
||||||
requestMetadata.byteRange.end + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var headers = {
|
|
||||||
'content-type': requestMetadata.format?.mimeType?.split(';')[0] || '',
|
|
||||||
'content-length': arrayBuffer.byteLength.toString(),
|
|
||||||
'x-shaka-from-cache': 'true'
|
|
||||||
};
|
|
||||||
|
|
||||||
headersReceived(headers);
|
|
||||||
progressUpdated(0, arrayBuffer.byteLength, 0);
|
|
||||||
|
|
||||||
return makeResponse(headers, arrayBuffer, 200, uri, uri, request, requestType);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle UMP response (SABR streaming format)
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.handleUmpResponse = async function(
|
|
||||||
response, requestMetadata, uri, request, requestType,
|
|
||||||
progressUpdated, abortController, minBytes
|
|
||||||
) {
|
|
||||||
var self = this;
|
|
||||||
var lastTime = Date.now();
|
|
||||||
|
|
||||||
// Check if SabrUmpProcessor is available
|
|
||||||
if (typeof SabrUmpProcessor === 'undefined') {
|
|
||||||
console.warn('[ShakaPlayerAdapter]', 'SabrUmpProcessor not available, falling back to normal handling');
|
|
||||||
var arrayBuffer = await response.arrayBuffer();
|
|
||||||
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: arrayBuffer });
|
|
||||||
}
|
|
||||||
|
|
||||||
var sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
|
|
||||||
|
|
||||||
function checkResultIntegrity(result) {
|
|
||||||
if (!result.data && ((!!requestMetadata.error || requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) && !requestMetadata.streamInfo?.sabrContextUpdate)) {
|
|
||||||
throw createRecoverableError('Server streaming error', requestMetadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldReturnEmptyResponse() {
|
|
||||||
return requestMetadata.isSABR && (requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If response body is not a ReadableStream, handle whole response
|
|
||||||
if (!response.body) {
|
|
||||||
var arrayBuffer = await response.arrayBuffer();
|
|
||||||
var currentTime = Date.now();
|
|
||||||
|
|
||||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
|
||||||
|
|
||||||
var result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
checkResultIntegrity(result);
|
|
||||||
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: result.data });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldReturnEmptyResponse()) {
|
|
||||||
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream processing with ReadableStream
|
|
||||||
var reader = response.body.getReader();
|
|
||||||
var loaded = 0;
|
|
||||||
var lastLoaded = 0;
|
|
||||||
var contentLength;
|
|
||||||
|
|
||||||
while (!abortController.signal.aborted) {
|
|
||||||
var readObj;
|
|
||||||
try {
|
|
||||||
readObj = await reader.read();
|
|
||||||
} catch (e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = readObj.value;
|
|
||||||
var done = readObj.done;
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
if (shouldReturnEmptyResponse()) {
|
|
||||||
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: undefined });
|
|
||||||
}
|
|
||||||
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await sabrUmpReader.processChunk(value);
|
|
||||||
var segmentInfo = sabrUmpReader.getSegmentInfo();
|
|
||||||
|
|
||||||
if (segmentInfo) {
|
|
||||||
if (!contentLength) {
|
|
||||||
contentLength = segmentInfo.mediaHeader.contentLength;
|
|
||||||
}
|
|
||||||
loaded += segmentInfo.lastChunkSize || 0;
|
|
||||||
segmentInfo.lastChunkSize = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentTime = Date.now();
|
|
||||||
var chunkSize = loaded - lastLoaded;
|
|
||||||
|
|
||||||
if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
|
|
||||||
if (result) checkResultIntegrity(result);
|
|
||||||
if (contentLength) {
|
|
||||||
var numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
|
|
||||||
try {
|
|
||||||
progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
|
|
||||||
} catch (e) { /* no-op */ }
|
|
||||||
finally {
|
|
||||||
lastLoaded = loaded;
|
|
||||||
lastTime = currentTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
abortController.abort();
|
|
||||||
return this.createShakaResponse({ uri: uri, request: request, requestType: requestType, response: response, arrayBuffer: result.data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createRecoverableError('UMP stream processing was aborted but did not produce a result.', requestMetadata);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform the network request
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.doRequest = async function(
|
|
||||||
uri, request, requestType, init, abortController,
|
|
||||||
abortStatus, progressUpdated, headersReceived, minBytes
|
|
||||||
) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('[ShakaPlayerAdapter] doRequest called:', { uri: uri, requestType: requestType });
|
|
||||||
|
|
||||||
// Convert sabr:// URLs to HTTP URLs before processing
|
|
||||||
if (uri.startsWith('sabr://')) {
|
|
||||||
// sabr:// URLs should have been converted by the request interceptor
|
|
||||||
// If we reach here, the interceptor wasn't set up properly
|
|
||||||
console.error('[ShakaPlayerAdapter] *** sabr:// URL reached doRequest without being converted:', uri);
|
|
||||||
console.error('[ShakaPlayerAdapter] This means the request interceptor is not working!');
|
|
||||||
// Try to handle it anyway - this shouldn't normally happen
|
|
||||||
return makeResponse({}, new ArrayBuffer(0), 200, uri, uri, request, requestType);
|
|
||||||
}
|
|
||||||
|
|
||||||
var requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if (requestMetadata) {
|
|
||||||
var cachedResponse = await this.handleCachedRequest(
|
|
||||||
requestMetadata, uri, request, progressUpdated, headersReceived, requestType
|
|
||||||
);
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy Google Video URLs
|
|
||||||
var fetchUrl = uri;
|
|
||||||
if (isGoogleVideoURL(uri)) {
|
|
||||||
// Ensure required headers are present for googlevideo requests
|
|
||||||
var headersForProxy = init.headers;
|
|
||||||
|
|
||||||
// Debug: log initial headers
|
|
||||||
console.log('[ShakaPlayerAdapter] Initial init.headers:', init.headers ? 'exists' : 'null',
|
|
||||||
'method:', init.method);
|
|
||||||
if (init.headers) {
|
|
||||||
var initHeadersDebug = [];
|
|
||||||
init.headers.forEach(function(v, k) { initHeadersDebug.push(k); });
|
|
||||||
console.log('[ShakaPlayerAdapter] Init headers keys:', initHeadersDebug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For SABR requests (POST to videoplayback), ensure we have proper headers
|
|
||||||
if (!headersForProxy || headersForProxy.entries().next().done) {
|
|
||||||
headersForProxy = new Headers();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always add User-Agent for googlevideo requests to avoid 403
|
|
||||||
// Use the browser's actual User-Agent
|
|
||||||
if (!headersForProxy.has('user-agent')) {
|
|
||||||
headersForProxy.set('user-agent', navigator.userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For POST requests (SABR), add additional required headers
|
|
||||||
if (init.method === 'POST') {
|
|
||||||
console.log('[ShakaPlayerAdapter] POST request detected, adding content-type header');
|
|
||||||
headersForProxy.set('content-type', 'application/x-protobuf');
|
|
||||||
if (!headersForProxy.has('origin')) {
|
|
||||||
headersForProxy.set('origin', 'https://www.youtube.com');
|
|
||||||
}
|
|
||||||
if (!headersForProxy.has('referer')) {
|
|
||||||
headersForProxy.set('referer', 'https://www.youtube.com/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log the headers being sent
|
|
||||||
var debugHeaders = [];
|
|
||||||
headersForProxy.forEach(function(v, k) { debugHeaders.push([k, v.substring(0, 50)]); });
|
|
||||||
console.log('[ShakaPlayerAdapter] Headers for proxy:', debugHeaders);
|
|
||||||
|
|
||||||
fetchUrl = SABRHelpers.proxyUrl(uri, headersForProxy).toString();
|
|
||||||
// Set Content-Type on the fetch request for POST (needed for the proxy to know the body type)
|
|
||||||
// Other headers are passed via __headers param and will be forwarded by the proxy
|
|
||||||
init.headers = new Headers();
|
|
||||||
if (init.method === 'POST') {
|
|
||||||
init.headers.set('Content-Type', 'application/x-protobuf');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await fetch(fetchUrl, init);
|
|
||||||
|
|
||||||
// Debug log for POST requests
|
|
||||||
if (init.method === 'POST' && init.body) {
|
|
||||||
var bodySize = init.body instanceof ArrayBuffer ? init.body.byteLength :
|
|
||||||
(init.body instanceof Uint8Array ? init.body.byteLength : 0);
|
|
||||||
console.log('[ShakaPlayerAdapter] POST request sent:', {
|
|
||||||
url: fetchUrl.substring(0, 100) + '...',
|
|
||||||
bodySize: bodySize + ' bytes',
|
|
||||||
status: response.status
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
headersReceived(headersToGenericObject(response.headers));
|
|
||||||
|
|
||||||
// Handle UMP response
|
|
||||||
if (requestMetadata && init.method !== 'HEAD' && response.headers.get('content-type') === 'application/vnd.yt-ump') {
|
|
||||||
return this.handleUmpResponse(
|
|
||||||
response, requestMetadata, uri, request, requestType,
|
|
||||||
progressUpdated, abortController, minBytes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle normal response
|
|
||||||
var lastTime = Date.now();
|
|
||||||
var arrayBuffer = await response.arrayBuffer();
|
|
||||||
var currentTime = Date.now();
|
|
||||||
|
|
||||||
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
|
||||||
|
|
||||||
return this.createShakaResponse({
|
|
||||||
uri: uri,
|
|
||||||
request: request,
|
|
||||||
requestType: requestType,
|
|
||||||
response: response,
|
|
||||||
arrayBuffer: arrayBuffer
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (abortStatus.canceled) {
|
|
||||||
throw new shaka.util.Error(
|
|
||||||
shaka.util.Error.Severity.RECOVERABLE,
|
|
||||||
shaka.util.Error.Category.NETWORK,
|
|
||||||
shaka.util.Error.Code.OPERATION_ABORTED,
|
|
||||||
uri, requestType
|
|
||||||
);
|
|
||||||
} else if (abortStatus.timedOut) {
|
|
||||||
throw new shaka.util.Error(
|
|
||||||
shaka.util.Error.Severity.RECOVERABLE,
|
|
||||||
shaka.util.Error.Category.NETWORK,
|
|
||||||
shaka.util.Error.Code.TIMEOUT,
|
|
||||||
uri, requestType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new shaka.util.Error(
|
|
||||||
shaka.util.Error.Severity.RECOVERABLE,
|
|
||||||
shaka.util.Error.Category.NETWORK,
|
|
||||||
shaka.util.Error.Code.HTTP_ERROR,
|
|
||||||
uri, error, requestType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check that player is initialized
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.checkPlayerStatus = function() {
|
|
||||||
if (!this.player) {
|
|
||||||
throw new Error('Player not initialized');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current playback time
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.getPlayerTime = function() {
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
var mediaElement = this.player.getMediaElement();
|
|
||||||
return mediaElement ? mediaElement.currentTime : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current playback rate
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.getPlaybackRate = function() {
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
return this.player.getPlaybackRate();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bandwidth estimate
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.getBandwidthEstimate = function() {
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
return this.player.getStats().estimatedBandwidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get active track formats
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.getActiveTrackFormats = function(activeFormat, sabrFormats) {
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
|
|
||||||
// Check if FormatKeyUtils is available
|
|
||||||
if (typeof FormatKeyUtils === 'undefined' || !FormatKeyUtils.getUniqueFormatId) {
|
|
||||||
return { videoFormat: undefined, audioFormat: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeVariant = this.player.getVariantTracks().find(function(track) {
|
|
||||||
return FormatKeyUtils.getUniqueFormatId(activeFormat) === (activeFormat.width ? track.originalVideoId : track.originalAudioId);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!activeVariant) {
|
|
||||||
return { videoFormat: undefined, audioFormat: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
var formatMap = new Map(sabrFormats.map(function(format) {
|
|
||||||
return [FormatKeyUtils.getUniqueFormatId(format), format];
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoFormat: activeVariant.originalVideoId ? formatMap.get(activeVariant.originalVideoId) : undefined,
|
|
||||||
audioFormat: activeVariant.originalAudioId ? formatMap.get(activeVariant.originalAudioId) : undefined
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register request interceptor
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.registerRequestInterceptor = function(interceptor) {
|
|
||||||
console.log('[ShakaPlayerAdapter] registerRequestInterceptor() called');
|
|
||||||
var self = this;
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
|
|
||||||
var networkingEngine = this.player.getNetworkingEngine();
|
|
||||||
if (!networkingEngine) {
|
|
||||||
console.warn('[ShakaPlayerAdapter] No networking engine available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('[ShakaPlayerAdapter] Got networking engine, registering filter');
|
|
||||||
|
|
||||||
this.requestFilter = async function(type, request, context) {
|
|
||||||
console.log('[ShakaPlayerAdapter] Request filter called:', { type: type, uri: request.uris[0] });
|
|
||||||
|
|
||||||
// Check if this is a SEGMENT request that needs processing
|
|
||||||
// Process sabr:// URLs (need conversion) and googlevideo URLs (already converted)
|
|
||||||
var uri = request.uris[0];
|
|
||||||
var isSabrUrl = uri.startsWith('sabr://');
|
|
||||||
var isGoogleVideo = isGoogleVideoURL(uri);
|
|
||||||
|
|
||||||
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || (!isSabrUrl && !isGoogleVideo)) {
|
|
||||||
console.log('[ShakaPlayerAdapter] Skipping request (not segment or not sabr/googlevideo):', uri);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ShakaPlayerAdapter] Calling interceptor for URL:', request.uris[0]);
|
|
||||||
try {
|
|
||||||
var modifiedRequest = await interceptor({
|
|
||||||
headers: request.headers,
|
|
||||||
url: request.uris[0],
|
|
||||||
method: request.method,
|
|
||||||
segment: {
|
|
||||||
getStartTime: function() { return context?.segment?.getStartTime() ?? null; },
|
|
||||||
isInit: function() { return !context?.segment; }
|
|
||||||
},
|
|
||||||
body: request.body
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[ShakaPlayerAdapter] Interceptor returned:', modifiedRequest);
|
|
||||||
|
|
||||||
if (modifiedRequest) {
|
|
||||||
console.log('[ShakaPlayerAdapter] Request modified:', { oldUrl: request.uris[0], newUrl: modifiedRequest.url });
|
|
||||||
request.uris = modifiedRequest.url ? [modifiedRequest.url] : request.uris;
|
|
||||||
request.method = modifiedRequest.method || request.method;
|
|
||||||
request.headers = modifiedRequest.headers || request.headers;
|
|
||||||
request.body = modifiedRequest.body || request.body;
|
|
||||||
} else {
|
|
||||||
console.warn('[ShakaPlayerAdapter] Interceptor returned null/undefined for:', request.uris[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ShakaPlayerAdapter] Interceptor error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
networkingEngine.registerRequestFilter(this.requestFilter);
|
|
||||||
console.log('[ShakaPlayerAdapter] Request filter registered');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register response interceptor
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.registerResponseInterceptor = function(interceptor) {
|
|
||||||
var self = this;
|
|
||||||
this.checkPlayerStatus();
|
|
||||||
|
|
||||||
var networkingEngine = this.player.getNetworkingEngine();
|
|
||||||
if (!networkingEngine) return;
|
|
||||||
|
|
||||||
this.responseFilter = async function(type, response, context) {
|
|
||||||
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(response.uri)) return;
|
|
||||||
|
|
||||||
var modifiedResponse = await interceptor({
|
|
||||||
url: response.originalRequest.uris[0],
|
|
||||||
method: response.originalRequest.method,
|
|
||||||
headers: response.headers,
|
|
||||||
data: response.data,
|
|
||||||
makeRequest: async function(url, headers) {
|
|
||||||
var retryParameters = self.player.getConfiguration().streaming.retryParameters;
|
|
||||||
var redirectRequest = shaka.net.NetworkingEngine.makeRequest([url], retryParameters);
|
|
||||||
Object.assign(redirectRequest.headers, headers);
|
|
||||||
|
|
||||||
var requestOperation = networkingEngine.request(type, redirectRequest, context);
|
|
||||||
var redirectResponse = await requestOperation.promise;
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: redirectResponse.uri,
|
|
||||||
method: redirectResponse.originalRequest.method,
|
|
||||||
headers: redirectResponse.headers,
|
|
||||||
data: redirectResponse.data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (modifiedResponse) {
|
|
||||||
response.data = modifiedResponse.data ?? response.data;
|
|
||||||
Object.assign(response.headers, modifiedResponse.headers);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
networkingEngine.registerResponseFilter(this.responseFilter);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Shaka response object
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.createShakaResponse = function(args) {
|
|
||||||
return makeResponse(
|
|
||||||
headersToGenericObject(args.response.headers),
|
|
||||||
args.arrayBuffer || new ArrayBuffer(0),
|
|
||||||
args.response.status,
|
|
||||||
args.uri,
|
|
||||||
args.response.url,
|
|
||||||
args.request,
|
|
||||||
args.requestType
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose of the adapter
|
|
||||||
*/
|
|
||||||
ShakaPlayerAdapter.prototype.dispose = function() {
|
|
||||||
if (this.abortController) {
|
|
||||||
this.abortController.abort();
|
|
||||||
this.abortController = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.player) {
|
|
||||||
var networkingEngine = this.player.getNetworkingEngine();
|
|
||||||
|
|
||||||
if (networkingEngine) {
|
|
||||||
if (this.requestFilter) {
|
|
||||||
networkingEngine.unregisterRequestFilter(this.requestFilter);
|
|
||||||
}
|
|
||||||
if (this.responseFilter) {
|
|
||||||
networkingEngine.unregisterResponseFilter(this.responseFilter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
shaka.net.NetworkingEngine.unregisterScheme('http');
|
|
||||||
shaka.net.NetworkingEngine.unregisterScheme('https');
|
|
||||||
|
|
||||||
this.player = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return ShakaPlayerAdapter;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Export for use
|
|
||||||
window.ShakaPlayerAdapter = ShakaPlayerAdapter;
|
|
||||||
191
assets/js/sabr_webm_index.js
Normal file
191
assets/js/sabr_webm_index.js
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
// 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) {
|
||||||
|
var 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;
|
||||||
|
})();
|
||||||
17
package-lock.json
generated
17
package-lock.json
generated
@ -6,9 +6,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "invidious-sabr",
|
"name": "invidious-sabr",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bgutils-js": "^3.1.3",
|
"bgutils-js": "^3.2.0",
|
||||||
"googlevideo": "^4.0.4",
|
"googlevideo": "^4.0.4",
|
||||||
"youtubei.js": "^16.0.0"
|
"youtubei.js": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.2"
|
"esbuild": "^0.27.2"
|
||||||
@ -513,6 +513,12 @@
|
|||||||
"@esbuild/win32-x64": "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": {
|
"node_modules/googlevideo": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
|
||||||
@ -535,15 +541,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/youtubei.js": {
|
"node_modules/youtubei.js": {
|
||||||
"version": "16.0.1",
|
"version": "17.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.2.0.tgz",
|
||||||
"integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==",
|
"integrity": "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/LuanRT"
|
"https://github.com/sponsors/LuanRT"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.0.0",
|
"@bufbuild/protobuf": "^2.0.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"meriyah": "^6.1.4"
|
"meriyah": "^6.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"googlevideo": "^4.0.4",
|
"googlevideo": "^4.0.4",
|
||||||
"youtubei.js": "^16.0.0",
|
"youtubei.js": "^17.0.1",
|
||||||
"bgutils-js": "^3.1.3"
|
"bgutils-js": "^3.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.2"
|
"esbuild": "^0.27.2"
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
# SABR (Server ABR) streaming dependencies
|
|
||||||
# These are fetched from esm.sh as pre-built ESM bundles
|
|
||||||
|
|
||||||
shaka-player:
|
|
||||||
version: 4.16.4
|
|
||||||
# esm.sh URL: https://esm.sh/shaka-player@4.16.4/dist/shaka-player.compiled.js
|
|
||||||
files:
|
|
||||||
- src: "dist/shaka-player.compiled.js"
|
|
||||||
dest: "shaka-player.js"
|
|
||||||
- src: "dist/controls.css"
|
|
||||||
dest: "shaka-player.css"
|
|
||||||
|
|
||||||
googlevideo:
|
|
||||||
version: 4.0.4
|
|
||||||
# Pre-bundled ESM from esm.sh
|
|
||||||
|
|
||||||
youtubei.js:
|
|
||||||
version: 16.0.1
|
|
||||||
# Pre-bundled ESM from esm.sh (web bundle)
|
|
||||||
|
|
||||||
bgutils-js:
|
|
||||||
version: 3.2.0
|
|
||||||
# Pre-bundled ESM from esm.sh
|
|
||||||
@ -21,30 +21,43 @@ fs.mkdirSync(path.join(outputDir, 'googlevideo'), { recursive: true });
|
|||||||
fs.mkdirSync(path.join(outputDir, 'youtubei.js'), { recursive: true });
|
fs.mkdirSync(path.join(outputDir, 'youtubei.js'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(outputDir, 'bgutils-js'), { recursive: true });
|
fs.mkdirSync(path.join(outputDir, 'bgutils-js'), { recursive: true });
|
||||||
|
|
||||||
// Create a custom entry point that re-exports everything from googlevideo
|
// 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 = `
|
const googlevideoEntryContent = `
|
||||||
export * from 'googlevideo/sabr-streaming-adapter';
|
import * as utils from 'googlevideo/utils';
|
||||||
export * from 'googlevideo/ump';
|
import * as ump from 'googlevideo/ump';
|
||||||
export * from 'googlevideo/utils';
|
import * as protos from 'googlevideo/protos';
|
||||||
export * 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');
|
const googlevideoEntryPath = path.join(__dirname, 'temp-googlevideo-entry.js');
|
||||||
fs.writeFileSync(googlevideoEntryPath, googlevideoEntryContent);
|
fs.writeFileSync(googlevideoEntryPath, googlevideoEntryContent);
|
||||||
|
|
||||||
// Bundle googlevideo with all exports
|
const commonEsbuildOptions = {
|
||||||
esbuild.build({
|
|
||||||
entryPoints: [googlevideoEntryPath],
|
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
outfile: path.join(outputDir, 'googlevideo/googlevideo.bundle.min.js'),
|
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
external: [],
|
external: [],
|
||||||
nodePaths: [nodeModules],
|
nodePaths: [nodeModules],
|
||||||
banner: {
|
platform: 'browser',
|
||||||
js: '// googlevideo library - bundled with esbuild'
|
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(() => {
|
}).then(() => {
|
||||||
console.log('✓ googlevideo bundled successfully');
|
console.log('✓ googlevideo bundled successfully');
|
||||||
fs.unlinkSync(googlevideoEntryPath);
|
fs.unlinkSync(googlevideoEntryPath);
|
||||||
@ -54,37 +67,16 @@ esbuild.build({
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bundle youtubei.js
|
// youtubei.js is fetched as a pre-built browser bundle from jsDelivr by
|
||||||
esbuild.build({
|
// scripts/fetch-sabr-dependencies.cr (not bundled here), to avoid re-bundling
|
||||||
entryPoints: [path.join(nodeModules, 'youtubei.js/bundle/browser.js')],
|
// a large lib and clobbering the pre-built artifact.
|
||||||
bundle: true,
|
|
||||||
format: 'esm',
|
|
||||||
outfile: path.join(outputDir, 'youtubei.js/youtubei.bundle.min.js'),
|
|
||||||
minify: true,
|
|
||||||
sourcemap: false,
|
|
||||||
external: [],
|
|
||||||
banner: {
|
|
||||||
js: '// youtubei.js library - bundled with esbuild'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
console.log('✓ youtubei.js bundled successfully');
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error('✗ youtubei.js bundling failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bundle bgutils-js
|
// Bundle bgutils-js
|
||||||
esbuild.build({
|
esbuild.build({
|
||||||
|
...commonEsbuildOptions,
|
||||||
entryPoints: [path.join(nodeModules, 'bgutils-js/dist/index.js')],
|
entryPoints: [path.join(nodeModules, 'bgutils-js/dist/index.js')],
|
||||||
bundle: true,
|
|
||||||
format: 'esm',
|
|
||||||
outfile: path.join(outputDir, 'bgutils-js/bgutils.bundle.min.js'),
|
outfile: path.join(outputDir, 'bgutils-js/bgutils.bundle.min.js'),
|
||||||
minify: true,
|
banner: { js: '// bgutils-js library - bundled with esbuild' }
|
||||||
sourcemap: false,
|
|
||||||
external: [],
|
|
||||||
banner: {
|
|
||||||
js: '// bgutils-js library - bundled with esbuild'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
console.log('✓ bgutils-js bundled successfully');
|
console.log('✓ bgutils-js bundled successfully');
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
|
|||||||
@ -9,30 +9,19 @@ require "colorize"
|
|||||||
|
|
||||||
SABR_DEPENDENCIES = {
|
SABR_DEPENDENCIES = {
|
||||||
"shaka-player" => {
|
"shaka-player" => {
|
||||||
"version" => "4.16.4",
|
"version" => "5.1.10",
|
||||||
"files" => [
|
"files" => [
|
||||||
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
|
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
|
||||||
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/controls.css", "dest" => "controls.css"},
|
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/controls.css", "dest" => "controls.css"},
|
||||||
]
|
|
||||||
},
|
|
||||||
"googlevideo" => {
|
|
||||||
"version" => "4.0.4",
|
|
||||||
"files" => [
|
|
||||||
# esm.sh bundled version - fetch full bundle path directly
|
|
||||||
{"url" => "https://esm.sh/googlevideo@4.0.4/es2022/sabr-streaming-adapter.bundle.mjs", "dest" => "googlevideo.bundle.min.js"},
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
# 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" => {
|
"youtubei.js" => {
|
||||||
"version" => "16.0.1",
|
"version" => "17.0.1",
|
||||||
"files" => [
|
"files" => [
|
||||||
# Use the web bundle from jsdelivr (pre-built by youtubei.js)
|
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@17.0.1/bundle/browser.js", "dest" => "youtubei.bundle.min.js"},
|
||||||
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@16.0.1/bundle/browser.min.js", "dest" => "youtubei.bundle.min.js"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"bgutils-js" => {
|
|
||||||
"version" => "3.2.0",
|
|
||||||
"files" => [
|
|
||||||
{"url" => "https://esm.sh/bgutils-js@3.2.0/es2022/bgutils-js.bundle.mjs", "dest" => "bgutils.bundle.min.js"},
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -135,49 +124,7 @@ if File.exists?(shaka_css_path)
|
|||||||
puts "#{"Patched".colorize(:green)} Shaka CSS to use system fonts"
|
puts "#{"Patched".colorize(:green)} Shaka CSS to use system fonts"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Post-process: Patch googlevideo bundle to remove esm.sh import and add process shim
|
# Note: googlevideo and bgutils-js are now bundled locally from npm via
|
||||||
googlevideo_path = "#{sabr_dir}/googlevideo/googlevideo.bundle.min.js"
|
# scripts/bundle-sabr-libs.js (esbuild, platform: 'browser', with
|
||||||
if File.exists?(googlevideo_path)
|
# process.env.NODE_ENV defined to 'production'). No esm.sh fetch or
|
||||||
js_content = File.read(googlevideo_path)
|
# post-patching is required.
|
||||||
|
|
||||||
# Add process shim at the beginning and remove the esm.sh import
|
|
||||||
process_shim = <<-JS
|
|
||||||
// Browser-compatible process shim for googlevideo
|
|
||||||
var __Process$ = { env: {} };
|
|
||||||
|
|
||||||
JS
|
|
||||||
|
|
||||||
# Remove the esm.sh import line: import __Process$ from "/node/process.mjs";
|
|
||||||
js_content = js_content.gsub(/import\s+__Process\$\s+from\s*["'][^"']+["'];?\s*/, "")
|
|
||||||
|
|
||||||
# Prepend the shim
|
|
||||||
js_content = process_shim + js_content
|
|
||||||
|
|
||||||
File.write(googlevideo_path, js_content)
|
|
||||||
puts "#{"Patched".colorize(:green)} googlevideo bundle with process shim"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Post-process: Patch bgutils-js bundle to be self-contained
|
|
||||||
bgutils_path = "#{sabr_dir}/bgutils-js/bgutils.bundle.min.js"
|
|
||||||
if File.exists?(bgutils_path)
|
|
||||||
js_content = File.read(bgutils_path)
|
|
||||||
|
|
||||||
# Check if it's just an export redirect and fetch the actual bundle
|
|
||||||
if js_content.includes?("export * from")
|
|
||||||
# The esm.sh bundle is just a redirect, we need the actual content
|
|
||||||
puts "#{"Info".colorize(:yellow)} bgutils bundle is a redirect, fetching actual content..."
|
|
||||||
|
|
||||||
# Extract the actual path from: export * from "/bgutils-js@3.1.3/es2022/bgutils-js.bundle.mjs";
|
|
||||||
if match = js_content.match(/export \* from ["']([^"']+)["']/)
|
|
||||||
actual_path = match[1]
|
|
||||||
actual_url = "https://esm.sh#{actual_path}"
|
|
||||||
|
|
||||||
HTTP::Client.get(actual_url) do |response|
|
|
||||||
if response.status_code == 200
|
|
||||||
File.write(bgutils_path, response.body_io.gets_to_end)
|
|
||||||
puts "#{"Fetched".colorize(:green)} actual bgutils bundle"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@ -84,7 +84,6 @@ module Invidious::Routes::Proxy
|
|||||||
custom_headers = HTTP::Headers.new
|
custom_headers = HTTP::Headers.new
|
||||||
if headers_param = query_params["__headers"]?
|
if headers_param = query_params["__headers"]?
|
||||||
begin
|
begin
|
||||||
puts "[DEBUG] Proxy: Parsing __headers parameter: #{headers_param}"
|
|
||||||
headers_array = JSON.parse(headers_param).as_a
|
headers_array = JSON.parse(headers_param).as_a
|
||||||
headers_array.each do |header|
|
headers_array.each do |header|
|
||||||
# header is a JSON::Any, need to extract as array
|
# header is a JSON::Any, need to extract as array
|
||||||
@ -93,11 +92,10 @@ module Invidious::Routes::Proxy
|
|||||||
value = header_array[1]?.try &.as_s
|
value = header_array[1]?.try &.as_s
|
||||||
if name && value
|
if name && value
|
||||||
custom_headers[name] = value
|
custom_headers[name] = value
|
||||||
puts "[DEBUG] Proxy: Adding custom header #{name}: #{value}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
# Ignore malformed headers but log the error
|
# Ignore malformed headers
|
||||||
puts "[WARN] Proxy: Failed to parse __headers: #{ex.message}"
|
puts "[WARN] Proxy: Failed to parse __headers: #{ex.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -121,14 +119,10 @@ module Invidious::Routes::Proxy
|
|||||||
request_headers = HTTP::Headers.new
|
request_headers = HTTP::Headers.new
|
||||||
|
|
||||||
# Copy custom headers
|
# Copy custom headers
|
||||||
puts "[DEBUG] Proxy: custom_headers size: #{custom_headers.size}"
|
|
||||||
custom_headers.each do |key, values|
|
custom_headers.each do |key, values|
|
||||||
# HTTP::Headers stores values as arrays, get the first value
|
|
||||||
value = values.is_a?(Array) ? values.first : values.to_s
|
value = values.is_a?(Array) ? values.first : values.to_s
|
||||||
puts "[DEBUG] Proxy: Forwarding header #{key}: #{value}"
|
|
||||||
request_headers[key] = value
|
request_headers[key] = value
|
||||||
end
|
end
|
||||||
puts "[DEBUG] Proxy: request_headers size after copying: #{request_headers.size}"
|
|
||||||
|
|
||||||
# Copy range header from original request
|
# Copy range header from original request
|
||||||
if range = env.request.headers["Range"]?
|
if range = env.request.headers["Range"]?
|
||||||
@ -139,25 +133,21 @@ module Invidious::Routes::Proxy
|
|||||||
if content_type = env.request.headers["Content-Type"]?
|
if content_type = env.request.headers["Content-Type"]?
|
||||||
if !request_headers["Content-Type"]?
|
if !request_headers["Content-Type"]?
|
||||||
request_headers["Content-Type"] = content_type
|
request_headers["Content-Type"] = content_type
|
||||||
puts "[DEBUG] Proxy: Copied Content-Type from request: #{content_type}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy user-agent if not already set
|
# Copy user-agent if not already set
|
||||||
if !request_headers["User-Agent"]? && env.request.headers["User-Agent"]?
|
if !request_headers["User-Agent"]? && env.request.headers["User-Agent"]?
|
||||||
request_headers["User-Agent"] = env.request.headers["User-Agent"]
|
request_headers["User-Agent"] = env.request.headers["User-Agent"]
|
||||||
puts "[DEBUG] Proxy: Copied User-Agent from request: #{request_headers["User-Agent"]}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set origin/referer for YouTube and Google domains (only if not already set)
|
# 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 target_host.includes?("youtube") || target_host.includes?("googlevideo") || target_host.includes?("googleapis")
|
||||||
if !request_headers["Origin"]?
|
if !request_headers["Origin"]?
|
||||||
request_headers["Origin"] = "https://www.youtube.com"
|
request_headers["Origin"] = "https://www.youtube.com"
|
||||||
puts "[DEBUG] Proxy: Set Origin header"
|
|
||||||
end
|
end
|
||||||
if !request_headers["Referer"]?
|
if !request_headers["Referer"]?
|
||||||
request_headers["Referer"] = "https://www.youtube.com/"
|
request_headers["Referer"] = "https://www.youtube.com/"
|
||||||
puts "[DEBUG] Proxy: Set Referer header"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -165,7 +155,12 @@ module Invidious::Routes::Proxy
|
|||||||
# YouTube requires application/x-protobuf for SABR videoplayback requests
|
# YouTube requires application/x-protobuf for SABR videoplayback requests
|
||||||
if env.request.method == "POST" && target_url.path.includes?("videoplayback")
|
if env.request.method == "POST" && target_url.path.includes?("videoplayback")
|
||||||
request_headers["Content-Type"] = "application/x-protobuf"
|
request_headers["Content-Type"] = "application/x-protobuf"
|
||||||
puts "[DEBUG] Proxy: Set Content-Type: application/x-protobuf for videoplayback POST"
|
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
|
end
|
||||||
|
|
||||||
# Copy authorization if present
|
# Copy authorization if present
|
||||||
@ -173,59 +168,63 @@ module Invidious::Routes::Proxy
|
|||||||
request_headers["Authorization"] = auth
|
request_headers["Authorization"] = auth
|
||||||
end
|
end
|
||||||
|
|
||||||
# Final debug output showing all headers being sent
|
# Make the proxied request, streaming the upstream body straight to the
|
||||||
puts "[DEBUG] Proxy: Final headers to send: #{request_headers.to_a.map { |k, v| "#{k}: #{v[0..50]}..." }.join(", ")}"
|
# client instead of buffering response.body. This is required so the SABR
|
||||||
|
# scheme plugin can read UMP parts incrementally and abort a fetch
|
||||||
# Make the proxied request
|
# mid-stream (backoff / reload / seek).
|
||||||
begin
|
begin
|
||||||
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
|
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
|
||||||
client.connect_timeout = 10.seconds
|
client.connect_timeout = 10.seconds
|
||||||
client.read_timeout = 30.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
|
||||||
|
|
||||||
case env.request.method
|
method = env.request.method
|
||||||
when "GET"
|
case method
|
||||||
response = client.get(target_url.request_target, headers: request_headers)
|
when "GET", "POST"
|
||||||
when "POST"
|
# Build a raw request so we control streaming + binary POST body.
|
||||||
# Read body as binary Slice to preserve protobuf data integrity
|
request = HTTP::Request.new(method, target_url.request_target, request_headers)
|
||||||
|
if method == "POST"
|
||||||
body_io = env.request.body
|
body_io = env.request.body
|
||||||
body_bytes : Bytes? = nil
|
body_bytes : Bytes? = nil
|
||||||
if body_io
|
if body_io
|
||||||
body_bytes = body_io.getb_to_end
|
body_bytes = body_io.getb_to_end
|
||||||
end
|
end
|
||||||
body_size = body_bytes.try(&.size) || 0
|
if body_bytes
|
||||||
puts "[DEBUG] Proxy: POST body size: #{body_size} bytes (binary)"
|
request.body = body_bytes
|
||||||
puts "[DEBUG] Proxy: POST target: #{target_url.request_target[0..200]}"
|
request.content_length = body_bytes.size
|
||||||
response = client.post(target_url.request_target, headers: request_headers, body: body_bytes)
|
end
|
||||||
puts "[DEBUG] Proxy: Response status: #{response.status_code}, content-type: #{response.headers["content-type"]?}"
|
|
||||||
else
|
|
||||||
env.response.status_code = 405
|
|
||||||
return "Method not allowed"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set response status
|
client.exec(request) do |response|
|
||||||
env.response.status_code = response.status_code
|
env.response.status_code = response.status_code
|
||||||
|
|
||||||
# Copy content headers
|
|
||||||
CONTENT_HEADERS.each do |header|
|
CONTENT_HEADERS.each do |header|
|
||||||
if value = response.headers[header]?
|
if value = response.headers[header]?
|
||||||
env.response.headers[header] = value
|
env.response.headers[header] = value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add CORS headers
|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = origin
|
env.response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
|
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-Methods"] = "GET, POST, OPTIONS"
|
||||||
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
env.response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
|
||||||
# Return the response body
|
# Stream the upstream body through without buffering, so the client's
|
||||||
# Wrap in error handling to suppress "Broken pipe" errors when client disconnects
|
# UmpReader can consume parts as they arrive and so backoff/abort
|
||||||
|
# actually cancels the transfer.
|
||||||
begin
|
begin
|
||||||
response.body
|
IO.copy(response.body_io, env.response.output)
|
||||||
|
env.response.output.flush
|
||||||
rescue ex : IO::Error
|
rescue ex : IO::Error
|
||||||
# Client disconnected (common during seeking/quality changes) - this is expected
|
# Client disconnected mid-stream (seek / quality change / abort).
|
||||||
puts "[DEBUG] Proxy: Client disconnected (#{ex.message})"
|
# This is expected, just stop copying.
|
||||||
""
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
env.response.status_code = 405
|
||||||
|
return "Method not allowed"
|
||||||
end
|
end
|
||||||
rescue ex
|
rescue ex
|
||||||
env.response.status_code = 502
|
env.response.status_code = 502
|
||||||
|
|||||||
@ -87,10 +87,12 @@ module Invidious::Videos::Parser
|
|||||||
begin
|
begin
|
||||||
params = self.parse_video_info(video_id, player_response)
|
params = self.parse_video_info(video_id, player_response)
|
||||||
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
|
||||||
params["reason"] = JSON::Any.new("Video unavailable")
|
# We have metadata from /next but no server-side streaming data (no
|
||||||
params["subreason"] = JSON::Any.new("Invidious Companion is not available. Video playback is not possible. \
|
# companion). Don't treat this as a hard error: the watch page still
|
||||||
If you are the administrator, install Invidious Companion: \
|
# renders, and for quality=sabr the browser SABR player fetches the
|
||||||
<a href=\"https://docs.invidious.io/installation/\">https://docs.invidious.io/installation/</a>")
|
# 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
|
return params
|
||||||
rescue ex
|
rescue ex
|
||||||
LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}")
|
LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}")
|
||||||
|
|||||||
@ -114,9 +114,15 @@
|
|||||||
<link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>">
|
||||||
<link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>">
|
||||||
<script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<!-- SABR engine ports (FreeTube): plain scripts reading shaka/googlevideo from window globals -->
|
||||||
|
<script src="/js/sabr_ebml_parser.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_mp4_index.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_webm_index.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_manifest_parser.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<script src="/js/sabr_scheme_plugin.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
|
<!-- SABR orchestrator + helpers -->
|
||||||
<script src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script src="/js/sabr_shaka_adapter.js?v=<%= ASSET_COMMIT %>"></script>
|
|
||||||
<script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
|
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user