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:
Emilien 2026-06-28 14:35:33 +02:00
parent a1d0a3ceb2
commit 6ba2fe79f8
23 changed files with 4659 additions and 3674 deletions

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -3,30 +3,27 @@
*
* All dependencies are ES modules:
* - 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
*
* 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 from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { Platform } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import * as googlevideo from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import Innertube, { Platform, Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
// Expose all SABR-related functions to window
// youtubei.js
window.Innertube = Innertube;
window.Platform = Platform;
window.Constants = Constants;
window.SabrStreamingAdapter = googlevideo.SabrStreamingAdapter;
window.SabrUmpProcessor = googlevideo.SabrUmpProcessor;
window.buildSabrFormat = googlevideo.buildSabrFormat;
window.FormatKeyUtils = googlevideo.FormatKeyUtils;
window.UmpUtils = googlevideo.UmpUtils;
window.SABR_CONSTANTS = googlevideo.SABR_CONSTANTS;
window.isGoogleVideoURL = googlevideo.isGoogleVideoURL;
// googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
window.googlevideo = googlevideo;
// BotGuard
window.BG = BG;
// Signal that all SABR libraries are loaded and ready
console.info('[SABR Loader]', 'All SABR libraries loaded');
window.dispatchEvent(new Event('sabr-libs-loaded'));

View File

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

109
assets/js/sabr_mp4_index.js Normal file
View 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;
})();

View File

