invidious/assets/js/sabr_shaka_adapter.js
2026-01-07 12:50:41 +01:00

737 lines
28 KiB
JavaScript

/**
* 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;