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

@ -1,32 +1,29 @@
/** /**
* SABR Loader - ES module loader for SABR dependencies * SABR Loader - ES module loader for SABR dependencies
* *
* All dependencies are ES modules: * All dependencies are ES modules:
* - youtubei.js: Provides Innertube for YouTube API access * - youtubei.js: Provides Innertube for YouTube API access
* - googlevideo: Provides SABR streaming adapter * - googlevideo: Provides SABR protos, UMP reader/writer and utils
* - bgutils-js: Provides BotGuard utilities * - bgutils-js: Provides BotGuard utilities
*
* Exposes everything needed by sabr_scheme_plugin.js / sabr_manifest_parser.js
* onto window.googlevideo so the plain-script plugin can read them at call time.
*/ */
// Import all ES modules import Innertube, { Platform, Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import Innertube from '/js/sabr/youtubei.js/youtubei.bundle.min.js'; import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import { Platform } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import { Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js';
import * as googlevideo from '/js/sabr/googlevideo/googlevideo.bundle.min.js';
import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js'; import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js';
// Expose all SABR-related functions to window // youtubei.js
window.Innertube = Innertube; window.Innertube = Innertube;
window.Platform = Platform; window.Platform = Platform;
window.Constants = Constants; window.Constants = Constants;
window.SabrStreamingAdapter = googlevideo.SabrStreamingAdapter;
window.SabrUmpProcessor = googlevideo.SabrUmpProcessor; // googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser
window.buildSabrFormat = googlevideo.buildSabrFormat; window.googlevideo = googlevideo;
window.FormatKeyUtils = googlevideo.FormatKeyUtils;
window.UmpUtils = googlevideo.UmpUtils; // BotGuard
window.SABR_CONSTANTS = googlevideo.SABR_CONSTANTS;
window.isGoogleVideoURL = googlevideo.isGoogleVideoURL;
window.BG = BG; window.BG = BG;
// Signal that all SABR libraries are loaded and ready
console.info('[SABR Loader]', 'All SABR libraries loaded'); console.info('[SABR Loader]', 'All SABR libraries loaded');
window.dispatchEvent(new Event('sabr-libs-loaded')); window.dispatchEvent(new Event('sabr-libs-loaded'));

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

File diff suppressed because it is too large Load Diff

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -114,9 +114,15 @@
<link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/sabr_player.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/js/sabr/shaka-player/controls.css?v=<%= ASSET_COMMIT %>">
<script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sabr/shaka-player/shaka-player.ui.js?v=<%= ASSET_COMMIT %>"></script>
<!-- SABR engine ports (FreeTube): plain scripts reading shaka/googlevideo from window globals -->
<script src="/js/sabr_ebml_parser.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_mp4_index.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_webm_index.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_manifest_parser.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_scheme_plugin.js?v=<%= ASSET_COMMIT %>"></script>
<!-- SABR orchestrator + helpers -->
<script src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sabr_helpers.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sabr_potoken.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_shaka_adapter.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sabr_player.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script> <script src="/js/sabr_init.js?v=<%= ASSET_COMMIT %>"></script>
<!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) --> <!-- ES module loader for SABR libraries (youtubei.js, googlevideo, bgutils) -->