mirror of
https://github.com/LuanRT/googlevideo.git
synced 2026-06-28 00:56:20 +00:00
feat: init repo
This commit is contained in:
106
src/core/ChunkedDataBuffer.ts
Normal file
106
src/core/ChunkedDataBuffer.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export class ChunkedDataBuffer {
|
||||
public chunks: Uint8Array[];
|
||||
public currentChunkOffset: number;
|
||||
public currentChunkIndex: number;
|
||||
public currentDataView?: DataView;
|
||||
public totalLength: number;
|
||||
|
||||
constructor(chunks: Uint8Array[] = []) {
|
||||
this.chunks = [];
|
||||
this.currentChunkOffset = this.currentChunkIndex = 0;
|
||||
this.currentDataView = undefined;
|
||||
this.totalLength = 0;
|
||||
chunks.forEach((chunk) => {
|
||||
this.append(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
getLength(): number {
|
||||
return this.totalLength;
|
||||
}
|
||||
|
||||
append(chunk: Uint8Array): void {
|
||||
if (this.canMergeWithLastChunk(chunk)) {
|
||||
const lastChunk = this.chunks[this.chunks.length - 1];
|
||||
this.chunks[this.chunks.length - 1] = new Uint8Array(
|
||||
lastChunk.buffer,
|
||||
lastChunk.byteOffset,
|
||||
lastChunk.length + chunk.length
|
||||
);
|
||||
this.resetFocus();
|
||||
} else {
|
||||
this.chunks.push(chunk);
|
||||
}
|
||||
this.totalLength += chunk.length;
|
||||
}
|
||||
|
||||
split(position: number): { extractedBuffer: ChunkedDataBuffer; remainingBuffer: ChunkedDataBuffer } {
|
||||
const extractedBuffer = new ChunkedDataBuffer();
|
||||
const remainingBuffer = new ChunkedDataBuffer();
|
||||
const iterator = this.chunks[Symbol.iterator]();
|
||||
let item = iterator.next();
|
||||
|
||||
while (!item.done) {
|
||||
const chunk = item.value;
|
||||
if (position >= chunk.length) {
|
||||
extractedBuffer.append(chunk);
|
||||
position -= chunk.length;
|
||||
} else if (position > 0) {
|
||||
extractedBuffer.append(new Uint8Array(chunk.buffer, chunk.byteOffset, position));
|
||||
remainingBuffer.append(
|
||||
new Uint8Array(chunk.buffer, chunk.byteOffset + position, chunk.length - position)
|
||||
);
|
||||
position = 0;
|
||||
} else {
|
||||
remainingBuffer.append(chunk);
|
||||
}
|
||||
item = iterator.next();
|
||||
}
|
||||
|
||||
return { extractedBuffer, remainingBuffer };
|
||||
}
|
||||
|
||||
isFocused(position: number): boolean {
|
||||
return position >= this.currentChunkOffset && position < this.currentChunkOffset + this.chunks[this.currentChunkIndex].length;
|
||||
}
|
||||
|
||||
focus(position: number): void {
|
||||
if (!this.isFocused(position)) {
|
||||
if (position < this.currentChunkOffset) this.resetFocus();
|
||||
|
||||
while (
|
||||
this.currentChunkOffset + this.chunks[this.currentChunkIndex].length <= position &&
|
||||
this.currentChunkIndex < this.chunks.length - 1
|
||||
) {
|
||||
this.currentChunkOffset += this.chunks[this.currentChunkIndex].length;
|
||||
this.currentChunkIndex += 1;
|
||||
}
|
||||
|
||||
this.currentDataView = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
canReadBytes(position: number, length: number): boolean {
|
||||
return position + length <= this.totalLength;
|
||||
}
|
||||
|
||||
getUint8(position: number): number {
|
||||
this.focus(position);
|
||||
return this.chunks[this.currentChunkIndex][position - this.currentChunkOffset];
|
||||
}
|
||||
|
||||
private canMergeWithLastChunk(chunk: Uint8Array): boolean {
|
||||
if (this.chunks.length === 0) return false;
|
||||
const lastChunk = this.chunks[this.chunks.length - 1];
|
||||
return (
|
||||
lastChunk.buffer === chunk.buffer &&
|
||||
lastChunk.byteOffset + lastChunk.length === chunk.byteOffset
|
||||
);
|
||||
}
|
||||
|
||||
private resetFocus(): void {
|
||||
this.currentDataView = undefined;
|
||||
this.currentChunkIndex = 0;
|
||||
this.currentChunkOffset = 0;
|
||||
}
|
||||
}
|
||||
300
src/core/ServerAbrStream.ts
Normal file
300
src/core/ServerAbrStream.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* TODO: Use camelCase for all variables and functions here (except for protobuf generated stuff)
|
||||
* I was originally planning to implement this into YouTube.js, but as I started implementing more
|
||||
* googlevideo related things, I realized this would be better suited as a separate module :).
|
||||
*/
|
||||
|
||||
import { UMP } from './UMP.js';
|
||||
import { EventEmitterLike, PART, base64ToU8 } from '../utils/index.js';
|
||||
|
||||
import { MediaInfo_MediaType } from '../../protos/generated/video_streaming/video_playback_abr_request.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/video_playback_abr_request.js';
|
||||
import type { FetchFunction, InitializedFormat, InitOptions, MediaArgs, ServerAbrResponse, ServerAbrStreamOptions } from '../utils/types.js';
|
||||
import { ChunkedDataBuffer } from './ChunkedDataBuffer.js';
|
||||
|
||||
export class ServerAbrStream extends EventEmitterLike {
|
||||
private fetch_fn: FetchFunction;
|
||||
private server_abr_streaming_url: string;
|
||||
private video_playback_ustreamer_config: string;
|
||||
private po_token?: string;
|
||||
private playback_cookie?: PlaybackCookie;
|
||||
private initialized_formats: InitializedFormat[] = [];
|
||||
private total_duration_ms: number;
|
||||
|
||||
constructor(args: ServerAbrStreamOptions) {
|
||||
super();
|
||||
this.fetch_fn = args.fetch || fetch;
|
||||
this.server_abr_streaming_url = args.server_abr_streaming_url;
|
||||
this.video_playback_ustreamer_config = args.video_playback_ustreamer_config;
|
||||
this.po_token = args.po_token;
|
||||
this.total_duration_ms = args.duration_ms;
|
||||
}
|
||||
|
||||
public on(event: 'data', listener: (data: ServerAbrResponse) => void): void;
|
||||
public on(event: 'error', listener: (error: Error) => void): void;
|
||||
public on(event: string, listener: (...args: any[]) => void): void {
|
||||
super.on(event, listener);
|
||||
}
|
||||
|
||||
public once(event: 'data', listener: (data: 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);
|
||||
}
|
||||
|
||||
public async init(args: InitOptions) {
|
||||
const { audio_formats, video_formats, media_info: initial_media_info } = args;
|
||||
|
||||
const first_video_format = video_formats ? video_formats[0] : undefined;
|
||||
|
||||
const media_info: MediaInfo = {
|
||||
lastManualDirection: 0,
|
||||
timeSinceLastManualFormatSelectionMs: 0,
|
||||
videoWidth: video_formats.length === 1 ? first_video_format?.width : 720,
|
||||
iea: video_formats.length === 1 ? first_video_format?.width : 720,
|
||||
startTimeMs: 0,
|
||||
visibility: 0,
|
||||
mediaType: MediaInfo_MediaType.MEDIA_TYPE_DEFAULT,
|
||||
...initial_media_info
|
||||
};
|
||||
|
||||
const audio_format_ids = audio_formats.map<FormatId>((fmt) => ({
|
||||
itag: fmt.itag,
|
||||
lastModified: parseInt(fmt.last_modified_ms),
|
||||
xtags: fmt.xtags
|
||||
}));
|
||||
|
||||
const video_format_ids = video_formats.map<FormatId>((fmt) => ({
|
||||
itag: fmt.itag,
|
||||
lastModified: parseInt(fmt.last_modified_ms),
|
||||
xtags: fmt.xtags
|
||||
}));
|
||||
|
||||
if (typeof media_info.startTimeMs !== 'number')
|
||||
throw new Error('Invalid media start time');
|
||||
|
||||
try {
|
||||
while (media_info.startTimeMs < this.total_duration_ms) {
|
||||
const data = await this.fetchMedia({ media_info, audio_format_ids, video_format_ids });
|
||||
|
||||
this.emit('data', data);
|
||||
|
||||
if (data.sabr_error) break;
|
||||
|
||||
const main_format =
|
||||
media_info.mediaType === MediaInfo_MediaType.MEDIA_TYPE_DEFAULT
|
||||
? data.initialized_formats.find((fmt) => fmt.mime_type?.includes('video'))
|
||||
: data.initialized_formats[0];
|
||||
|
||||
if (!main_format) break;
|
||||
if (main_format?.sequence_count === main_format.sequence_list[main_format.sequence_list.length - 1].sequence_number) break;
|
||||
|
||||
media_info.startTimeMs += main_format.sequence_list.reduce((acc, seq) => acc + (seq.duration_ms || 0), 0);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMedia(args: MediaArgs): Promise<ServerAbrResponse> {
|
||||
const { media_info, audio_format_ids, video_format_ids } = args;
|
||||
|
||||
this.initialized_formats.forEach((format) => {
|
||||
format.sequence_list = [];
|
||||
format.media_data = new Uint8Array(0);
|
||||
});
|
||||
|
||||
const body = VideoPlaybackAbrRequest.encode({
|
||||
mediaInfo: media_info,
|
||||
formatIds: this.initialized_formats.map((fmt) => fmt.format_id),
|
||||
audioFormatIds: audio_format_ids,
|
||||
videoFormatIds: video_format_ids,
|
||||
videoPlaybackUstreamerConfig: base64ToU8(this.video_playback_ustreamer_config),
|
||||
sc: {
|
||||
field5: [],
|
||||
field6: [],
|
||||
poToken: this.po_token ? base64ToU8(this.po_token) : undefined,
|
||||
playbackCookie: this.playback_cookie ? PlaybackCookie.encode(this.playback_cookie).finish() : undefined,
|
||||
clientInfo: {
|
||||
clientName: 1,
|
||||
clientVersion: '2.2040620.05.00',
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0'
|
||||
}
|
||||
},
|
||||
ud: this.initialized_formats.map((fmt) => fmt._state),
|
||||
field1000: []
|
||||
}).finish();
|
||||
|
||||
const response = await this.fetch_fn(this.server_abr_streaming_url, { method: 'POST', body });
|
||||
|
||||
return this.processUMPResponse(response);
|
||||
}
|
||||
|
||||
public async processUMPResponse(response: Response) {
|
||||
let sabr_error: SabrError | undefined;
|
||||
let stream_protection_status: StreamProtectionStatus | undefined;
|
||||
|
||||
const data = await response.arrayBuffer();
|
||||
const ump = new UMP(new ChunkedDataBuffer([ new Uint8Array(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_REDIRECT:
|
||||
this.processSabrRedirect(data);
|
||||
break;
|
||||
case PART.SABR_ERROR:
|
||||
sabr_error = SabrError.decode(data);
|
||||
break;
|
||||
case PART.STREAM_PROTECTION_STATUS:
|
||||
stream_protection_status = StreamProtectionStatus.decode(data);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
initialized_formats: this.initialized_formats,
|
||||
stream_protection_status,
|
||||
sabr_error
|
||||
};
|
||||
}
|
||||
|
||||
private processMediaHeader(data: Uint8Array) {
|
||||
const media_header = MediaHeader.decode(data);
|
||||
const target_format = this.initialized_formats.find((fmt) => fmt.format_id.itag === media_header.itag);
|
||||
|
||||
if (!target_format) return;
|
||||
|
||||
// Skip processing if this is an init segment and we've already received it.
|
||||
if (media_header.isInitSeg) {
|
||||
if (!target_format.init_segment) {
|
||||
target_format._init_segment_media_id = media_header.headerId;
|
||||
} else return;
|
||||
}
|
||||
|
||||
// Save the header's ID so we can identify its media data later.
|
||||
if (!target_format._media_data_ids.includes(media_header.headerId || 0)) {
|
||||
target_format._media_data_ids.push(media_header.headerId || 0);
|
||||
}
|
||||
|
||||
if (media_header.sequenceNumber && !target_format.sequence_list.some((seq) => seq.sequence_number === media_header.sequenceNumber)) {
|
||||
target_format.sequence_list.push({
|
||||
itag: media_header.itag,
|
||||
format_id: media_header.formatId,
|
||||
duration_ms: media_header.durationMs,
|
||||
start_ms: media_header.startMs,
|
||||
start_data_range: media_header.startDataRange,
|
||||
sequence_number: media_header.sequenceNumber,
|
||||
content_length: media_header.contentLength,
|
||||
time_range: media_header.timeRange
|
||||
});
|
||||
|
||||
// This ensures sequences are retrieved in order.
|
||||
this.initialized_formats.forEach((item) => {
|
||||
if (item._state && item.format_id.itag === media_header.itag) {
|
||||
item._state.durationMs += media_header.durationMs || 0;
|
||||
item._state.field5 += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private processMediaData(data: ChunkedDataBuffer) {
|
||||
const media_data_id = data.getUint8(0);
|
||||
const new_data = data.split(1).remainingBuffer.chunks[0];
|
||||
|
||||
const target_format = this.initialized_formats.find((fmt) => fmt._media_data_ids.includes(media_data_id));
|
||||
|
||||
if (!target_format) return;
|
||||
|
||||
const isInitSegData = target_format._init_segment_media_id === media_data_id;
|
||||
|
||||
if (target_format.init_segment && isInitSegData)
|
||||
return;
|
||||
|
||||
if (isInitSegData) {
|
||||
target_format.init_segment = new_data;
|
||||
delete target_format._init_segment_media_id;
|
||||
return;
|
||||
}
|
||||
|
||||
const combined_length = target_format.media_data.length + new_data.length;
|
||||
const temp_media_data = new Uint8Array(combined_length);
|
||||
|
||||
temp_media_data.set(target_format.media_data);
|
||||
temp_media_data.set(new_data, target_format.media_data.length);
|
||||
|
||||
target_format.media_data = temp_media_data;
|
||||
}
|
||||
|
||||
private processEndOfMedia(data: ChunkedDataBuffer) {
|
||||
const media_data_id = data.getUint8(0);
|
||||
const target_format = this.initialized_formats.find((fmt) => fmt._media_data_ids.includes(media_data_id));
|
||||
if (target_format) target_format._media_data_ids.splice(target_format._media_data_ids.indexOf(media_data_id), 1);
|
||||
}
|
||||
|
||||
private processNextRequestPolicy(data: Uint8Array) {
|
||||
const next_request_policy = NextRequestPolicy.decode(data);
|
||||
this.playback_cookie = next_request_policy.playbackCookie;
|
||||
}
|
||||
|
||||
private processFormatInitialization(data: Uint8Array) {
|
||||
const format_initialization_metadata = FormatInitializationMetadata.decode(data);
|
||||
if (format_initialization_metadata.formatId && !this.initialized_formats.some((item) => item.format_id.itag === format_initialization_metadata.formatId?.itag)) {
|
||||
this.initialized_formats.push({
|
||||
format_id: format_initialization_metadata.formatId,
|
||||
duration_ms: format_initialization_metadata.durationMs,
|
||||
mime_type: format_initialization_metadata.mimeType,
|
||||
sequence_count: format_initialization_metadata.field4,
|
||||
sequence_list: [],
|
||||
media_data: new Uint8Array(),
|
||||
// Only meant to be used internally.
|
||||
_media_data_ids: [],
|
||||
_state: {
|
||||
formatId: format_initialization_metadata.formatId,
|
||||
startTimeMs: 0,
|
||||
durationMs: 0,
|
||||
field4: 1,
|
||||
field5: 0
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private processSabrRedirect(data: Uint8Array) {
|
||||
const sabr_redirect = SabrRedirect.decode(data);
|
||||
|
||||
if (!sabr_redirect.url)
|
||||
throw new Error('Invalid SABR redirect');
|
||||
|
||||
this.server_abr_streaming_url = sabr_redirect.url;
|
||||
}
|
||||
}
|
||||
127
src/core/UMP.ts
Normal file
127
src/core/UMP.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Part } from '../index.js';
|
||||
import type { ChunkedDataBuffer } from './ChunkedDataBuffer.js';
|
||||
|
||||
export class UMP {
|
||||
private chunkedDataBuffer: ChunkedDataBuffer;
|
||||
|
||||
constructor(chunkedDataBuffer: ChunkedDataBuffer) {
|
||||
this.chunkedDataBuffer = chunkedDataBuffer;
|
||||
}
|
||||
|
||||
public parse(handlePart: (part: Part) => void) {
|
||||
while (true) {
|
||||
let offset = 0;
|
||||
|
||||
const [ partType, newOffset ] = this.readVarInt(offset);
|
||||
offset = newOffset;
|
||||
|
||||
const [ partSize, finalOffset ] = this.readVarInt(offset);
|
||||
offset = finalOffset;
|
||||
|
||||
if (partType < 0 || partSize < 0)
|
||||
break;
|
||||
|
||||
// Note that we don't handle cases like this YET..
|
||||
if (!this.chunkedDataBuffer.canReadBytes(offset, partSize))
|
||||
break;
|
||||
|
||||
const splitResult = this.chunkedDataBuffer.split(offset).remainingBuffer.split(partSize);
|
||||
offset = 0;
|
||||
|
||||
handlePart({
|
||||
type: partType,
|
||||
size: partSize,
|
||||
data: splitResult.extractedBuffer
|
||||
});
|
||||
|
||||
this.chunkedDataBuffer = splitResult.remainingBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
public readVarInt(offset: number): [ number, number ] {
|
||||
let byteLength: number;
|
||||
|
||||
if (this.chunkedDataBuffer.canReadBytes(offset, 1)) {
|
||||
const firstByte = this.chunkedDataBuffer.getUint8(offset);
|
||||
|
||||
// Determine the length of the val
|
||||
if (firstByte < 128) {
|
||||
byteLength = 1;
|
||||
} else if (firstByte < 192) {
|
||||
byteLength = 2;
|
||||
} else if (firstByte < 224) {
|
||||
byteLength = 3;
|
||||
} else if (firstByte < 240) {
|
||||
byteLength = 4;
|
||||
} else {
|
||||
byteLength = 5;
|
||||
}
|
||||
} else {
|
||||
byteLength = 0;
|
||||
}
|
||||
|
||||
if (byteLength < 1 || !this.chunkedDataBuffer.canReadBytes(offset, byteLength)) {
|
||||
return [ -1, offset ];
|
||||
}
|
||||
|
||||
let value: number;
|
||||
|
||||
// Now read it based on the length
|
||||
switch (byteLength) {
|
||||
case 1:
|
||||
value = this.chunkedDataBuffer.getUint8(offset++);
|
||||
break;
|
||||
case 2: {
|
||||
const byte1 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte2 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
value = (byte1 & 0x3f) + 64 * byte2;
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
const byte1 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte2 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte3 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
value = (byte1 & 0x1f) + 32 * (byte2 + 256 * byte3);
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
const byte1 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte2 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte3 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
const byte4 = this.chunkedDataBuffer.getUint8(offset++);
|
||||
value = (byte1 & 0x0f) + 16 * (byte2 + 256 * (byte3 + 256 * byte4));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const tempOffset = offset + 1;
|
||||
this.chunkedDataBuffer.focus(tempOffset);
|
||||
|
||||
if (this.canReadFromCurrentChunk(tempOffset, 4)) {
|
||||
value = this.getCurrentDataView().getUint32(tempOffset - this.chunkedDataBuffer.currentChunkOffset, true);
|
||||
} else {
|
||||
const byte3 = this.chunkedDataBuffer.getUint8(tempOffset + 2) + 256 * this.chunkedDataBuffer.getUint8(tempOffset + 3);
|
||||
value =
|
||||
this.chunkedDataBuffer.getUint8(tempOffset) +
|
||||
256 * (this.chunkedDataBuffer.getUint8(tempOffset + 1) + 256 * byte3);
|
||||
}
|
||||
offset += 5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [ value, offset ];
|
||||
}
|
||||
|
||||
public canReadFromCurrentChunk(offset: number, length: number): boolean {
|
||||
this.chunkedDataBuffer.isFocused(offset);
|
||||
return offset - this.chunkedDataBuffer.currentChunkOffset + length <= this.chunkedDataBuffer.chunks[this.chunkedDataBuffer.currentChunkIndex].length;
|
||||
}
|
||||
|
||||
public getCurrentDataView(): DataView {
|
||||
if (!this.chunkedDataBuffer.currentDataView) {
|
||||
const currentChunk = this.chunkedDataBuffer.chunks[this.chunkedDataBuffer.currentChunkIndex];
|
||||
this.chunkedDataBuffer.currentDataView = new DataView(currentChunk.buffer, currentChunk.byteOffset, currentChunk.length);
|
||||
}
|
||||
return this.chunkedDataBuffer.currentDataView;
|
||||
}
|
||||
}
|
||||
3
src/core/index.ts
Normal file
3
src/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ChunkedDataBuffer.js';
|
||||
export * from './UMP.js';
|
||||
export * from './ServerAbrStream.js';
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import * as GoogleVideo from './core/index.js';
|
||||
export { GoogleVideo };
|
||||
export default GoogleVideo;
|
||||
export * from './utils/index.js';
|
||||
47
src/utils/EventEmitterLike.ts
Normal file
47
src/utils/EventEmitterLike.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CustomEvent } from './index.js';
|
||||
|
||||
export class EventEmitterLike extends EventTarget {
|
||||
#legacy_listeners = new Map<(...args: any[]) => void, EventListener>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
emit(type: string, ...args: any[]) {
|
||||
const event = new CustomEvent(type, { detail: args });
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
on(type: string, listener: (...args: any[]) => void) {
|
||||
const wrapper: EventListener = (ev) => {
|
||||
if (ev instanceof CustomEvent) {
|
||||
listener(...ev.detail as any[]);
|
||||
} else {
|
||||
listener(ev);
|
||||
}
|
||||
};
|
||||
this.#legacy_listeners.set(listener, wrapper);
|
||||
this.addEventListener(type, wrapper);
|
||||
}
|
||||
|
||||
once(type: string, listener: (...args: any[]) => void) {
|
||||
const wrapper: EventListener = (ev) => {
|
||||
if (ev instanceof CustomEvent) {
|
||||
listener(...ev.detail as any[]);
|
||||
} else {
|
||||
listener(ev);
|
||||
}
|
||||
this.off(type, listener);
|
||||
};
|
||||
this.#legacy_listeners.set(listener, wrapper);
|
||||
this.addEventListener(type, wrapper);
|
||||
}
|
||||
|
||||
off(type: string, listener: (...args: any[]) => void) {
|
||||
const wrapper = this.#legacy_listeners.get(listener);
|
||||
if (wrapper) {
|
||||
this.removeEventListener(type, wrapper);
|
||||
this.#legacy_listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/utils/Protos.ts
Normal file
10
src/utils/Protos.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { FormatInitializationMetadata } from '../../protos/generated/video_streaming/format_initialization_metadata.js';
|
||||
export { MediaHeader } from '../../protos/generated/video_streaming/media_header.js';
|
||||
export { NextRequestPolicy } from '../../protos/generated/video_streaming/next_request_policy.js';
|
||||
export { PlaybackCookie } from '../../protos/generated/video_streaming/playback_cookie.js';
|
||||
export { PlaybackStartPolicy } from '../../protos/generated/video_streaming/playback_start_policy.js';
|
||||
export { RequestCancellationPolicy } from '../../protos/generated/video_streaming/request_cancellation_policy.js';
|
||||
export { SabrError } from '../../protos/generated/video_streaming/sabr_error.js';
|
||||
export { SabrRedirect } from '../../protos/generated/video_streaming/sabr_redirect.js';
|
||||
export { StreamProtectionStatus } from '../../protos/generated/video_streaming/stream_protection_status.js';
|
||||
export { VideoPlaybackAbrRequest } from '../../protos/generated/video_streaming/video_playback_abr_request.js';
|
||||
65
src/utils/helpers.ts
Normal file
65
src/utils/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export enum PART {
|
||||
ONESIE_HEADER = 10,
|
||||
ONESIE_DATA = 11,
|
||||
MEDIA_HEADER = 20,
|
||||
MEDIA = 21,
|
||||
MEDIA_END = 22,
|
||||
LIVE_METADATA = 31,
|
||||
HOSTNAME_CHANGE_HINT = 32,
|
||||
LIVE_METADATA_PROMISE = 33,
|
||||
LIVE_METADATA_PROMISE_CANCELLATION = 34,
|
||||
NEXT_REQUEST_POLICY = 35,
|
||||
USTREAMER_VIDEO_AND_FORMAT_DATA = 36,
|
||||
FORMAT_SELECTION_CONFIG = 37,
|
||||
USTREAMER_SELECTED_MEDIA_STREAM = 38,
|
||||
FORMAT_INITIALIZATION_METADATA = 42,
|
||||
SABR_REDIRECT = 43,
|
||||
SABR_ERROR = 44,
|
||||
SABR_SEEK = 45,
|
||||
RELOAD_PLAYER_RESPONSE = 46,
|
||||
PLAYBACK_START_POLICY = 47,
|
||||
ALLOWED_CACHED_FORMATS = 48,
|
||||
START_BW_SAMPLING_HINT = 49,
|
||||
PAUSE_BW_SAMPLING_HINT = 50,
|
||||
SELECTABLE_FORMATS = 51,
|
||||
REQUEST_IDENTIFIER = 52,
|
||||
REQUEST_CANCELLATION_POLICY = 53,
|
||||
ONESIE_PREFETCH_REJECTION = 54,
|
||||
TIMELINE_CONTEXT = 55,
|
||||
REQUEST_PIPELINING = 56,
|
||||
SABR_CONTEXT_UPDATE = 57,
|
||||
STREAM_PROTECTION_STATUS = 58,
|
||||
SABR_CONTEXT_SENDING_POLICY = 59,
|
||||
LAWNMOWER_POLICY = 60,
|
||||
SABR_ACK = 61,
|
||||
END_OF_TRACK = 62,
|
||||
CACHE_LOAD_POLICY = 63,
|
||||
LAWNMOWER_MESSAGING_POLICY = 64,
|
||||
PREWARM_CONNECTION = 65
|
||||
}
|
||||
|
||||
export { MediaInfo_MediaType as MediaType } from '../../protos/generated/video_streaming/video_playback_abr_request.js';
|
||||
|
||||
export function u8ToBase64(u8: Uint8Array): string {
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(u8)));
|
||||
}
|
||||
|
||||
export function base64ToU8(base64: string): Uint8Array {
|
||||
const standard_base64 = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded_base64 = standard_base64.padEnd(standard_base64.length + (4 - standard_base64.length % 4) % 4, '=');
|
||||
return new Uint8Array(atob(padded_base64).split('').map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
// See https://github.com/nodejs/node/issues/40678#issuecomment-1126944677
|
||||
export class CustomEvent extends Event {
|
||||
#detail;
|
||||
|
||||
constructor(type: string, options?: CustomEventInit<any[]>) {
|
||||
super(type, options);
|
||||
this.#detail = options?.detail ?? null;
|
||||
}
|
||||
|
||||
get detail() {
|
||||
return this.#detail;
|
||||
}
|
||||
}
|
||||
4
src/utils/index.ts
Normal file
4
src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './helpers.js';
|
||||
export * from './EventEmitterLike.js';
|
||||
export * as Protos from './Protos.js';
|
||||
export type * from './types.js';
|
||||
133
src/utils/types.ts
Normal file
133
src/utils/types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { FormatId } from '../../protos/generated/misc/common.js';
|
||||
import type { MediaHeader_TimeRange } from '../../protos/generated/video_streaming/media_header.js';
|
||||
import type { SabrError } from '../../protos/generated/video_streaming/sabr_error.js';
|
||||
import type { StreamProtectionStatus } from '../../protos/generated/video_streaming/stream_protection_status.js';
|
||||
import type { Zpa, MediaInfo } from '../../protos/generated/video_streaming/video_playback_abr_request.js';
|
||||
import type { ChunkedDataBuffer } from '../core/index.js';
|
||||
|
||||
export type Part = {
|
||||
type: number;
|
||||
size: number;
|
||||
data: ChunkedDataBuffer;
|
||||
};
|
||||
|
||||
export type ServerAbrStreamOptions = {
|
||||
fetch: FetchFunction;
|
||||
server_abr_streaming_url: string;
|
||||
video_playback_ustreamer_config: string;
|
||||
po_token?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
export type ServerAbrResponse = {
|
||||
initialized_formats: InitializedFormat[];
|
||||
stream_protection_status?: StreamProtectionStatus;
|
||||
sabr_error?: SabrError;
|
||||
}
|
||||
|
||||
export type Sequence = {
|
||||
itag?: number;
|
||||
format_id?: FormatId;
|
||||
duration_ms?: number;
|
||||
start_ms?: number;
|
||||
start_data_range?: number;
|
||||
sequence_number?: number;
|
||||
content_length?: number;
|
||||
time_range?: MediaHeader_TimeRange;
|
||||
}
|
||||
|
||||
export type InitializedFormat = {
|
||||
format_id: FormatId;
|
||||
duration_ms?: number;
|
||||
mime_type?: string;
|
||||
sequence_count?: number;
|
||||
init_segment?: Uint8Array;
|
||||
sequence_list: Sequence[];
|
||||
media_data: Uint8Array;
|
||||
_init_segment_media_id?: number;
|
||||
_media_data_ids: number[];
|
||||
_state: Zpa;
|
||||
}
|
||||
|
||||
export type InitOptions = {
|
||||
audio_formats: Format[];
|
||||
video_formats: Format[];
|
||||
media_info?: MediaInfo;
|
||||
};
|
||||
|
||||
export type MediaArgs = {
|
||||
media_info: MediaInfo;
|
||||
audio_format_ids: FormatId[];
|
||||
video_format_ids: FormatId[];
|
||||
}
|
||||
|
||||
export type Format = {
|
||||
itag: number;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
last_modified: Date;
|
||||
last_modified_ms: string;
|
||||
content_length?: number;
|
||||
quality?: string;
|
||||
xtags?: string;
|
||||
drm_families?: string[];
|
||||
fps?: number;
|
||||
quality_label?: string;
|
||||
projection_type?: 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
|
||||
average_bitrate?: number;
|
||||
bitrate: number;
|
||||
spatial_audio_type?: 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
|
||||
target_duration_dec?: number;
|
||||
fair_play_key_uri?: string;
|
||||
stereo_layout?: 'LEFT_RIGHT' | 'TOP_BOTTOM';
|
||||
max_dvr_duration_sec?: number;
|
||||
high_replication?: boolean;
|
||||
audio_quality?: string;
|
||||
approx_duration_ms: number;
|
||||
audio_sample_rate?: number;
|
||||
audio_channels?: number;
|
||||
loudness_db?: number;
|
||||
signature_cipher?: string;
|
||||
is_drc?: boolean;
|
||||
drm_track_type?: string;
|
||||
distinct_params?: string;
|
||||
track_absolute_loudness_lkfs?: number;
|
||||
mime_type: string;
|
||||
is_type_otf: boolean;
|
||||
init_range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
index_range?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
cipher?: string;
|
||||
audio_track?: {
|
||||
audio_is_default: boolean;
|
||||
display_name: string;
|
||||
id: string;
|
||||
};
|
||||
has_audio: boolean;
|
||||
has_video: boolean;
|
||||
has_text: boolean;
|
||||
language?: string | null;
|
||||
is_dubbed?: boolean;
|
||||
is_descriptive?: boolean;
|
||||
is_secondary?: boolean;
|
||||
is_original?: boolean;
|
||||
color_info?: {
|
||||
primaries?: string;
|
||||
transfer_characteristics?: string;
|
||||
matrix_coefficients?: string;
|
||||
};
|
||||
caption_track?: {
|
||||
display_name: string;
|
||||
vss_id: string;
|
||||
language_code: string;
|
||||
kind?: 'asr' | 'frc';
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type FetchFunction = typeof fetch;
|
||||
Reference in New Issue
Block a user