mirror of
https://github.com/LuanRT/googlevideo.git
synced 2026-06-13 08:42:31 +00:00
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import shaka from 'shaka-player/dist/shaka-player.ui';
|
|
|
|
import { FormatKeyUtils, type CacheManager, type RequestMetadataManager, isGoogleVideoURL } from 'googlevideo/utils';
|
|
|
|
import type { SabrFormat } from 'googlevideo/shared-types';
|
|
|
|
import {
|
|
SabrUmpProcessor,
|
|
type RequestFilter,
|
|
type ResponseFilter,
|
|
type SabrPlayerAdapter,
|
|
type SabrRequestMetadata,
|
|
type UmpProcessingResult
|
|
} from 'googlevideo/sabr-streaming-adapter';
|
|
|
|
import {
|
|
asMap, checkExtension,
|
|
createRecoverableError,
|
|
getInjectedProxyFunction,
|
|
headersToGenericObject,
|
|
makeResponse
|
|
} from './helpers.js';
|
|
|
|
interface ShakaResponseArgs {
|
|
uri: string;
|
|
request: shaka.extern.Request;
|
|
requestType: shaka.net.NetworkingEngine.RequestType;
|
|
response: Response;
|
|
arrayBuffer?: Uint8Array | ArrayBuffer;
|
|
}
|
|
|
|
export class ShakaPlayerAdapter implements SabrPlayerAdapter {
|
|
protected player: shaka.Player | null = null;
|
|
private requestMetadataManager?: RequestMetadataManager;
|
|
private cacheManager?: CacheManager;
|
|
private abortController?: AbortController;
|
|
|
|
private requestFilter?: (type: shaka.net.NetworkingEngine.RequestType, request: shaka.extern.Request, context?: shaka.extern.RequestContext) => Promise<void>;
|
|
private responseFilter?: (type: shaka.net.NetworkingEngine.RequestType, response: shaka.extern.Response, context?: shaka.extern.RequestContext) => Promise<void>;
|
|
|
|
public initialize(
|
|
player: shaka.Player,
|
|
requestMetadataManager: RequestMetadataManager,
|
|
cacheManager: CacheManager
|
|
): void {
|
|
this.player = player;
|
|
this.requestMetadataManager = requestMetadataManager;
|
|
this.cacheManager = cacheManager;
|
|
|
|
const networkingEngine = shaka.net.NetworkingEngine;
|
|
const schemes = [ 'http', 'https' ];
|
|
|
|
if (!shaka.net.HttpFetchPlugin.isSupported())
|
|
throw new Error('The Fetch API is not supported in this browser.');
|
|
|
|
schemes.forEach((scheme) => {
|
|
networkingEngine.registerScheme(
|
|
scheme, this.parseRequest.bind(this),
|
|
networkingEngine.PluginPriority.PREFERRED
|
|
);
|
|
});
|
|
}
|
|
|
|
private parseRequest(
|
|
uri: string,
|
|
request: shaka.extern.Request,
|
|
requestType: shaka.net.NetworkingEngine.RequestType,
|
|
progressUpdated: shaka.extern.ProgressUpdated,
|
|
headersReceived: shaka.extern.HeadersReceived,
|
|
config: shaka.extern.SchemePluginConfig
|
|
): shaka.extern.IAbortableOperation<shaka.extern.Response> {
|
|
const headers = new Headers();
|
|
asMap(request.headers).forEach((value, key) => {
|
|
headers.append(key as string, value);
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
this.abortController = controller;
|
|
|
|
const init: RequestInit = {
|
|
body: request.body as any || undefined,
|
|
headers,
|
|
method: request.method,
|
|
signal: this.abortController.signal,
|
|
credentials: request.allowCrossSiteCredentials ? 'include' : undefined
|
|
};
|
|
|
|
const abortStatus = { canceled: false, timedOut: false };
|
|
|
|
const minBytes = config.minBytesForProgressEvents || 0;
|
|
|
|
const pendingRequest = this.request(uri, request, requestType, init, controller, abortStatus, progressUpdated, headersReceived, minBytes);
|
|
|
|
const operation = new shaka.util.AbortableOperation(
|
|
pendingRequest,
|
|
() => {
|
|
abortStatus.canceled = true;
|
|
controller.abort();
|
|
return Promise.resolve();
|
|
}
|
|
);
|
|
|
|
const timeoutMs = request.retryParameters.timeout;
|
|
if (timeoutMs) {
|
|
const timer = new shaka.util.Timer(() => {
|
|
abortStatus.timedOut = true;
|
|
controller.abort();
|
|
console.warn('[ShakaPlayerAdapter]', 'Request aborted due to timeout:', uri, requestType);
|
|
});
|
|
timer.tickAfter(timeoutMs / 1000);
|
|
operation.finally(() => timer.stop());
|
|
}
|
|
|
|
return operation;
|
|
}
|
|
|
|
private async handleCachedRequest(
|
|
requestMetadata: SabrRequestMetadata,
|
|
uri: string,
|
|
request: shaka.extern.Request,
|
|
progressUpdated: shaka.extern.ProgressUpdated,
|
|
headersReceived: shaka.extern.HeadersReceived,
|
|
requestType: shaka.net.NetworkingEngine.RequestType
|
|
): Promise<shaka.extern.Response | null> {
|
|
if (!requestMetadata.byteRange || !this.cacheManager) {
|
|
return null;
|
|
}
|
|
|
|
const segmentKey = FormatKeyUtils.createSegmentCacheKeyFromMetadata(requestMetadata);
|
|
|
|
let arrayBuffer = (
|
|
requestMetadata.isInit ?
|
|
this.cacheManager.getInitSegment(segmentKey) :
|
|
this.cacheManager.getSegment(segmentKey)
|
|
)?.buffer as ArrayBuffer;
|
|
|
|
if (!arrayBuffer) {
|
|
return null;
|
|
}
|
|
|
|
if (requestMetadata.isInit) {
|
|
arrayBuffer = arrayBuffer.slice(
|
|
requestMetadata.byteRange.start,
|
|
requestMetadata.byteRange.end + 1
|
|
);
|
|
}
|
|
|
|
const 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);
|
|
}
|
|
|
|
private async handleUmpResponse(
|
|
response: Response,
|
|
requestMetadata: SabrRequestMetadata,
|
|
uri: string,
|
|
request: shaka.extern.Request,
|
|
requestType: shaka.net.NetworkingEngine.RequestType,
|
|
progressUpdated: shaka.extern.ProgressUpdated,
|
|
abortController: AbortController,
|
|
minBytes: number
|
|
): Promise<shaka.extern.Response> {
|
|
let lastTime = Date.now();
|
|
|
|
const sabrUmpReader = new SabrUmpProcessor(requestMetadata, this.cacheManager);
|
|
|
|
const checkResultIntegrity = (result: UmpProcessingResult) => {
|
|
if (!result.data && ((!!requestMetadata.error || requestMetadata.streamInfo?.streamProtectionStatus?.status === 3) && !requestMetadata.streamInfo?.sabrContextUpdate)) {
|
|
throw createRecoverableError('Server streaming error', requestMetadata);
|
|
}
|
|
};
|
|
|
|
const shouldReturnEmptyResponse = () => {
|
|
return requestMetadata.isSABR && (requestMetadata.streamInfo?.redirect || requestMetadata.streamInfo?.sabrContextUpdate);
|
|
};
|
|
|
|
// Fetch returning a ReadableStream response body is not currently
|
|
// supported by all browsers.
|
|
// Browser compatibility:
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
|
|
// If it is not supported, returning the whole segment when
|
|
// it's ready (as xhr)
|
|
if (!response.body) {
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const currentTime = Date.now();
|
|
|
|
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
|
|
|
const result = await sabrUmpReader.processChunk(new Uint8Array(arrayBuffer));
|
|
|
|
if (result) {
|
|
checkResultIntegrity(result);
|
|
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
|
|
}
|
|
|
|
if (shouldReturnEmptyResponse()) {
|
|
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
|
|
}
|
|
|
|
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
|
} else {
|
|
const reader = response.body.getReader();
|
|
|
|
let loaded = 0;
|
|
let lastLoaded = 0;
|
|
let contentLength;
|
|
|
|
while (!abortController.signal.aborted) {
|
|
let readObj;
|
|
try {
|
|
readObj = await reader.read();
|
|
} catch {
|
|
// If we abort the request while reading, we'll get an error here. Just ignore it.
|
|
break;
|
|
}
|
|
|
|
const { value, done } = readObj;
|
|
|
|
if (done) {
|
|
// If we got here, we read the whole response but there was no segment data; it means we must follow a
|
|
// redirect, or handle protocol updates.
|
|
if (shouldReturnEmptyResponse()) {
|
|
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: undefined });
|
|
}
|
|
throw createRecoverableError('Empty response with no redirect information', requestMetadata);
|
|
}
|
|
|
|
const result = await sabrUmpReader.processChunk(value);
|
|
|
|
const segmentInfo = sabrUmpReader.getSegmentInfo();
|
|
|
|
if (segmentInfo) {
|
|
if (!contentLength) {
|
|
contentLength = segmentInfo.mediaHeader.contentLength;
|
|
}
|
|
|
|
loaded += segmentInfo.lastChunkSize || 0;
|
|
segmentInfo.lastChunkSize = 0;
|
|
}
|
|
|
|
const currentTime = Date.now();
|
|
const chunkSize = loaded - lastLoaded;
|
|
|
|
// If the time between last time and this time we got
|
|
// progress event is long enough, or if a whole segment
|
|
// is downloaded, call progressUpdated().
|
|
if ((currentTime - lastTime > 100 && chunkSize >= minBytes) || result) {
|
|
// If we have a result, check its integrity before attempting anything.
|
|
if (result) checkResultIntegrity(result);
|
|
if (contentLength) {
|
|
const numBytesRemaining = result ? 0 : parseInt(contentLength) - loaded;
|
|
try {
|
|
progressUpdated(currentTime - lastTime, chunkSize, numBytesRemaining);
|
|
} catch { /** no-op */
|
|
} finally {
|
|
lastLoaded = loaded;
|
|
lastTime = currentTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result) {
|
|
abortController.abort();
|
|
return this.createShakaResponse({ uri, request, requestType, response, arrayBuffer: result.data });
|
|
}
|
|
}
|
|
|
|
// Unreachable if the loop is aborted correctly.
|
|
throw createRecoverableError('UMP stream processing was aborted but did not produce a result.', requestMetadata);
|
|
}
|
|
}
|
|
|
|
private async request(
|
|
uri: string,
|
|
request: shaka.extern.Request,
|
|
requestType: shaka.net.NetworkingEngine.RequestType,
|
|
init: RequestInit,
|
|
abortController: AbortController,
|
|
abortStatus: { canceled: boolean; timedOut: boolean },
|
|
progressUpdated: shaka.extern.ProgressUpdated,
|
|
headersReceived: shaka.extern.HeadersReceived,
|
|
minBytes: number
|
|
): Promise<shaka.extern.Response> {
|
|
try {
|
|
const requestMetadata = this.requestMetadataManager?.getRequestMetadata(uri);
|
|
|
|
// Check the cache first.
|
|
if (requestMetadata) {
|
|
const cachedResponse = await this.handleCachedRequest(requestMetadata, uri, request, progressUpdated, headersReceived, requestType);
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
}
|
|
|
|
// We only make one InnerTube request through the player, and it needs to be proxied properly.
|
|
const fetchFn = uri.includes('get_drm_license') && checkExtension() ? getInjectedProxyFunction() : fetch;
|
|
|
|
const response = await fetchFn(uri, init);
|
|
headersReceived(headersToGenericObject(response.headers));
|
|
|
|
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 other requests normally.
|
|
const lastTime = Date.now();
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const currentTime = Date.now();
|
|
|
|
progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
|
|
|
|
return this.createShakaResponse({
|
|
uri,
|
|
request,
|
|
requestType,
|
|
response,
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
public checkPlayerStatus(): asserts this is ({ player: shaka.Player } & this) {
|
|
if (!this.player) {
|
|
throw new Error('Player not initialized');
|
|
}
|
|
}
|
|
|
|
public getPlayerTime() {
|
|
this.checkPlayerStatus();
|
|
return this.player.getMediaElement()?.currentTime || 0;
|
|
}
|
|
|
|
public getPlaybackRate() {
|
|
this.checkPlayerStatus();
|
|
return this.player.getPlaybackRate();
|
|
}
|
|
|
|
public getBandwidthEstimate() {
|
|
this.checkPlayerStatus();
|
|
return this.player.getStats().estimatedBandwidth;
|
|
}
|
|
|
|
public getActiveTrackFormats(activeFormat: SabrFormat, sabrFormats: SabrFormat[]): {
|
|
videoFormat?: SabrFormat;
|
|
audioFormat?: SabrFormat
|
|
} {
|
|
this.checkPlayerStatus();
|
|
|
|
const activeVariant = this.player.getVariantTracks().find((track) =>
|
|
FormatKeyUtils.getUniqueFormatId(activeFormat) === (activeFormat.width ? track.originalVideoId : track.originalAudioId)
|
|
);
|
|
|
|
if (!activeVariant) {
|
|
return { videoFormat: undefined, audioFormat: undefined };
|
|
}
|
|
|
|
const formatMap = new Map(sabrFormats.map((format) => [ FormatKeyUtils.getUniqueFormatId(format), format ]));
|
|
|
|
return {
|
|
videoFormat: activeVariant.originalVideoId ? formatMap.get(activeVariant.originalVideoId) : undefined,
|
|
audioFormat: activeVariant.originalAudioId ? formatMap.get(activeVariant.originalAudioId) : undefined
|
|
};
|
|
}
|
|
|
|
public registerRequestInterceptor(interceptor: RequestFilter): void {
|
|
this.checkPlayerStatus();
|
|
|
|
const networkingEngine = this.player.getNetworkingEngine();
|
|
if (!networkingEngine)
|
|
return;
|
|
|
|
this.requestFilter = async (type, request, context) => {
|
|
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(request.uris[0])) return;
|
|
|
|
const modifiedRequest = await interceptor({
|
|
headers: request.headers,
|
|
url: request.uris[0],
|
|
method: request.method,
|
|
segment: {
|
|
getStartTime: () => context?.segment?.getStartTime() ?? null,
|
|
isInit: () => !context?.segment
|
|
},
|
|
body: request.body
|
|
});
|
|
|
|
if (modifiedRequest) {
|
|
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;
|
|
}
|
|
};
|
|
|
|
networkingEngine.registerRequestFilter(this.requestFilter);
|
|
}
|
|
|
|
public registerResponseInterceptor(interceptor: ResponseFilter): void {
|
|
this.checkPlayerStatus();
|
|
const networkingEngine = this.player.getNetworkingEngine();
|
|
if (!networkingEngine) return;
|
|
|
|
this.responseFilter = async (type, response, context) => {
|
|
if (type !== shaka.net.NetworkingEngine.RequestType.SEGMENT || !isGoogleVideoURL(response.uri)) return;
|
|
|
|
const modifiedResponse = await interceptor({
|
|
url: response.originalRequest.uris[0],
|
|
method: response.originalRequest.method,
|
|
headers: response.headers,
|
|
data: response.data,
|
|
makeRequest: async (url: string, headers: Record<string, string>) => {
|
|
const retryParameters = this.player!.getConfiguration().streaming.retryParameters;
|
|
const redirectRequest = shaka.net.NetworkingEngine.makeRequest([ url ], retryParameters);
|
|
Object.assign(redirectRequest.headers, headers);
|
|
|
|
const requestOperation = networkingEngine.request(type, redirectRequest, context);
|
|
const 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);
|
|
}
|
|
|
|
public createShakaResponse(args: ShakaResponseArgs): shaka.extern.Response {
|
|
return makeResponse(
|
|
headersToGenericObject(args.response.headers),
|
|
args.arrayBuffer as any || new ArrayBuffer(0),
|
|
args.response.status,
|
|
args.uri,
|
|
args.response.url,
|
|
args.request,
|
|
args.requestType
|
|
);
|
|
}
|
|
|
|
public dispose(): void {
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
this.abortController = undefined;
|
|
}
|
|
|
|
if (this.player) {
|
|
const networkingEngine = this.player.getNetworkingEngine();
|
|
|
|
if (networkingEngine && this.requestFilter && this.responseFilter) {
|
|
networkingEngine.unregisterRequestFilter(this.requestFilter);
|
|
networkingEngine.unregisterResponseFilter(this.responseFilter);
|
|
}
|
|
|
|
shaka.net.NetworkingEngine.unregisterScheme('http');
|
|
shaka.net.NetworkingEngine.unregisterScheme('https');
|
|
|
|
this.player = null;
|
|
}
|
|
}
|
|
} |