Files
googlevideo/src/core/ServerAbrStream.ts
2024-09-18 16:06:51 -03:00

331 lines
12 KiB
TypeScript

import { UMP } from './UMP.js';
import { ChunkedDataBuffer } from './ChunkedDataBuffer.js';
import { EventEmitterLike, PART, QUALITY, base64ToU8, getFormatKey } from '../utils/index.js';
import { MediaInfo_MediaType } from '../../protos/generated/video_streaming/media_info.js';
import { VideoPlaybackAbrRequest } from '../../protos/generated/video_streaming/video_playback_abr_request.js';
import { MediaHeader } from '../../protos/generated/video_streaming/media_header.js';
import { NextRequestPolicy } from '../../protos/generated/video_streaming/next_request_policy.js';
import { FormatInitializationMetadata } from '../../protos/generated/video_streaming/format_initialization_metadata.js';
import { SabrRedirect } from '../../protos/generated/video_streaming/sabr_redirect.js';
import { SabrError } from '../../protos/generated/video_streaming/sabr_error.js';
import { StreamProtectionStatus } from '../../protos/generated/video_streaming/stream_protection_status.js';
import { PlaybackCookie } from '../../protos/generated/video_streaming/playback_cookie.js';
import type { FormatId } from '../../protos/generated/misc/common.js';
import type { MediaInfo } from '../../protos/generated/video_streaming/media_info.js';
import type { FetchFunction, InitializedFormat, InitOptions, MediaArgs, ServerAbrResponse, ServerAbrStreamOptions } from '../utils/types.js';
const DEFAULT_QUALITY = QUALITY.HD720;
export class ServerAbrStream extends EventEmitterLike {
private fetchFunction: FetchFunction;
private serverAbrStreamingUrl: string;
private videoPlaybackUstreamerConfig: string;
private poToken?: string;
private playbackCookie?: PlaybackCookie;
private totalDurationMs: number;
private initializedFormats: InitializedFormat[] = [];
private formatsByKey: Map<string, InitializedFormat> = new Map();
private headerIdToFormatKeyMap: Map<number, string> = new Map();
private previousSequences: Map<string, number[]> = new Map();
constructor(args: ServerAbrStreamOptions) {
super();
this.fetchFunction = args.fetch || fetch;
this.serverAbrStreamingUrl = args.serverAbrStreamingUrl;
this.videoPlaybackUstreamerConfig = args.videoPlaybackUstreamerConfig;
this.poToken = args.poToken;
this.totalDurationMs = args.durationMs;
}
public on(event: 'end', listener: (streamData: ServerAbrResponse) => void): void;
public on(event: 'data', listener: (streamData: ServerAbrResponse) => void): void;
public on(event: 'error', listener: (error: Error) => void): void;
public on(event: string, listener: (...data: any[]) => void): void {
super.on(event, listener);
}
public once(event: 'end', listener: (streamData: ServerAbrResponse) => void): void;
public once(event: 'data', listener: (streamData: ServerAbrResponse) => void): void;
public once(event: 'error', listener: (error: Error) => void): void;
public once(event: string, listener: (...args: any[]) => void): void {
super.once(event, listener);
}
/**
* Initializes the server ABR stream with the provided options.
* @param args - The initialization options.
*/
public async init(args: InitOptions) {
const { audioFormats, videoFormats, mediaInfo: initialMediaInfo } = args;
const firstVideoFormat = videoFormats ? videoFormats[0] : undefined;
const mediaInfo: MediaInfo = {
lastManualDirection: 0,
timeSinceLastManualFormatSelectionMs: 0,
quality: videoFormats.length === 1 ? firstVideoFormat?.width : DEFAULT_QUALITY,
iea: videoFormats.length === 1 ? firstVideoFormat?.width : DEFAULT_QUALITY,
startTimeMs: 0,
visibility: 0,
mediaType: MediaInfo_MediaType.MEDIA_TYPE_DEFAULT,
...initialMediaInfo
};
const audioFormatIds = audioFormats.map<FormatId>((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
const videoFormatIds = videoFormats.map<FormatId>((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
if (typeof mediaInfo.startTimeMs !== 'number')
throw new Error('Invalid media start time');
try {
while (mediaInfo.startTimeMs < this.totalDurationMs) {
const data = await this.fetchMedia({ mediaInfo, audioFormatIds, videoFormatIds });
this.emit('data', data);
if (data.sabrError) break;
const mainFormat =
mediaInfo.mediaType === MediaInfo_MediaType.MEDIA_TYPE_DEFAULT
? data.initializedFormats.find((fmt) => fmt.mimeType?.includes('video'))
: data.initializedFormats[0];
for (const fmt of data.initializedFormats) {
this.previousSequences.set(`${fmt.formatId.itag};${fmt.formatId.lastModified};`, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0));
}
if (
!mainFormat ||
mainFormat.sequenceCount ===
mainFormat.sequenceList[mainFormat.sequenceList.length - 1].sequenceNumber
) {
this.emit('end', data);
break;
}
mediaInfo.startTimeMs += mainFormat.sequenceList.reduce((acc, seq) => acc + (seq.durationMs || 0), 0);
}
} catch (error) {
this.emit('error', error);
}
}
private async fetchMedia(args: MediaArgs): Promise<ServerAbrResponse> {
const { mediaInfo, audioFormatIds, videoFormatIds } = args;
const body = VideoPlaybackAbrRequest.encode({
mediaInfo: mediaInfo,
formatIds: this.initializedFormats.map((fmt) => fmt.formatId),
audioFormatIds: audioFormatIds,
videoFormatIds: videoFormatIds,
videoPlaybackUstreamerConfig: base64ToU8(this.videoPlaybackUstreamerConfig),
streamerContext: {
field5: [],
field6: [],
poToken: this.poToken ? base64ToU8(this.poToken) : undefined,
playbackCookie: this.playbackCookie ? PlaybackCookie.encode(this.playbackCookie).finish() : undefined,
clientInfo: {
clientName: 1,
clientVersion: '2.2040620.05.00',
osName: 'Windows',
osVersion: '10.0'
}
},
ud: this.initializedFormats.map((fmt) => fmt._state),
field1000: []
}).finish();
const response = await this.fetchFunction(this.serverAbrStreamingUrl, { method: 'POST', body });
const data = await response.arrayBuffer();
return this.parseUMPResponse(new Uint8Array(data));
}
/**
* Parses the UMP response data and updates the initialized formats.
* @param data - The UMP response data as a byte array.
*/
public async parseUMPResponse(data: Uint8Array): Promise<ServerAbrResponse> {
this.headerIdToFormatKeyMap.clear();
this.initializedFormats.forEach((format) => {
format.sequenceList = [];
format.mediaChunks = [];
});
let sabrError: SabrError | undefined;
let sabrRedirect: SabrRedirect | undefined;
let streamProtectionStatus: StreamProtectionStatus | undefined;
const ump = new UMP(new ChunkedDataBuffer([ data ]));
ump.parse((part) => {
const data = part.data.chunks[0];
switch (part.type) {
case PART.MEDIA_HEADER:
this.processMediaHeader(data);
break;
case PART.MEDIA:
this.processMediaData(part.data);
break;
case PART.MEDIA_END:
this.processEndOfMedia(part.data);
break;
case PART.NEXT_REQUEST_POLICY:
this.processNextRequestPolicy(data);
break;
case PART.FORMAT_INITIALIZATION_METADATA:
this.processFormatInitialization(data);
break;
case PART.SABR_ERROR:
sabrError = SabrError.decode(data);
break;
case PART.SABR_REDIRECT:
sabrRedirect = this.processSabrRedirect(data);
break;
case PART.STREAM_PROTECTION_STATUS:
streamProtectionStatus = StreamProtectionStatus.decode(data);
break;
default:
break;
}
});
return {
initializedFormats: this.initializedFormats,
streamProtectionStatus,
sabrRedirect,
sabrError
};
}
private processMediaHeader(data: Uint8Array) {
const mediaHeader = MediaHeader.decode(data);
if (!mediaHeader.formatId) return;
const formatKey = getFormatKey(mediaHeader.formatId);
let currentFormat = this.formatsByKey.get(formatKey);
if (!currentFormat) {
this.initializedFormats.push({
formatId: mediaHeader.formatId,
formatKey,
durationMs: mediaHeader.durationMs,
mimeType: undefined,
sequenceCount: undefined,
sequenceList: [],
mediaChunks: [],
_state: {
formatId: mediaHeader.formatId,
startTimeMs: 0,
durationMs: 0,
field4: 1,
sequenceNumber: 0
}
});
this.formatsByKey.set(formatKey, this.initializedFormats[this.initializedFormats.length - 1]);
currentFormat = this.formatsByKey.get(formatKey)!;
}
// FIXME: This is a hacky workaround to prevent duplicate sequences from being added. This should be fixed in the future (preferably by figuring out how to make the server not send duplicates).
if (mediaHeader.sequenceNumber !== undefined && this.previousSequences.get(formatKey)?.includes(mediaHeader.sequenceNumber))
return;
// Save the header's ID so we can identify its stream data later.
if (mediaHeader.headerId !== undefined) {
if (!this.headerIdToFormatKeyMap.has(mediaHeader.headerId)) {
this.headerIdToFormatKeyMap.set(mediaHeader.headerId, formatKey);
}
}
if (!currentFormat.sequenceList.some((seq) => seq.sequenceNumber === (mediaHeader.sequenceNumber || 0))) {
currentFormat.sequenceList.push({
itag: mediaHeader.itag,
formatId: mediaHeader.formatId,
isInitSegment: mediaHeader.isInitSeg,
durationMs: mediaHeader.durationMs,
startMs: mediaHeader.startMs,
startDataRange: mediaHeader.startDataRange,
sequenceNumber: mediaHeader.sequenceNumber,
contentLength: mediaHeader.contentLength,
timeRange: mediaHeader.timeRange
});
if (typeof mediaHeader.sequenceNumber === 'number') {
currentFormat._state.durationMs += mediaHeader.durationMs || 0;
currentFormat._state.sequenceNumber += 1;
}
}
}
private processMediaData(data: ChunkedDataBuffer) {
const headerId = data.getUint8(0);
const streamData = data.split(1).remainingBuffer;
const formatKey = this.headerIdToFormatKeyMap.get(headerId);
if (!formatKey) return;
const currentFormat = this.formatsByKey.get(formatKey);
if (!currentFormat) return;
currentFormat.mediaChunks.push(streamData.chunks[0]);
}
private processEndOfMedia(data: ChunkedDataBuffer) {
const headerId = data.getUint8(0);
this.headerIdToFormatKeyMap.delete(headerId);
}
private processNextRequestPolicy(data: Uint8Array) {
const nextRequestPolicy = NextRequestPolicy.decode(data);
this.playbackCookie = nextRequestPolicy.playbackCookie;
}
private processFormatInitialization(data: Uint8Array) {
const formatInitializationMetadata = FormatInitializationMetadata.decode(data);
if (!formatInitializationMetadata.formatId) return;
const formatKey = getFormatKey(formatInitializationMetadata.formatId);
if (!this.formatsByKey.has(formatKey)) {
this.initializedFormats.push({
formatId: formatInitializationMetadata.formatId,
formatKey: getFormatKey(formatInitializationMetadata.formatId),
durationMs: formatInitializationMetadata.durationMs,
mimeType: formatInitializationMetadata.mimeType,
sequenceCount: formatInitializationMetadata.field4,
sequenceList: [],
mediaChunks: [],
_state: {
formatId: formatInitializationMetadata.formatId,
startTimeMs: 0,
durationMs: 0,
field4: 1,
sequenceNumber: 0
}
});
this.formatsByKey.set(formatKey, this.initializedFormats[this.initializedFormats.length - 1]);
}
}
private processSabrRedirect(data: Uint8Array): SabrRedirect {
const sabrRedirect = SabrRedirect.decode(data);
if (!sabrRedirect.url) throw new Error('Invalid SABR redirect');
this.serverAbrStreamingUrl = sabrRedirect.url;
return sabrRedirect;
}
}