@ -1,18 +1,18 @@
/**
* SABR Player - Main player initialization for SABR streaming
* Ported from Kira project (https://github.com/LuanRT/kira)
*
* This module provides:
* - Innertube API initialization
* - Onesie config fetching
* - Shaka Player setup with SABR adapter
* - Video playback management
* Re-engineered (from the kira-based POC) to use FreeTube's SABR engine:
* - Builds a data:application/sabr+json manifest from the youtube.js VideoInfo
* - Registers FreeTube's sabr: networking scheme (sabr_scheme_plugin.js)
* - Drives Shaka 5 via the application/sabr+json manifest parser
*
* PoToken is minted browser-side via bgutils-js BotGuard and proxied through
* the Invidious /proxy route for CSP compliance.
*/
'use strict';
var SABRPlayer = (function () {
// Constants
var VOLUME_KEY = 'youtube_player_volume';
var PLAYBACK_POSITION_KEY = 'youtube_playback_positions';
var SAVE_POSITION_INTERVAL_MS = 5000;
@ -27,7 +27,6 @@ var SABRPlayer = (function() {
// State
var player = null;
var ui = null;
var sabrAdapter = null;
var videoElement = null;
var shakaContainer = null;
var currentVideoId = '';
@ -40,91 +39,58 @@ var SABRPlayer = (function() {
var playbackWebPoTokenContentBinding = null;
var playbackWebPoTokenCreationLock = false;
/**
* Get saved volume from localStorage
*/
var sabrStream = null; // handle returned by setupSabrScheme
var sabrManifest = null; // captured from player.getManifest() on 'loaded'
var currentLoadOptions = null; // for onReloadOnce -> re-run loadVideo
function getSavedVolume() {
try {
var volume = localStorage.getItem(VOLUME_KEY);
return volume ? parseFloat(volume) : 1;
} catch (error) {
return 1;
var v = localStorage.getItem(VOLUME_KEY);
return v ? parseFloat(v) : 1;
} catch (e) { return 1; }
}
}
/**
* Save volume to localStorage
*/
function saveVolume(volume) {
try {
localStorage.setItem(VOLUME_KEY, volume.toString());
} catch (error) {
// Ignore
try { localStorage.setItem(VOLUME_KEY, volume.toString()); } catch (e) {}
}
}
/**
* Get all saved playback positions
*/
function getPlaybackPositions() {
try {
var positions = localStorage.getItem(PLAYBACK_POSITION_KEY);
return positions ? JSON.parse(positions) : {};
} catch (error) {
return {};
var p = localStorage.getItem(PLAYBACK_POSITION_KEY);
return p ? JSON.parse(p) : {};
} catch (e) { return {}; }
}
}
/**
* Save playback position
*/
function savePlaybackPosition(videoId, time) {
if (!videoId || time < 1) return;
try {
var positions = getPlaybackPositions();
positions[videoId] = time;
localStorage.setItem(PLAYBACK_POSITION_KEY, JSON.stringify(positions));
} catch (error) {
// Ignore
} catch (e) {}
}
}
/**
* Get playback position for a video
*/
function getPlaybackPosition(videoId) {
var positions = getPlaybackPositions();
return positions[videoId] || 0;
}
/**
* Initialize Innertube API
*/
async function initInnertube() {
if (innertube) return innertube;
try {
console.info('[SABRPlayer]', 'Initializing InnerTube API');
// Check if Innertube is available from youtubei.js
if (typeof Innertube === 'undefined') {
throw new Error('youtubei.js not loaded');
function base64ToU8(base64) {
// base64ToU8 from googlevideo handles websafe, but for client config we need raw.
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// Set up Platform.shim.eval for URL deciphering (like Kira does)
// This is required because the browser bundle doesn't include Jinter
async function initInnertube() {
if (innertube) return innertube;
try {
console.info('[SABRPlayer]', 'Initializing InnerTube API');
if (typeof Innertube === 'undefined') throw new Error('youtubei.js not loaded');
// Set up Platform.shim.eval for URL deciphering (browser bundle lacks Jinter).
if (typeof Platform !== 'undefined' && Platform.shim) {
Platform.shim.eval = async function (data, env) {
var properties = [];
if (env.n) {
properties.push('n: exportedVars.nFunction("' + env.n + '")');
}
if (env.sig) {
properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
}
if (env.n) properties.push('n: exportedVars.nFunction("' + env.n + '")');
if (env.sig) properties.push('sig: exportedVars.sigFunction("' + env.sig + '")');
var code = data.output + '\nreturn { ' + properties.join(', ') + ' }';
return new Function(code)();
};
@ -133,29 +99,27 @@ var SABRPlayer = (function() {
console.warn('[SABRPlayer]', 'Platform.shim not available, URL deciphering may fail');
}
// Create Innertube with proxy fetch for CSP compliance
innertube = await Innertube.create({
fetch: SABRHelpers.fetchWithProxy,
retrieve_player: true,
generate_session_locally: true
});
// Initialize BotGuard for PoToken generation
// Kick off BotGuard init (don't block player setup).
BotguardService.init().then(function () {
console.info('[SABRPlayer]', 'BotGuard client initialized');
}).catch(function (err) {
console.warn('[SABRPlayer]', 'BotGuard initialization failed:', err.message);
});
// Preload the redirector URL
// Preload the redirector URL.
try {
var redirectorResponse = await SABRHelpers.fetchWithProxy(
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1E5),
'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1e5),
{ method: 'GET' }
);
var redirectorResponseUrl = await redirectorResponse.text();
if (redirectorResponseUrl.startsWith('https://')) {
if (redirectorResponseUrl.indexOf('https://') === 0) {
localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, redirectorResponseUrl);
}
} catch (e) {
@ -169,47 +133,23 @@ var SABRPlayer = (function() {
}
}
/**
* Fetch Onesie client config
*/
async function fetchOnesieConfig() {
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) {
return clientConfig;
}
// Try loading from cache
var cachedConfig = SABRHelpers.loadCachedClientConfig();
if (cachedConfig) {
clientConfig = cachedConfig;
return clientConfig;
}
if (clientConfig && SABRHelpers.isConfigValid(clientConfig)) return clientConfig;
var cached = SABRHelpers.loadCachedClientConfig();
if (cached) { clientConfig = cached; return clientConfig; }
try {
var tvConfigResponse = await SABRHelpers.fetchWithProxy(
'https://www.youtube.com/tv_config?action_get_config=true&client=lb4&theme=cl',
{
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version'
}
headers: { 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' }
}
);
var tvConfigText = await tvConfigResponse.text();
var tvConfigJson = JSON.parse(tvConfigText.slice(4));
var webPlayerContextConfig = tvConfigJson.webPlayerContextConfig.WEB_PLAYER_CONTEXT_CONFIG_ID_LIVING_ROOM_WATCH;
var onesieHotConfig = webPlayerContextConfig.onesieHotConfig;
// Helper to decode base64 to Uint8Array
function base64ToU8(base64) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
clientConfig = {
clientKeyData: base64ToU8(onesieHotConfig.clientKey),
keyExpiresInSeconds: onesieHotConfig.keyExpiresInSeconds,
@ -218,7 +158,6 @@ var SABRPlayer = (function() {
baseUrl: onesieHotConfig.baseUrl,
timestamp: Date.now()
};
SABRHelpers.saveCachedClientConfig(clientConfig);
return clientConfig;
} catch (error) {
@ -227,30 +166,18 @@ var SABRPlayer = (function() {
}
}
/**
* Mint content-bound PoToken
*/
async function mintContentWebPO() {
console.log('[SABRPlayer] mintContentWebPO called, binding:', playbackWebPoTokenContentBinding);
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) {
console.log('[SABRPlayer] mintContentWebPO skipped:', { binding: !!playbackWebPoTokenContentBinding, locked: playbackWebPoTokenCreationLock });
return;
}
if (!playbackWebPoTokenContentBinding || playbackWebPoTokenCreationLock) return;
playbackWebPoTokenCreationLock = true;
try {
coldStartToken = BotguardService.mintColdStartToken(playbackWebPoTokenContentBinding);
console.info('[SABRPlayer]', 'Cold start token created:', coldStartToken ? coldStartToken.substring(0, 30) + '...' : 'null');
if (!BotguardService.isInitialized()) {
console.log('[SABRPlayer] BotGuard not initialized, reinitializing...');
await BotguardService.reinit();
}
if (BotguardService.isInitialized()) {
playbackWebPoToken = await BotguardService.mintWebPoToken(
decodeURIComponent(playbackWebPoTokenContentBinding)
);
playbackWebPoToken = await BotguardService.mintWebPoToken(decodeURIComponent(playbackWebPoTokenContentBinding));
console.info('[SABRPlayer]', 'WebPO token created:', playbackWebPoToken ? playbackWebPoToken.substring(0, 30) + '...' : 'null');
} else {
console.warn('[SABRPlayer]', 'BotGuard still not initialized after reinit');
@ -262,15 +189,10 @@ var SABRPlayer = (function() {
}
}
/**
* Initialize Shaka Player
*/
async function initializeShakaPlayer(containerElement) {
async function initializeShakaPlayer(containerElement, listenMode) {
if (!shaka.Player.isBrowserSupported()) {
throw new Error('Shaka Player is not supported in this browser');
}
// Install polyfills
shaka.polyfill.installAll();
shakaContainer = document.createElement('div');
@ -288,7 +210,6 @@ var SABRPlayer = (function() {
containerElement.appendChild(shakaContainer);
player = new shaka.Player();
player.configure({
preferredAudioLanguage: 'en-US',
abr: DEFAULT_ABR_CONFIG,
@ -296,370 +217,390 @@ var SABRPlayer = (function() {
bufferingGoal: 120,
rebufferingGoal: 0.01,
bufferBehind: 300,
retryParameters: {
maxAttempts: 8,
fuzzFactor: 0.5,
timeout: 30 * 1000
}
}
retryParameters: { maxAttempts: 8, fuzzFactor: 0.5, timeout: 30 * 1000 }
},
// disableVideo is read by our SabrManifestParser to skip video streams for audio-only/listen mode.
disableVideo: !!listenMode
});
videoElement.volume = getSavedVolume();
videoElement.addEventListener('volumechange', function() {
saveVolume(videoElement.volume);
});
videoElement.addEventListener('volumechange', function () { saveVolume(videoElement.volume); });
videoElement.addEventListener('playing', function () {
player.configure('abr.restrictions.maxHeight', Infinity);
});
videoElement.addEventListener('pause', function () {
if (currentVideoId) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
if (currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
});
await player.attach(videoElement);
// Initialize UI if available
if (shaka.ui && shaka.ui.Overlay) {
ui = new shaka.ui.Overlay(player, shakaContainer, videoElement);
ui.configure({
addBigPlayButton: true,
overflowMenuButtons: [
'captions',
'quality',
'language',
'playback_rate',
'loop',
'picture_in_picture'
]
overflowMenuButtons: ['captions', 'quality', 'language', 'playback_rate', 'loop', 'picture_in_picture']
});
}
return player;
}
/**
* Initialize SABR adapter
*/
async function initializeSabrAdapter() {
if (!player || !innertube) return;
// Check if SabrStreamingAdapter is available from googlevideo
if (typeof SabrStreamingAdapter === 'undefined') {
console.error('[SABRPlayer]', 'googlevideo library not loaded');
return;
}
// Create the player adapter
var playerAdapter = new ShakaPlayerAdapter();
// Use YouTube.js ANDROID client constants
var androidClient = Constants.CLIENTS.ANDROID;
sabrAdapter = new SabrStreamingAdapter({
playerAdapter: playerAdapter,
clientInfo: {
osName: 'Android',
osVersion: androidClient.OS_VERSION || '14',
clientName: 3, // ANDROID - Used exclusively for SABR streaming requests
clientVersion: androidClient.VERSION
}
});
sabrAdapter.onMintPoToken(async function() {
console.log('[SABRPlayer] onMintPoToken callback invoked');
if (!playbackWebPoToken) {
if (isLive) {
await mintContentWebPO();
} else {
mintContentWebPO(); // Don't block for VOD
}
}
var token = playbackWebPoToken || coldStartToken || '';
console.log('[SABRPlayer] Returning token:', token ? 'token present (' + token.substring(0, 20) + '...)' : 'empty');
return token;
});
sabrAdapter.attach(player);
return sabrAdapter;
}
/**
* Setup request filters for proxying
*/
async function setupRequestFilters() {
var networkingEngine = player?.getNetworkingEngine();
// Route non-sabr: requests (captions, storyboards, image tiles) through the
// Invidious /proxy so they satisfy CSP. The sabr: scheme is handled
// separately by setupSabrScheme and also routes through /proxy internally.
function setupRequestFilters() {
var networkingEngine = player && player.getNetworkingEngine ? player.getNetworkingEngine() : null;
if (!networkingEngine) return;
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 url = new URL(request.uris[0]);
var isGoogleVideo = url.hostname.endsWith('.googlevideo.com') || url.hostname.indexOf('googlevideo') !== -1;
var isYouTube = url.hostname.endsWith('.youtube.com');
if (!isGoogleVideo && !isYouTube) return;
// Proxy googlevideo requests
if (url.host.endsWith('.googlevideo.com') || url.host.includes('youtube')) {
var newUrl = new URL(url.toString());
newUrl.searchParams.set('__host', url.host);
newUrl.host = config.PROXY_HOST;
newUrl.port = config.PROXY_PORT;
newUrl.protocol = config.PROXY_PROTOCOL + ':';
newUrl.pathname = '/proxy' + url.pathname;
// Add required headers for googlevideo requests to avoid 403
var proxyHeaders = [
['user-agent', navigator.userAgent],
['origin', 'https://www.youtube.com'],
['referer', 'https://www.youtube.com/']
];
// CRITICAL: Add content-type for POST requests (SABR videoplayback)
// This is REQUIRED for YouTube to accept the protobuf request body
if (request.body && request.body.byteLength > 0) {
proxyHeaders.push(['content-type', 'application/x-protobuf']);
console.log('[SABRPlayer] Adding content-type: application/x-protobuf for POST request');
}
// Add visitor ID if available from innertube session
if (innertube?.session?.context?.client?.visitorData) {
proxyHeaders.push(['x-goog-visitor-id', innertube.session.context.client.visitorData]);
}
// Add client name and version - Force ANDROID (3) for SABR requests
proxyHeaders.push(['x-youtube-client-name', '3']); // ANDROID
if (innertube?.session?.context?.client?.clientVersion) {
proxyHeaders.push(['x-youtube-client-version', innertube.session.context.client.clientVersion]);
}
newUrl.searchParams.set('__headers', JSON.stringify(proxyHeaders));
request.uris[0] = newUrl.toString();
}
// Reuse the shared proxy helper so __host/__headers are set consistently.
var proxied = SABRHelpers.proxyUrl(url, request.headers || {});
proxied.searchParams.set('alr', 'yes');
request.uris[0] = proxied.toString();
});
}
// Map a youtube.js Format to the SabrManifest "formats" entry shape expected
// by sabr_manifest_parser.js (port of FreeTube's SabrManifestParser).
function mapFormatToManifestEntry(fmt) {
var audioTrack = fmt.audio_track || null;
var colorInfo = fmt.color_info || null;
return {
itag: fmt.itag,
lastModified: fmt.last_modified_ms,
mimeType: fmt.mime_type,
xtags: fmt.xtags,
bitrate: fmt.bitrate,
initRange: fmt.init_range,
indexRange: fmt.index_range,
width: fmt.width,
height: fmt.height,
frameRate: fmt.fps,
quality: fmt.quality,
language: fmt.language,
audioSampleRate: fmt.audio_sample_rate,
audioChannels: fmt.audio_channels,
isDrc: fmt.is_drc,
isVoiceBoost: fmt.is_vb,
isOriginal: fmt.is_original,
isDubbed: fmt.is_dubbed,
isAutoDubbed: fmt.is_auto_dubbed,
isDescriptive: fmt.is_descriptive,
isSecondary: fmt.is_secondary,
spatialAudio: !!(fmt.spatial_audio_type),
label: audioTrack ? audioTrack.display_name : undefined,
colorTransferCharacteristics: colorInfo ? colorInfo.transfer_characteristics : undefined,
colorPrimaries: colorInfo ? colorInfo.primaries : undefined
};
}
function buildCaptions(videoInfo) {
var out = [];
var tracks = videoInfo.captions && videoInfo.captions.caption_tracks;
if (!tracks) return out;
for (var i = 0; i < tracks.length; i++) {
var c = tracks[i];
var url;
try {
url = new URL(c.base_url);
url.searchParams.set('fmt', 'vtt');
url = url.toString();
} catch (e) {
url = c.base_url;
}
out.push({
id: c.vss_id,
url: url,
label: c.name ? c.name.text : (c.language_code || ''),
language: c.language_code || 'und',
mimeType: 'text/vtt'
});
}
return out;
}
function buildStoryboards(videoInfo) {
var out = [];
var sb = videoInfo.storyboards;
if (!sb || sb.type !== 'PlayerStoryboardSpec') return out;
var boards = sb.boards || [];
// Pick the highest-res storyboard (matches FreeTube behaviour).
var board = boards.length ? boards[boards.length - 1] : null;
if (!board) return out;
out.push({
templateUrl: board.template_url,
mimeType: 'image/webp',
columns: board.columns,
rows: board.rows,
thumbnailCount: board.thumbnail_count,
thumbnailWidth: board.thumbnail_width,
thumbnailHeight: board.thumbnail_height,
storyboardCount: board.storyboard_count,
interval: board.interval > 0 ? board.interval / 1000 : 0
});
return out;
}
function buildChapters(videoInfo) {
var out = [];
var ch = videoInfo.chapters;
if (!ch || !ch.get) return out;
try {
var list = ch.get();
if (!list || !list.length) return out;
for (var i = 0; i < list.length; i++) {
var c = list[i];
out.push({
title: c.title || '',
startSeconds: c.start || 0,
endSeconds: c.end || (i + 1 < list.length ? (list[i + 1].start || 0) : 0)
});
}
} catch (e) {
// chapters not available for this video - harmless
}
return out;
}
// Build a data:application/sabr+json manifest URI from a youtube.js VideoInfo.
// Port of FreeTube's Watch.js#createLocalSabrManifest.
function buildSabrManifest(videoInfo, poToken, clientInfo) {
var streamingData = videoInfo.streaming_data;
if (!streamingData || !streamingData.server_abr_streaming_url || !streamingData.adaptive_formats) {
return null;
}
var url = new URL(streamingData.server_abr_streaming_url);
url.searchParams.set('alr', 'yes');
if (videoInfo.cpn) url.searchParams.set('cpn', videoInfo.cpn);
var sabrData = {
url: url.toString(),
poToken: poToken || '',
ustreamerConfig: videoInfo.player_config &&
videoInfo.player_config.media_common_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config &&
videoInfo.player_config.media_common_config.media_ustreamer_request_config.video_playback_ustreamer_config || '',
clientInfo: clientInfo
};
SABRPlayer._lastSabrData = sabrData;
var adaptiveFormats = streamingData.adaptive_formats;
var duration = Infinity;
for (var i = 0; i < adaptiveFormats.length; i++) {
var d = adaptiveFormats[i].approx_duration_ms;
if (typeof d === 'number' && d < duration) duration = d;
}
if (!isFinite(duration)) duration = (videoInfo.basic_info && videoInfo.basic_info.duration) ? videoInfo.basic_info.duration * 1000 : 0;
duration = duration / 1000;
var formats = [];
for (var j = 0; j < adaptiveFormats.length; j++) {
formats.push(mapFormatToManifestEntry(adaptiveFormats[j]));
}
var sabrManifest = {
duration: duration,
formats: formats,
captions: buildCaptions(videoInfo),
storyboards: buildStoryboards(videoInfo),
chapters: buildChapters(videoInfo)
};
return 'data:' + window.MANIFEST_TYPE_SABR + ',' + encodeURIComponent(JSON.stringify(sabrManifest));
}
function getPlayerWidth() { return videoElement ? videoElement.clientWidth : 1280; }
function getPlayerHeight() { return videoElement ? videoElement.clientHeight : 720; }
function getPlayer() { return player; }
function getManifest() { return sabrManifest; }
function wireSabrStream(loadFn) {
if (!sabrStream) return;
sabrStream.onBackoffRequested(function (info) {
var ms = info && info.backoffMs ? info.backoffMs : 0;
console.warn('[SABRPlayer] SABR backoff requested:', ms, 'ms');
var toast = document.querySelector('.sabr-backoff-toast');
if (!toast && shakaContainer) {
toast = document.createElement('div');
toast.className = 'sabr-backoff-toast';
toast.style.cssText = 'position:absolute;bottom:48px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;padding:6px 12px;border-radius:4px;font-size:13px;pointer-events:none;z-index:10';
shakaContainer.appendChild(toast);
}
if (toast) {
toast.textContent = 'SABR throttled by YouTube, retrying in ' + (ms / 1000).toFixed(1) + 's…';
clearTimeout(toast._t);
toast._t = setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, Math.max(ms + 500, 2000));
}
});
sabrStream.onReloadOnce(function () {
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) {
options = options || {};
currentVideoId = videoId;
currentLoadOptions = options;
playbackWebPoToken = null;
playbackWebPoTokenContentBinding = videoId;
try {
// Initialize components if needed
if (!innertube) {
innertube = await initInnertube();
if (!innertube) {
throw new Error('Failed to initialize Innertube');
if (!innertube) throw new Error('Failed to initialize Innertube');
}
if (!clientConfig) {
await fetchOnesieConfig();
}
if (!clientConfig) {
clientConfig = await fetchOnesieConfig();
}
// Mint a content-bound PoToken before getInfo so streaming_data includes
// the server_abr_streaming_url and so the SABR requests authenticate.
try { await mintContentWebPO(); } catch (e) { console.warn('[SABRPlayer] poToken mint failed, continuing', e); }
var poToken = playbackWebPoToken || coldStartToken || '';
if (!player) {
await initializeShakaPlayer(containerElement);
await initializeShakaPlayer(containerElement, options.listen);
} else {
// Reset player configuration
player.configure('abr', DEFAULT_ABR_CONFIG);
player.configure('disableVideo', !!options.listen);
}
if (!sabrAdapter) {
await initializeSabrAdapter();
}
setupRequestFilters();
await setupRequestFilters();
// Fetch video info using Innertube
var videoInfo = await innertube.getInfo(videoId);
if (!videoInfo || videoInfo.playability_status?.status !== 'OK') {
var reason = videoInfo?.playability_status?.reason || 'Unknown error';
var videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined });
if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') {
var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error';
throw new Error('Video unavailable: ' + reason);
}
isLive = !!(videoInfo.basic_info && videoInfo.basic_info.is_live);
isLive = !!videoInfo.basic_info?.is_live;
// Get streaming URL
var streamingData = videoInfo.streaming_data;
if (!streamingData) {
throw new Error('No streaming data available');
}
if (!streamingData) throw new Error('No streaming data available');
// Derive clientInfo from the innertube session (matches FreeTube's local.js).
var ctxClient = innertube.session && innertube.session.context && innertube.session.context.client;
var clientName = ctxClient ? ctxClient.clientName : 'ANDROID';
var clientInfo = {
clientName: (typeof Constants !== 'undefined' && Constants.CLIENT_NAME_IDS && Constants.CLIENT_NAME_IDS[clientName]) || 3,
clientVersion: ctxClient ? ctxClient.clientVersion : (Constants.CLIENTS && Constants.CLIENTS.ANDROID ? Constants.CLIENTS.ANDROID.VERSION : '19.09.37'),
osName: ctxClient ? ctxClient.osName : 'Android',
osVersion: ctxClient ? ctxClient.osVersion : '14'
};
var manifestUri;
if (isLive) {
// 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;
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 {
// For VOD, generate DASH manifest from adaptive formats
if (sabrAdapter && streamingData.server_abr_streaming_url) {
// SABR mode - need to decipher the server_abr_streaming_url first
var sabrUrl = streamingData.server_abr_streaming_url;
// Decipher the URL if the player has a decipher function
if (innertube?.session?.player?.decipher) {
if (streamingData.server_abr_streaming_url && innertube.session && innertube.session.player && innertube.session.player.decipher) {
try {
sabrUrl = await innertube.session.player.decipher(sabrUrl);
console.log('[SABRPlayer] Deciphered streaming URL:', sabrUrl?.substring(0, 100) + '...');
} catch (decipherErr) {
console.error('[SABRPlayer] Failed to decipher URL:', decipherErr);
// Try to use the raw URL anyway
console.warn('[SABRPlayer] Trying raw URL instead');
}
} else {
console.warn('[SABRPlayer] Player decipher not available, using raw URL');
}
console.log('[SABRPlayer] Setting streaming URL:', sabrUrl);
sabrAdapter.setStreamingURL(sabrUrl);
// Build SABR formats
console.log('[SABRPlayer] Checking SABR format requirements:', {
buildSabrFormat: typeof buildSabrFormat,
adaptive_formats: !!streamingData.adaptive_formats,
formats_length: streamingData.adaptive_formats?.length
});
if (typeof buildSabrFormat !== 'undefined' && streamingData.adaptive_formats) {
console.log('[SABRPlayer] Building SABR formats from', streamingData.adaptive_formats.length, 'adaptive formats');
var sabrFormats = streamingData.adaptive_formats.map(function(fmt) {
return buildSabrFormat(fmt);
}).filter(function(format) {
return !format.xtags;
});
console.log('[SABRPlayer] Setting', sabrFormats.length, 'SABR formats on adapter');
sabrAdapter.setServerAbrFormats(sabrFormats);
console.log('[SABRPlayer] SABR formats set successfully');
} else {
console.warn('[SABRPlayer] buildSabrFormat not available or no adaptive formats', {
buildSabrFormat: typeof buildSabrFormat,
has_adaptive_formats: !!streamingData.adaptive_formats
});
}
// Set ustreamer config
var ustreamerConfig = videoInfo.player_config?.media_common_config?.media_ustreamer_request_config?.video_playback_ustreamer_config;
if (ustreamerConfig) {
sabrAdapter.setUstreamerConfig(ustreamerConfig);
streamingData.server_abr_streaming_url = await innertube.session.player.decipher(streamingData.server_abr_streaming_url);
} catch (e) {
console.warn('[SABRPlayer] Failed to decipher server_abr_streaming_url', e);
}
}
// Generate DASH manifest
var dashManifest = await videoInfo.toDash({
manifest_options: {
is_sabr: !!sabrAdapter,
captions_format: 'vtt',
include_thumbnails: false
}
});
manifestUri = buildSabrManifest(videoInfo, poToken, clientInfo);
if (!manifestUri) {
// No SABR URL available - fall back to DASH.
var dashManifest = await videoInfo.toDash({ manifest_options: { captions_format: 'vtt', include_thumbnails: false } });
manifestUri = 'data:application/dash+xml;base64,' + btoa(dashManifest);
}
if (!manifestUri) {
throw new Error('Could not find a valid manifest URI');
}
// 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;
if (startTime === undefined && options.savePlayerPos !== false) {
startTime = getPlaybackPosition(videoId);
}
// Load the manifest
await player.load(manifestUri, isLive ? undefined : startTime);
var mimeType = (!isLive && manifestUri.indexOf('data:' + window.MANIFEST_TYPE_SABR) === 0)
? window.MANIFEST_TYPE_SABR
: undefined;
await player.load(manifestUri, isLive ? undefined : startTime, mimeType);
// Start playback
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) clearInterval(savePositionInterval);
savePositionInterval = setInterval(function () {
if (videoElement && currentVideoId && !videoElement.paused) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
}, SAVE_POSITION_INTERVAL_MS);
return {
player: player,
videoElement: videoElement,
videoInfo: videoInfo
};
return { player: player, videoElement: videoElement, videoInfo: videoInfo };
} catch (error) {
console.error('[SABRPlayer]', 'Error loading video:', error);
throw error;
}
}
/**
* Dispose of the player
*/
async function dispose() {
if (savePositionInterval) {
clearInterval(savePositionInterval);
savePositionInterval = null;
}
if (savePositionInterval) { clearInterval(savePositionInterval); savePositionInterval = null; }
if (videoElement && currentVideoId) savePlaybackPosition(currentVideoId, videoElement.currentTime);
if (videoElement && currentVideoId) {
savePlaybackPosition(currentVideoId, videoElement.currentTime);
}
if (sabrAdapter) {
sabrAdapter.dispose();
sabrAdapter = null;
}
if (player) {
await player.destroy();
player = null;
}
if (ui) {
ui.destroy();
ui = null;
}
if (shakaContainer && shakaContainer.parentNode) {
shakaContainer.parentNode.removeChild(shakaContainer);
}
if (sabrStream) { try { sabrStream.cleanup(); } catch (e) {} sabrStream = null; }
if (player) { await player.destroy(); player = null; }
if (ui) { ui.destroy(); ui = null; }
if (shakaContainer && shakaContainer.parentNode) shakaContainer.parentNode.removeChild(shakaContainer);
videoElement = null;
shakaContainer = null;
currentVideoId = '';
sabrManifest = null;
}
/**
* Get current player instance
*/
function getPlayer() {
return player;
}
/**
* Get current video element
*/
function getVideoElement() {
return videoElement;
}
function getPlayerInstance() { return player; }
function getVideoElement() { return videoElement; }
return {
loadVideo: loadVideo,
dispose: dispose,
getPlayer: getPlayer,
getPlayer: getPlayerInstance,
getVideoElement: getVideoElement,
initInnertube: initInnertube,
fetchOnesieConfig: fetchOnesieConfig
};
})();
// Export for use
window.SABRPlayer = SABRPlayer;

View File

@ -0,0 +1,711 @@
// Port of FreeTube's SabrSchemePlugin.js
// Registers the 'sabr' networking scheme with Shaka.
// Reads shaka from window.shaka, googlevideo symbols from window.googlevideo.
// Invidious is browser-sandboxed, so outbound fetches are routed through
// SABRHelpers.fetchWithProxy (the Crystal /proxy route) instead of hitting
// googlevideo directly.
// Fixes the latent SABR_REDIRECT bug (write sabrStreamState.sabrUrl, not currentState.sabrUrl).
// Exports window.setupSabrScheme.
'use strict';
(function () {
// Lazily resolve dependencies at call time so load order is flexible.
function shaka() { return window.shaka; }
function gv() { return window.googlevideo; }
function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
function formatIdFromString(str) {
var parts = str.split('-');
return {
itag: parseInt(parts[0], 10),
lastModified: parts[1],
xtags: parts[2]
};
}
function createBufferedRange(formatId, buffered, segmentIndex) {
var endSegmentIndex = segmentIndex.find(buffered.end);
if (endSegmentIndex == null) {
endSegmentIndex = segmentIndex.getNumReferences() - 1;
}
return {
formatId: formatId,
startTimeMs: String(Math.round(buffered.start * 1000)),
durationMs: String(Math.round((buffered.end - buffered.start) * 1000)),
startSegmentIndex: segmentIndex.find(buffered.start),
endSegmentIndex: endSegmentIndex
};
}
function createFullBufferRange(formatId) {
var MAX_INT32_VALUE = gv().utils.MAX_INT32_VALUE;
return {
formatId: formatId,
durationMs: MAX_INT32_VALUE,
startTimeMs: '0',
startSegmentIndex: parseInt(MAX_INT32_VALUE, 10),
endSegmentIndex: parseInt(MAX_INT32_VALUE, 10),
timeRange: {
durationTicks: MAX_INT32_VALUE,
startTicks: '0',
timescale: 1000
}
};
}
function fillBufferedRanges(player, manifest, audioFormatsActive, streamIsVideo, streamIsAudio, bufferedRanges, activeVariant) {
var bufferedInfo = player.getBufferedInfo();
if (bufferedInfo.audio.length === 0 && bufferedInfo.video.length === 0) return;
var activeManifestVariant;
if (audioFormatsActive) {
activeManifestVariant = manifest.variants.find(function (variant) {
return variant.audio.originalId === activeVariant.originalAudioId;
});
} else {
activeManifestVariant = manifest.variants.find(function (variant) {
return variant.audio.originalId === activeVariant.originalAudioId &&
variant.video.originalId === activeVariant.originalVideoId;
});
}
var audioFormatId = formatIdFromString(activeVariant.originalAudioId);
var audioSegmentIndex = activeManifestVariant.audio.segmentIndex;
if (streamIsVideo) {
bufferedRanges.push(createFullBufferRange(audioFormatId));
} else {
for (var i = 0; i < bufferedInfo.audio.length; i++) {
bufferedRanges.push(createBufferedRange(audioFormatId, bufferedInfo.audio[i], audioSegmentIndex));
}
}
var videoFormatId;
var videoSegmentIndex;
if (streamIsAudio && bufferedInfo.video.length > 0) {
videoFormatId = formatIdFromString(activeVariant.originalVideoId);
bufferedRanges.push(createFullBufferRange(videoFormatId));
} else {
for (var j = 0; j < bufferedInfo.video.length; j++) {
var buffered = bufferedInfo.video[j];
if (!videoFormatId) {
videoFormatId = formatIdFromString(activeVariant.originalVideoId);
}
if (!videoSegmentIndex) {
videoSegmentIndex = activeManifestVariant.video.segmentIndex;
}
bufferedRanges.push(createBufferedRange(videoFormatId, buffered, videoSegmentIndex));
}
}
}
function createCacheResponse(ShakaAbortableOperation, uri, request, data) {
return ShakaAbortableOperation.completed({
data: data,
fromCache: true,
headers: {},
originalRequest: request,
originalUri: uri,
uri: uri
});
}
function createRecoverableNetworkError(code) {
var ShakaError = shaka().util.Error;
var args = Array.prototype.slice.call(arguments, 1);
return new ShakaError(ShakaError.Severity.RECOVERABLE, ShakaError.Category.NETWORK, code, args);
}
function prepareSabrContexts(sabrStreamState) {
var sabrContexts = [];
var unsentSabrContexts = [];
sabrStreamState.sabrContexts.forEach(function (ctxUpdate) {
if (sabrStreamState.activeSabrContextTypes.has(ctxUpdate.type)) {
sabrContexts.push(ctxUpdate);
} else {
unsentSabrContexts.push(ctxUpdate.type);
}
});
return { sabrContexts: sabrContexts, unsentSabrContexts: unsentSabrContexts };
}
function decodePart(part, decoder) {
if (!part.data.chunks.length) return undefined;
try {
var concatenateChunks = gv().utils.concatenateChunks;
var chunk = part.data.chunks.length === 1 ? part.data.chunks[0] : concatenateChunks(part.data.chunks);
return decoder.decode(chunk);
} catch (e) {
return undefined;
}
}
function createTimeoutController(callback, timeoutMs) {
return {
_timeout: setTimeout(callback, timeoutMs),
_resetCount: 0,
resetTimeoutOnce: function () {
if (this._resetCount > 0) return;
this.clearTimeout();
this._timeout = setTimeout(callback, timeoutMs);
this._resetCount++;
},
clearTimeout: function () {
clearTimeout(this._timeout);
}
};
}
// Wrap the youtube/googlevideo fetch URL through the Invidious /proxy route,
// forwarding the UMP headers youtube needs. Returns a fetch-compatible RequestInit.
function proxyFetch(url, requestInit) {
var headers = new Headers(requestInit.headers || {});
// Ensure these headers survive the proxy hop.
if (!headers.has('x-youtube-client-name')) {
headers.set('x-youtube-client-name', '3');
}
return SABRHelpers.fetchWithProxy(url, Object.assign({}, requestInit, { headers: headers }));
}
async function doRequest(operationInputs, currentState) {
var ShakaError = shaka().util.Error;
var protos = gv().protos;
var ump = gv().ump;
var utils = gv().utils;
var UmpReader = ump.UmpReader;
var CompositeBuffer = ump.CompositeBuffer;
var UMPPartId = protos.UMPPartId;
var VideoPlaybackAbrRequest = protos.VideoPlaybackAbrRequest;
var StreamProtectionStatus = protos.StreamProtectionStatus;
var SabrError = protos.SabrError;
var SabrRedirect = protos.SabrRedirect;
var MediaHeader = protos.MediaHeader;
var SabrContextSendingPolicy = protos.SabrContextSendingPolicy;
var SabrContextUpdate = protos.SabrContextUpdate;
var SabrContextWritePolicy = protos.SabrContextWritePolicy;
var NextRequestPolicy = protos.NextRequestPolicy;
var PlaybackCookie = protos.PlaybackCookie;
var ReloadPlaybackContext = protos.ReloadPlaybackContext;
var response;
var chunkedDataBuffer = null;
var responseDataChunks = [];
var segmentComplete = false;
var shouldRetry = false;
var shouldRetryDueToNextRequestPolicy = false;
var invalidPoToken = false;
var error;
if (currentState.sabrStreamState.playerReloadRequested) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
}
try {
var shouldReloadDueToBackoffLoop = false;
if ((currentState.sabrStreamState.nextRequestPolicy?.backoffTimeMs || 0) > 0) {
var currentBackoffTimeMs = currentState.sabrStreamState.nextRequestPolicy.backoffTimeMs;
currentState.eventEmitter.emit('backoff-requested', { backoffMs: currentBackoffTimeMs });
await new Promise(function (resolve, reject) {
setTimeout(resolve, currentBackoffTimeMs);
currentState.abortController.signal.addEventListener('abort', reject);
});
currentState.timeoutController?.resetTimeoutOnce();
currentState.cumulativeBackOffTimeMs += currentState.sabrStreamState.nextRequestPolicy.backoffTimeMs;
currentState.cumulativeBackOffRequested += 1;
var timeoutMs = operationInputs.request.retryParameters.timeout;
if (currentState.cumulativeBackOffRequested >= 3 || (timeoutMs > 0 && timeoutMs <= (currentState.cumulativeBackOffTimeMs + currentBackoffTimeMs))) {
shouldReloadDueToBackoffLoop = true;
}
}
if (shouldReloadDueToBackoffLoop || currentState.cumulativeRetryDueToNextRequestPolicy >= 100) {
currentState.sabrStreamState.playerReloadRequested = true;
if (!currentState.abortController.signal.aborted) {
currentState.abortController.abort();
currentState.eventEmitter.emit('reload');
}
}
var sabrURL = new URL(currentState.sabrStreamState.sabrUrl);
sabrURL.searchParams.set('rn', String(currentState.sabrStreamState.requestNumber++));
// Stream through the Invidious proxy (CSP + CORS), honoring requestInit headers/body.
response = await proxyFetch(sabrURL.toString(), currentState.requestInit);
operationInputs.headersReceived({});
var fmtId = formatIdFromString(operationInputs.formatIdString);
var itag = fmtId.itag;
var lastModified = fmtId.lastModified;
var xtags = fmtId.xtags;
var mediaHeaderId;
var reader = response.body.getReader();
var readObj = await reader.read();
while (!readObj.done && !currentState.abortStatus.finished) {
if (chunkedDataBuffer) {
chunkedDataBuffer.append(readObj.value);
} else {
chunkedDataBuffer = new CompositeBuffer([readObj.value]);
}
var remainingData = new UmpReader(chunkedDataBuffer).read(function (part) {
switch (part.type) {
case UMPPartId.STREAM_PROTECTION_STATUS: {
var streamProtectionStatus = decodePart(part, StreamProtectionStatus);
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
invalidPoToken = true;
}
break;
}
case UMPPartId.SABR_ERROR: {
var sabrError = decodePart(part, SabrError);
if (!sabrError) break;
error = 'SABR Error: type: ' + sabrError.type + ', code: ' + sabrError.code;
break;
}
case UMPPartId.SABR_REDIRECT: {
var sabrRedirect = decodePart(part, SabrRedirect);
if (!sabrRedirect) break;
// BUGFIX (vs FreeTube): the read site reads sabrStreamState.sabrUrl,
// so write there, not currentState.sabrUrl.
currentState.sabrStreamState.sabrUrl = sabrRedirect.url;
shouldRetry = true;
break;
}
case UMPPartId.MEDIA_HEADER: {
if (mediaHeaderId === undefined) {
var mediaHeader = decodePart(part, MediaHeader);
if (!mediaHeader) break;
if (
mediaHeader.formatId.itag === itag &&
mediaHeader.formatId.lastModified === lastModified &&
mediaHeader.formatId.xtags === xtags
) {
if (operationInputs.isInit && mediaHeader.isInitSeg) {
mediaHeaderId = mediaHeader.headerId;
} else if (!operationInputs.isInit && mediaHeader.sequenceNumber === operationInputs.sequenceNumber) {
mediaHeaderId = mediaHeader.headerId;
}
}
}
break;
}
case UMPPartId.MEDIA: {
if (mediaHeaderId === part.data.getUint8(0)) {
var split = part.data.split(1);
var remaining = split.remainingBuffer;
for (var k = 0; k < remaining.chunks.length; k++) {
responseDataChunks.push(remaining.chunks[k]);
}
}
break;
}
case UMPPartId.MEDIA_END: {
if (mediaHeaderId === part.data.getUint8(0)) {
segmentComplete = true;
currentState.abortStatus.finished = true;
currentState.abortController.abort();
}
break;
}
case UMPPartId.NEXT_REQUEST_POLICY: {
var nextRequestPolicy = decodePart(part, NextRequestPolicy);
shouldRetry = true;
shouldRetryDueToNextRequestPolicy = true;
currentState.sabrStreamState.nextRequestPolicy = nextRequestPolicy;
currentState.abrRequest.streamerContext.playbackCookie = nextRequestPolicy?.playbackCookie ? PlaybackCookie.encode(nextRequestPolicy.playbackCookie).finish() : undefined;
currentState.abrRequest.streamerContext.backoffTimeMs = nextRequestPolicy?.backoffTimeMs;
break;
}
case UMPPartId.FORMAT_INITIALIZATION_METADATA: {
break;
}
case UMPPartId.SABR_CONTEXT_UPDATE: {
var sabrContextUpdate = decodePart(part, SabrContextUpdate);
if (!sabrContextUpdate) break;
if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
if (
sabrContextUpdate.writePolicy === SabrContextWritePolicy.KEEP_EXISTING &&
currentState.sabrStreamState.sabrContexts.has(sabrContextUpdate.type)
) {
break;
}
currentState.sabrStreamState.sabrContexts.set(sabrContextUpdate.type, sabrContextUpdate);
if (sabrContextUpdate.sendByDefault) {
currentState.sabrStreamState.activeSabrContextTypes.add(sabrContextUpdate.type);
}
}
break;
}
case UMPPartId.SABR_CONTEXT_SENDING_POLICY: {
var sabrContextSendingPolicy = decodePart(part, SabrContextSendingPolicy);
if (!sabrContextSendingPolicy) break;
for (var i = 0; i < sabrContextSendingPolicy.startPolicy.length; i++) {
var startPolicy = sabrContextSendingPolicy.startPolicy[i];
if (!currentState.sabrStreamState.activeSabrContextTypes.has(startPolicy)) {
currentState.sabrStreamState.activeSabrContextTypes.add(startPolicy);
}
}
for (var j = 0; j < sabrContextSendingPolicy.stopPolicy.length; j++) {
var stopPolicy = sabrContextSendingPolicy.stopPolicy[j];
if (currentState.sabrStreamState.activeSabrContextTypes.has(stopPolicy)) {
currentState.sabrStreamState.activeSabrContextTypes.delete(stopPolicy);
}
}
for (var m = 0; m < sabrContextSendingPolicy.discardPolicy.length; m++) {
var discardPolicy = sabrContextSendingPolicy.discardPolicy[m];
if (currentState.sabrStreamState.sabrContexts.has(discardPolicy)) {
currentState.sabrStreamState.sabrContexts.delete(discardPolicy);
}
}
break;
}
case UMPPartId.RELOAD_PLAYER_RESPONSE: {
var reloadPlaybackContext = decodePart(part, ReloadPlaybackContext);
if (!reloadPlaybackContext) break;
currentState.sabrStreamState.playerReloadRequested = true;
if (!currentState.abortController.signal.aborted) {
currentState.abortController.abort();
currentState.eventEmitter.emit('reload');
}
break;
}
default: {
break;
}
}
});
if (!currentState.abortStatus.finished) {
if (remainingData) {
chunkedDataBuffer = remainingData.data;
} else {
chunkedDataBuffer = null;
}
readObj = await reader.read();
}
}
} catch (err) {
if (currentState.abortStatus.cancelled) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
} else if (currentState.abortStatus.timedOut) {
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
} else if (!currentState.abortStatus.finished) {
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, err, operationInputs.requestType);
}
}
if (currentState.abortStatus.cancelled) {
throw createRecoverableNetworkError(ShakaError.Code.OPERATION_ABORTED, operationInputs.uri, operationInputs.requestType);
} else if (currentState.abortStatus.timedOut) {
throw createRecoverableNetworkError(ShakaError.Code.TIMEOUT, operationInputs.uri, operationInputs.requestType);
}
if (responseDataChunks.length > 0 && segmentComplete) {
var concatenateChunks = utils.concatenateChunks;
var data = concatenateChunks(responseDataChunks);
if (operationInputs.isInit) {
currentState.initDataCache.set(operationInputs.formatIdString, data);
}
return {
uri: operationInputs.uri,
originalUri: operationInputs.uri,
data: data,
status: response.status,
headers: {},
fromCache: false,
originalRequest: operationInputs.request
};
} else if (shouldRetry) {
if (shouldRetryDueToNextRequestPolicy) {
currentState.cumulativeRetryDueToNextRequestPolicy += 1;
}
var prepared = prepareSabrContexts(currentState.sabrStreamState);
currentState.abrRequest.streamerContext.sabrContexts = prepared.sabrContexts;
currentState.abrRequest.streamerContext.unsentSabrContexts = prepared.unsentSabrContexts;
var body;
try {
body = VideoPlaybackAbrRequest.encode(currentState.abrRequest).finish();
} catch (e) {
console.error('Invalid VideoPlaybackAbrRequest data', currentState.abrRequest);
throw e;
}
currentState.requestInit = {
body: body,
method: 'POST',
headers: {
'content-type': 'application/x-protobuf',
'accept-encoding': 'identity',
'accept': 'application/vnd.yt-ump'
},
signal: currentState.abortController.signal
};
currentState.abortStatus.timedOut = false;
currentState.abortStatus.finished = false;
return doRequest(operationInputs, currentState);
} else if (invalidPoToken) {
throw new ShakaError(
ShakaError.Severity.CRITICAL,
ShakaError.Category.NETWORK,
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Invalid PO token'),
operationInputs.requestType
);
} else if (error) {
throw createRecoverableNetworkError(ShakaError.Code.HTTP_ERROR, operationInputs.uri, new Error(error), operationInputs.requestType);
} else if (responseDataChunks.length > 0 && !segmentComplete) {
throw createRecoverableNetworkError(
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Incomplete segment, missing MEDIA_END part'),
operationInputs.requestType
);
} else if (response.status === 200) {
throw createRecoverableNetworkError(
ShakaError.Code.HTTP_ERROR,
operationInputs.uri,
new Error('Empty response, this should not happen'),
operationInputs.requestType
);
} else {
var severity = response.status === 401 || response.status === 403
? ShakaError.Severity.CRITICAL
: ShakaError.Severity.RECOVERABLE;
throw new ShakaError(
severity,
ShakaError.Category.NETWORK,
ShakaError.Code.BAD_HTTP_STATUS,
operationInputs.uri,
response.status,
'',
{},
operationInputs.requestType,
operationInputs.uri
);
}
}
function setupSabrScheme(sabrData, getPlayer, getManifest, getWidth, getHeight) {
var ShakaAbortableOperation = shaka().util.AbortableOperation;
var ShakaError = shaka().util.Error;
var protos = gv().protos;
var utils = gv().utils;
var base64ToU8 = utils.base64ToU8;
var VideoPlaybackAbrRequest = protos.VideoPlaybackAbrRequest;
var PlaybackCookie = protos.PlaybackCookie;
var EventEmitterLike = utils.EventEmitterLike;
var eventEmitter = new EventEmitterLike();
var initDataCache = new Map();
var poToken = base64ToU8(sabrData.poToken);
var videoPlaybackUstreamerConfig = base64ToU8(sabrData.ustreamerConfig);
var clientInfo = deepCopy(sabrData.clientInfo);
var sabrStreamState = {
sabrUrl: sabrData.url,
activeSabrContextTypes: new Set(),
sabrContexts: new Map(),
nextRequestPolicy: undefined,
playerReloadRequested: false,
requestNumber: 0
};
shaka().net.NetworkingEngine.registerScheme('sabr', function (uri, request, requestType, _progressUpdated, headersReceived, _config) {
var player = getPlayer();
if (player == null) {
return new ShakaAbortableOperation(Promise.resolve());
}
var isAudioOnly = player.isAudioOnly();
var url = new URL(request.uris[0]);
var isInit = url.searchParams.has('init');
var formatIdString = url.searchParams.get('formatId');
if (isInit && initDataCache.has(formatIdString)) {
return createCacheResponse(ShakaAbortableOperation, uri, request, initDataCache.get(formatIdString));
}
var variantTracks = player.getVariantTracks();
var activeVariant = null;
for (var i = 0; i < variantTracks.length; i++) {
if (variantTracks[i].active) { activeVariant = variantTracks[i]; break; }
}
var streamIsAudio = url.pathname === 'audio';
var streamIsVideo = url.pathname === 'video';
var audioFormatId;
var videoFormatId;
if (streamIsAudio) {
audioFormatId = formatIdFromString(formatIdString);
if (isAudioOnly) {
videoFormatId = formatIdFromString(url.searchParams.get('videoFormatId'));
} else {
videoFormatId = formatIdFromString((activeVariant || variantTracks[0]).originalVideoId);
}
} else if (streamIsVideo) {
videoFormatId = formatIdFromString(formatIdString);
if (activeVariant) {
audioFormatId = formatIdFromString(activeVariant.originalAudioId);
} else {
var candidates = variantTracks.filter(function (track) {
return track.audioRoles.indexOf('main') !== -1;
});
var probableAudioFormat = candidates.reduce(function (previous, current) {
return current.audioBandwidth >= previous.audioBandwidth ? current : previous;
}, candidates[0]);
audioFormatId = formatIdFromString(probableAudioFormat.originalAudioId);
}
}
var bufferedRanges = [];
if (!isInit && activeVariant) {
fillBufferedRanges(player, getManifest(), isAudioOnly, streamIsVideo, streamIsAudio, bufferedRanges, activeVariant);
}
var playerTimeMs = '0';
if (url.searchParams.has('startTimeMs')) {
playerTimeMs = url.searchParams.get('startTimeMs');
}
var drcEnabled = url.searchParams.has('drc') || !!(activeVariant && activeVariant.audioRoles.indexOf('drc') !== -1);
var enableVoiceBoost = url.searchParams.has('vb') || !!(activeVariant && activeVariant.audioRoles.indexOf('vb') !== -1);
var resolution = streamIsVideo ? parseInt(url.searchParams.get('resolution'), 10) : undefined;
var prepared = prepareSabrContexts(sabrStreamState);
var requestData = {
clientAbrState: {
bandwidthEstimate: String(Math.round(player.getStats().estimatedBandwidth)),
timeSinceLastManualFormatSelectionMs: streamIsVideo ? '0' : undefined,
stickyResolution: resolution,
lastManualSelectedResolution: resolution,
playbackRate: player.getPlaybackRate(),
enabledTrackTypesBitfield: streamIsAudio ? 1 : 0,
drcEnabled: drcEnabled,
enableVoiceBoost: enableVoiceBoost,
playerTimeMs: playerTimeMs,
clientViewportWidth: getWidth(),
clientViewportHeight: getHeight(),
clientViewportIsFlexible: false
},
preferredAudioFormatIds: [audioFormatId],
preferredVideoFormatIds: [videoFormatId],
preferredSubtitleFormatIds: [],
selectedFormatIds: isInit ? [] : [audioFormatId, videoFormatId],
bufferedRanges: bufferedRanges,
streamerContext: {
poToken: poToken,
clientInfo: clientInfo,
sabrContexts: prepared.sabrContexts,
unsentSabrContexts: prepared.unsentSabrContexts,
playbackCookie: sabrStreamState.nextRequestPolicy?.playbackCookie ? PlaybackCookie.encode(sabrStreamState.nextRequestPolicy.playbackCookie).finish() : undefined
},
field1000: [],
videoPlaybackUstreamerConfig: videoPlaybackUstreamerConfig
};
var body;
try {
body = VideoPlaybackAbrRequest.encode(requestData).finish();
} catch (e) {
console.error('Invalid VideoPlaybackAbrRequest data', requestData);
throw e;
}
var sequenceNumber = parseInt(url.searchParams.get('sq'), 10);
var opInputs = {
uri: uri,
request: request,
requestType: requestType,
headersReceived: headersReceived,
formatIdString: formatIdString,
isInit: isInit,
sequenceNumber: isNaN(sequenceNumber) ? undefined : sequenceNumber
};
var abortController = new AbortController();
var init = {
body: body,
method: 'POST',
headers: {
'content-type': 'application/x-protobuf',
'accept-encoding': 'identity',
'accept': 'application/vnd.yt-ump'
},
signal: abortController.signal
};
var abortStatus = { cancelled: false, timedOut: false, finished: false };
var timeoutMs = request.retryParameters.timeout;
var timeoutController = null;
if (timeoutMs) {
timeoutController = createTimeoutController(function () {
abortStatus.timedOut = true;
abortController.abort();
}, timeoutMs);
}
var currentState = {
initDataCache: initDataCache,
abrRequest: requestData,
requestInit: init,
abortStatus: abortStatus,
abortController: abortController,
sabrStreamState: sabrStreamState,
timeoutController: timeoutController,
eventEmitter: eventEmitter,
cumulativeBackOffTimeMs: 0,
cumulativeBackOffRequested: 0,
cumulativeRetryDueToNextRequestPolicy: 0
};
var pendingRequest = doRequest(opInputs, currentState);
var op = new ShakaAbortableOperation(pendingRequest, function () {
abortStatus.cancelled = true;
abortController.abort();
return Promise.resolve();
});
if (timeoutController) {
op.finally(function () {
timeoutController.clearTimeout();
});
}
return op;
});
function cleanup() {
shaka().net.NetworkingEngine.unregisterScheme('sabr');
initDataCache.clear();
}
return {
onBackoffRequested: function (callback) {
eventEmitter.on('backoff-requested', callback);
},
onReloadOnce: function (callback) {
eventEmitter.once('reload', callback);
},
cleanup: cleanup
};
}
window.setupSabrScheme = setupSabrScheme;
})();

View File

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

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

@ -6,9 +6,9 @@
"": {
"name": "invidious-sabr",
"dependencies": {
"bgutils-js": "^3.1.3",
"bgutils-js": "^3.2.0",
"googlevideo": "^4.0.4",
"youtubei.js": "^16.0.0"
"youtubei.js": "^17.0.1"
},
"devDependencies": {
"esbuild": "^0.27.2"
@ -513,6 +513,12 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/googlevideo": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.0.4.tgz",
@ -535,15 +541,16 @@
}
},
"node_modules/youtubei.js": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz",
"integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==",
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.2.0.tgz",
"integrity": "sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"fflate": "^0.8.2",
"meriyah": "^6.1.4"
}
}

View File

@ -7,8 +7,8 @@
},
"dependencies": {
"googlevideo": "^4.0.4",
"youtubei.js": "^16.0.0",
"bgutils-js": "^3.1.3"
"youtubei.js": "^17.0.1",
"bgutils-js": "^3.2.0"
},
"devDependencies": {
"esbuild": "^0.27.2"

View File

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

View File

@ -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, '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 = `
export * from 'googlevideo/sabr-streaming-adapter';
export * from 'googlevideo/ump';
export * from 'googlevideo/utils';
export * from 'googlevideo/protos';
import * as utils from 'googlevideo/utils';
import * as ump from 'googlevideo/ump';
import * as protos from 'googlevideo/protos';
export const googlevideo = { utils, ump, protos };
// Also re-export flat for convenience / debugging on window.
export { utils, ump, protos };
`;
const googlevideoEntryPath = path.join(__dirname, 'temp-googlevideo-entry.js');
fs.writeFileSync(googlevideoEntryPath, googlevideoEntryContent);
// Bundle googlevideo with all exports
esbuild.build({
entryPoints: [googlevideoEntryPath],
const commonEsbuildOptions = {
bundle: true,
format: 'esm',
outfile: path.join(outputDir, 'googlevideo/googlevideo.bundle.min.js'),
minify: true,
sourcemap: false,
external: [],
nodePaths: [nodeModules],
banner: {
js: '// googlevideo library - bundled with esbuild'
platform: 'browser',
define: {
'process.env.NODE_ENV': '"production"',
'process.env.SUPPORTS_LOCAL_API': 'true'
}
};
// Bundle googlevideo with all exports
esbuild.build({
...commonEsbuildOptions,
entryPoints: [googlevideoEntryPath],
outfile: path.join(outputDir, 'googlevideo/googlevideo.bundle.min.js'),
banner: { js: '// googlevideo library - bundled with esbuild' }
}).then(() => {
console.log('✓ googlevideo bundled successfully');
fs.unlinkSync(googlevideoEntryPath);
@ -54,37 +67,16 @@ esbuild.build({
process.exit(1);
});
// Bundle youtubei.js
esbuild.build({
entryPoints: [path.join(nodeModules, 'youtubei.js/bundle/browser.js')],
bundle: true,
format: 'esm',
outfile: path.join(outputDir, 'youtubei.js/youtubei.bundle.min.js'),
minify: true,
sourcemap: false,
external: [],
banner: {
js: '// youtubei.js library - bundled with esbuild'
}
}).then(() => {
console.log('✓ youtubei.js bundled successfully');
}).catch((err) => {
console.error('✗ youtubei.js bundling failed:', err);
process.exit(1);
});
// youtubei.js is fetched as a pre-built browser bundle from jsDelivr by
// scripts/fetch-sabr-dependencies.cr (not bundled here), to avoid re-bundling
// a large lib and clobbering the pre-built artifact.
// Bundle bgutils-js
esbuild.build({
...commonEsbuildOptions,
entryPoints: [path.join(nodeModules, 'bgutils-js/dist/index.js')],
bundle: true,
format: 'esm',
outfile: path.join(outputDir, 'bgutils-js/bgutils.bundle.min.js'),
minify: true,
sourcemap: false,
external: [],
banner: {
js: '// bgutils-js library - bundled with esbuild'
}
banner: { js: '// bgutils-js library - bundled with esbuild' }
}).then(() => {
console.log('✓ bgutils-js bundled successfully');
}).catch((err) => {

View File

@ -9,30 +9,19 @@ require "colorize"
SABR_DEPENDENCIES = {
"shaka-player" => {
"version" => "4.16.4",
"version" => "5.1.10",
"files" => [
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@4.16.4/dist/controls.css", "dest" => "controls.css"},
]
},
"googlevideo" => {
"version" => "4.0.4",
"files" => [
# esm.sh bundled version - fetch full bundle path directly
{"url" => "https://esm.sh/googlevideo@4.0.4/es2022/sabr-streaming-adapter.bundle.mjs", "dest" => "googlevideo.bundle.min.js"},
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/shaka-player.ui.js", "dest" => "shaka-player.ui.js"},
{"url" => "https://cdn.jsdelivr.net/npm/shaka-player@5.1.10/dist/controls.css", "dest" => "controls.css"},
]
},
# googlevideo + bgutils-js are bundled locally from npm via scripts/bundle-sabr-libs.js
# (no esm.sh fetch). youtubei.js is also bundled locally from npm, but we keep a
# pre-built jsDelivr copy as the esbuild entry for reproducibility.
"youtubei.js" => {
"version" => "16.0.1",
"version" => "17.0.1",
"files" => [
# Use the web bundle from jsdelivr (pre-built by youtubei.js)
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@16.0.1/bundle/browser.min.js", "dest" => "youtubei.bundle.min.js"},
]
},
"bgutils-js" => {
"version" => "3.2.0",
"files" => [
{"url" => "https://esm.sh/bgutils-js@3.2.0/es2022/bgutils-js.bundle.mjs", "dest" => "bgutils.bundle.min.js"},
{"url" => "https://cdn.jsdelivr.net/npm/youtubei.js@17.0.1/bundle/browser.js", "dest" => "youtubei.bundle.min.js"},
]
},
}
@ -135,49 +124,7 @@ if File.exists?(shaka_css_path)
puts "#{"Patched".colorize(:green)} Shaka CSS to use system fonts"
end
# Post-process: Patch googlevideo bundle to remove esm.sh import and add process shim
googlevideo_path = "#{sabr_dir}/googlevideo/googlevideo.bundle.min.js"
if File.exists?(googlevideo_path)
js_content = File.read(googlevideo_path)
# Add process shim at the beginning and remove the esm.sh import
process_shim = <<-JS
// Browser-compatible process shim for googlevideo
var __Process$ = { env: {} };
JS
# Remove the esm.sh import line: import __Process$ from "/node/process.mjs";
js_content = js_content.gsub(/import\s+__Process\$\s+from\s*["'][^"']+["'];?\s*/, "")
# Prepend the shim
js_content = process_shim + js_content
File.write(googlevideo_path, js_content)
puts "#{"Patched".colorize(:green)} googlevideo bundle with process shim"
end
# Post-process: Patch bgutils-js bundle to be self-contained
bgutils_path = "#{sabr_dir}/bgutils-js/bgutils.bundle.min.js"
if File.exists?(bgutils_path)
js_content = File.read(bgutils_path)
# Check if it's just an export redirect and fetch the actual bundle
if js_content.includes?("export * from")
# The esm.sh bundle is just a redirect, we need the actual content
puts "#{"Info".colorize(:yellow)} bgutils bundle is a redirect, fetching actual content..."
# Extract the actual path from: export * from "/bgutils-js@3.1.3/es2022/bgutils-js.bundle.mjs";
if match = js_content.match(/export \* from ["']([^"']+)["']/)
actual_path = match[1]
actual_url = "https://esm.sh#{actual_path}"
HTTP::Client.get(actual_url) do |response|
if response.status_code == 200
File.write(bgutils_path, response.body_io.gets_to_end)
puts "#{"Fetched".colorize(:green)} actual bgutils bundle"
end
end
end
end
end
# Note: googlevideo and bgutils-js are now bundled locally from npm via
# scripts/bundle-sabr-libs.js (esbuild, platform: 'browser', with
# process.env.NODE_ENV defined to 'production'). No esm.sh fetch or
# post-patching is required.

View File

@ -84,7 +84,6 @@ module Invidious::Routes::Proxy
custom_headers = HTTP::Headers.new
if headers_param = query_params["__headers"]?
begin
puts "[DEBUG] Proxy: Parsing __headers parameter: #{headers_param}"
headers_array = JSON.parse(headers_param).as_a
headers_array.each do |header|
# header is a JSON::Any, need to extract as array
@ -93,11 +92,10 @@ module Invidious::Routes::Proxy
value = header_array[1]?.try &.as_s
if name && value
custom_headers[name] = value
puts "[DEBUG] Proxy: Adding custom header #{name}: #{value}"
end
end
rescue ex
# Ignore malformed headers but log the error
# Ignore malformed headers
puts "[WARN] Proxy: Failed to parse __headers: #{ex.message}"
end
end
@ -121,14 +119,10 @@ module Invidious::Routes::Proxy
request_headers = HTTP::Headers.new
# Copy custom headers
puts "[DEBUG] Proxy: custom_headers size: #{custom_headers.size}"
custom_headers.each do |key, values|
# HTTP::Headers stores values as arrays, get the first value
value = values.is_a?(Array) ? values.first : values.to_s
puts "[DEBUG] Proxy: Forwarding header #{key}: #{value}"
request_headers[key] = value
end
puts "[DEBUG] Proxy: request_headers size after copying: #{request_headers.size}"
# Copy range header from original request
if range = env.request.headers["Range"]?
@ -139,25 +133,21 @@ module Invidious::Routes::Proxy
if content_type = env.request.headers["Content-Type"]?
if !request_headers["Content-Type"]?
request_headers["Content-Type"] = content_type
puts "[DEBUG] Proxy: Copied Content-Type from request: #{content_type}"
end
end
# Copy user-agent if not already set
if !request_headers["User-Agent"]? && env.request.headers["User-Agent"]?
request_headers["User-Agent"] = env.request.headers["User-Agent"]
puts "[DEBUG] Proxy: Copied User-Agent from request: #{request_headers["User-Agent"]}"
end
# Set origin/referer for YouTube and Google domains (only if not already set)
if target_host.includes?("youtube") || target_host.includes?("googlevideo") || target_host.includes?("googleapis")
if !request_headers["Origin"]?
request_headers["Origin"] = "https://www.youtube.com"
puts "[DEBUG] Proxy: Set Origin header"
end
if !request_headers["Referer"]?
request_headers["Referer"] = "https://www.youtube.com/"
puts "[DEBUG] Proxy: Set Referer header"
end
end
@ -165,7 +155,12 @@ module Invidious::Routes::Proxy
# YouTube requires application/x-protobuf for SABR videoplayback requests
if env.request.method == "POST" && target_url.path.includes?("videoplayback")
request_headers["Content-Type"] = "application/x-protobuf"
puts "[DEBUG] Proxy: Set Content-Type: application/x-protobuf for videoplayback POST"
end
# The SABR scheme plugin reads UMP parts incrementally from the response
# stream, so make sure the upstream sends us raw bytes (no gzip/deflate).
if !request_headers["Accept-Encoding"]?
request_headers["Accept-Encoding"] = "identity"
end
# Copy authorization if present
@ -173,59 +168,63 @@ module Invidious::Routes::Proxy
request_headers["Authorization"] = auth
end
# Final debug output showing all headers being sent
puts "[DEBUG] Proxy: Final headers to send: #{request_headers.to_a.map { |k, v| "#{k}: #{v[0..50]}..." }.join(", ")}"
# Make the proxied request
# Make the proxied request, streaming the upstream body straight to the
# client instead of buffering response.body. This is required so the SABR
# scheme plugin can read UMP parts incrementally and abort a fetch
# mid-stream (backoff / reload / seek).
begin
client = HTTP::Client.new(target_url.host.not_nil!, tls: true)
client.connect_timeout = 10.seconds
client.read_timeout = 30.seconds
# Don't let HTTP::Client advertise its own Accept-Encoding; we forward the
# plugin's identity header and stream raw bytes.
client.compress = false
case env.request.method
when "GET"
response = client.get(target_url.request_target, headers: request_headers)
when "POST"
# Read body as binary Slice to preserve protobuf data integrity
method = env.request.method
case method
when "GET", "POST"
# Build a raw request so we control streaming + binary POST body.
request = HTTP::Request.new(method, target_url.request_target, request_headers)
if method == "POST"
body_io = env.request.body
body_bytes : Bytes? = nil
if body_io
body_bytes = body_io.getb_to_end
end
body_size = body_bytes.try(&.size) || 0
puts "[DEBUG] Proxy: POST body size: #{body_size} bytes (binary)"
puts "[DEBUG] Proxy: POST target: #{target_url.request_target[0..200]}"
response = client.post(target_url.request_target, headers: request_headers, body: body_bytes)
puts "[DEBUG] Proxy: Response status: #{response.status_code}, content-type: #{response.headers["content-type"]?}"
else
env.response.status_code = 405
return "Method not allowed"
if body_bytes
request.body = body_bytes
request.content_length = body_bytes.size
end
end
# Set response status
client.exec(request) do |response|
env.response.status_code = response.status_code
# Copy content headers
CONTENT_HEADERS.each do |header|
if value = response.headers[header]?
env.response.headers[header] = value
end
end
# Add CORS headers
env.response.headers["Access-Control-Allow-Origin"] = origin
env.response.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS.join(", ")
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
env.response.headers["Access-Control-Allow-Credentials"] = "true"
# Return the response body
# Wrap in error handling to suppress "Broken pipe" errors when client disconnects
# Stream the upstream body through without buffering, so the client's
# UmpReader can consume parts as they arrive and so backoff/abort
# actually cancels the transfer.
begin
response.body
IO.copy(response.body_io, env.response.output)
env.response.output.flush
rescue ex : IO::Error
# Client disconnected (common during seeking/quality changes) - this is expected
puts "[DEBUG] Proxy: Client disconnected (#{ex.message})"
""
# Client disconnected mid-stream (seek / quality change / abort).
# This is expected, just stop copying.
end
end
else
env.response.status_code = 405
return "Method not allowed"
end
rescue ex
env.response.status_code = 502

View File

@ -87,10 +87,12 @@ module Invidious::Videos::Parser
begin
params = self.parse_video_info(video_id, player_response)
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
params["reason"] = JSON::Any.new("Video unavailable")
params["subreason"] = JSON::Any.new("Invidious Companion is not available. Video playback is not possible. \
If you are the administrator, install Invidious Companion: \
<a href=\"https://docs.invidious.io/installation/\">https://docs.invidious.io/installation/</a>")
# We have metadata from /next but no server-side streaming data (no
# companion). Don't treat this as a hard error: the watch page still
# renders, and for quality=sabr the browser SABR player fetches the
# stream itself via Innertube + browser-side PoToken. An informational
# subreason is surfaced for non-SABR playback (which needs companion or
# the local streams proxy).
return params
rescue ex
LOGGER.debug("extract_video_info: parse from /next failed for #{video_id}: #{ex.message}")

View File

@ -114,9 +114,15 @@
<link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>">
<script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
<!-- 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_potoken.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_shaka_adapter.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->