googlevideo
Version:
A set of utilities for working with Google Video APIs.
260 lines (259 loc) • 11.8 kB
JavaScript
import { UMP } from './UMP.js';
import { ChunkedDataBuffer } from './ChunkedDataBuffer.js';
import { EventEmitterLike, PART, QUALITY, base64ToU8, getFormatKey } from '../utils/index.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';
const DEFAULT_QUALITY = QUALITY.HD720;
export class ServerAbrStream extends EventEmitterLike {
constructor(args) {
super();
this.initializedFormats = [];
this.formatsByKey = new Map();
this.headerIdToFormatKeyMap = new Map();
this.previousSequences = new Map();
this.fetchFunction = args.fetch || fetch;
this.serverAbrStreamingUrl = args.serverAbrStreamingUrl;
this.videoPlaybackUstreamerConfig = args.videoPlaybackUstreamerConfig;
this.poToken = args.poToken;
this.totalDurationMs = args.durationMs;
}
on(event, listener) {
super.on(event, listener);
}
once(event, listener) {
super.once(event, listener);
}
/**
* Initializes the server ABR stream with the provided options.
* @param args - The initialization options.
*/
async init(args) {
const { audioFormats, videoFormats, clientAbrState: initialState } = args;
const firstVideoFormat = videoFormats ? videoFormats[0] : undefined;
const clientAbrState = {
lastManualDirection: 0,
timeSinceLastManualFormatSelectionMs: 0,
lastManualSelectedResolution: videoFormats.length === 1 ? firstVideoFormat?.height : DEFAULT_QUALITY,
stickyResolution: videoFormats.length === 1 ? firstVideoFormat?.height : DEFAULT_QUALITY,
playerTimeMs: 0,
visibility: 0,
enabledTrackTypesBitfield: 0,
...initialState
};
const audioFormatIds = audioFormats.map((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
const videoFormatIds = videoFormats.map((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
if (typeof clientAbrState.playerTimeMs !== 'number')
throw new Error('Invalid media start time');
try {
while (clientAbrState.playerTimeMs < this.totalDurationMs) {
const data = await this.fetchMedia({ clientAbrState, audioFormatIds, videoFormatIds });
this.emit('data', data);
if (data.sabrError)
break;
const mainFormat = clientAbrState.enabledTrackTypesBitfield === 0
? data.initializedFormats.find((fmt) => fmt.mimeType?.includes('video'))
: data.initializedFormats[0];
for (const fmt of data.initializedFormats) {
this.previousSequences.set(fmt.formatKey, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0));
}
if (!mainFormat ||
mainFormat.sequenceCount ===
mainFormat.sequenceList[mainFormat.sequenceList.length - 1]?.sequenceNumber) {
this.emit('end', data);
break;
}
clientAbrState.playerTimeMs += mainFormat.sequenceList.reduce((acc, seq) => acc + (seq.durationMs || 0), 0);
}
}
catch (error) {
this.emit('error', error);
clientAbrState.playerTimeMs = Infinity;
}
}
async fetchMedia(args) {
const { clientAbrState, audioFormatIds, videoFormatIds } = args;
const body = VideoPlaybackAbrRequest.encode({
clientAbrState: clientAbrState,
selectedAudioFormatIds: audioFormatIds,
selectedVideoFormatIds: videoFormatIds,
selectedFormatIds: this.initializedFormats.map((fmt) => fmt.formatId),
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'
}
},
bufferedRanges: this.initializedFormats.map((fmt) => fmt._state),
field1000: []
}).finish();
const response = await this.fetchFunction(this.serverAbrStreamingUrl, { method: 'POST', body });
const data = await response.arrayBuffer();
if (response.status !== 200 || !data.byteLength)
throw new Error(`Received an invalid response from the server: ${response.status}`);
return this.parseUMPResponse(new Uint8Array(data));
}
/**
* Parses the UMP response data and updates the initialized formats.
* @param response - The UMP response data as a byte array.
*/
async parseUMPResponse(response) {
this.headerIdToFormatKeyMap.clear();
this.initializedFormats.forEach((format) => {
format.sequenceList = [];
format.mediaChunks = [];
});
let sabrError;
let sabrRedirect;
let streamProtectionStatus;
const ump = new UMP(new ChunkedDataBuffer([response]));
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
};
}
processMediaHeader(data) {
const mediaHeader = MediaHeader.decode(data);
if (!mediaHeader.formatId)
return;
const formatKey = getFormatKey(mediaHeader.formatId);
const currentFormat = this.formatsByKey.get(formatKey) || this.registerFormat(mediaHeader);
if (!currentFormat)
return;
// 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.endSegmentIndex += 1;
}
}
}
processMediaData(data) {
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]);
}
processEndOfMedia(data) {
const headerId = data.getUint8(0);
this.headerIdToFormatKeyMap.delete(headerId);
}
processNextRequestPolicy(data) {
const nextRequestPolicy = NextRequestPolicy.decode(data);
this.playbackCookie = nextRequestPolicy.playbackCookie;
}
processFormatInitialization(data) {
const formatInitializationMetadata = FormatInitializationMetadata.decode(data);
this.registerFormat(formatInitializationMetadata);
}
processSabrRedirect(data) {
const sabrRedirect = SabrRedirect.decode(data);
if (!sabrRedirect.url)
throw new Error('Invalid SABR redirect');
this.serverAbrStreamingUrl = sabrRedirect.url;
return sabrRedirect;
}
registerFormat(data) {
if (!data.formatId)
return;
const formatKey = getFormatKey(data.formatId);
if (!this.formatsByKey.has(formatKey)) {
const format = {
formatId: data.formatId,
formatKey: formatKey,
durationMs: data.durationMs,
mimeType: 'mimeType' in data ? data.mimeType : undefined,
sequenceCount: 'field4' in data ? data.field4 : undefined,
sequenceList: [],
mediaChunks: [],
_state: {
formatId: data.formatId,
startTimeMs: 0,
durationMs: 0,
startSegmentIndex: 1,
endSegmentIndex: 0
}
};
this.initializedFormats.push(format);
this.formatsByKey.set(formatKey, this.initializedFormats[this.initializedFormats.length - 1]);
return format;
}
}
}