fix(ServerAbrStream): Ignore duplicate sequences

Ignoring them is not the best solution, but at least we don't end up with a corrupted file :P

Note:
This would only happen when downloading two itags (MediaType.MEDIA_TYPE_DEFAULT).

Other changes:
Convert field names to camelCase.
This commit is contained in:
Luan
2024-09-14 12:03:06 -03:00
parent 96d0a0938c
commit d9fb9431ed
2 changed files with 182 additions and 162 deletions

View File

@@ -1,7 +1,7 @@
import type { WriteStream } from 'node:fs';
import { createWriteStream } from 'node:fs';
import { Innertube, UniversalCache } from 'youtubei.js';
import GoogleVideo, { MediaType } from '../../dist/src/index.js';
import GoogleVideo, { type Format, MediaType } from '../../dist/src/index.js';
const innertube = await Innertube.create({ cache: new UniversalCache(true) });
@@ -31,17 +31,22 @@ let videoOutput: WriteStream | undefined;
const durationMs = info.basic_info?.duration ? info.basic_info.duration * 1000 : 0;
const audioFormat = info.chooseFormat({
quality: 'best',
format: 'webm',
type: 'audio'
});
const audioFormat = info.chooseFormat({ quality: 'best', format: 'webm', type: 'audio' });
const videoFormat = info.chooseFormat({ quality: '720p', format: 'webm', type: 'video' });
const videoFormat = info.chooseFormat({
quality: '720p',
format: 'mp4',
type: 'video'
});
const selectedAudioFormat: Format = {
itag: audioFormat.itag,
lastModified: audioFormat.last_modified_ms,
xtags: audioFormat.xtags
};
const selectedVideoFormat: Format = {
itag: videoFormat.itag,
lastModified: videoFormat.last_modified_ms,
width: videoFormat.width,
height: videoFormat.height,
xtags: videoFormat.xtags
};
console.info(`Selected audio format: ${audioFormat.itag} (${audioFormat.audio_quality})`);
console.info(`Selected video format: ${videoFormat.itag} (${videoFormat.quality_label})`);
@@ -59,45 +64,45 @@ if (!serverAbrStreamingUrl)
const serverAbrStream = new GoogleVideo.ServerAbrStream({
fetch: innertube.session.http.fetch_function,
server_abr_streaming_url: serverAbrStreamingUrl,
video_playback_ustreamer_config: videoPlaybackUstreamerConfig,
duration_ms: durationMs
serverAbrStreamingUrl,
videoPlaybackUstreamerConfig: videoPlaybackUstreamerConfig,
durationMs
});
serverAbrStream.on('data', (data) => {
let progressText = '';
for (const initializedFormat of data.initialized_formats) {
const isVideo = initializedFormat.mime_type?.includes('video');
const mediaFormat = info.streaming_data?.adaptive_formats.find((f) => f.itag === initializedFormat.format_id.itag);
for (const initializedFormat of data.initializedFormats) {
const isVideo = initializedFormat.mimeType?.includes('video');
const mediaFormat = info.streaming_data?.adaptive_formats.find((f) => f.itag === initializedFormat.formatId.itag);
if (isVideo && initializedFormat.media_data) {
if (isVideo && initializedFormat.mediaData) {
if (!videoOutput)
videoOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.format_id.itag}.${determineFileExtension(initializedFormat.mime_type || '')}`);
videoOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`);
if (initializedFormat.init_segment && !wroteVideoInitSegment) {
videoOutput.write(initializedFormat.init_segment);
if (initializedFormat.initSegment && !wroteVideoInitSegment) {
videoOutput.write(initializedFormat.initSegment);
wroteVideoInitSegment = true;
}
videoOutput.write(initializedFormat.media_data);
} else if (initializedFormat.media_data) {
videoOutput.write(initializedFormat.mediaData);
} else if (initializedFormat.mediaData) {
if (!audioOutput)
audioOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.format_id.itag}.${determineFileExtension(initializedFormat.mime_type || '')}`);
audioOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`);
if (initializedFormat.init_segment && !wroteAudioInitSegment) {
audioOutput.write(initializedFormat.init_segment);
if (initializedFormat.initSegment && !wroteAudioInitSegment) {
audioOutput.write(initializedFormat.initSegment);
wroteAudioInitSegment = true;
}
audioOutput.write(initializedFormat.media_data);
audioOutput.write(initializedFormat.mediaData);
}
const fmtIdentifier = `${initializedFormat.format_id.itag}_${initializedFormat.mime_type?.split(';')[0]}`;
const fmtIdentifier = `${initializedFormat.formatId.itag}_${initializedFormat.mimeType?.split(';')[0]}`;
const percentage = Math.round((initializedFormat.sequence_list.at(-1)?.start_data_range ?? 0) / (mediaFormat?.content_length ?? 0) * 100);
const percentage = Math.round((initializedFormat.sequenceList.at(-1)?.startDataRange ?? 0) / (mediaFormat?.content_length ?? 0) * 100);
if (percentage !== undefined)
if (percentage)
progressText += `${fmtIdentifier}: ${percentage}% | `;
}
@@ -111,9 +116,14 @@ serverAbrStream.on('error', (error) => {
});
await serverAbrStream.init({
audio_formats: [ audioFormat ],
video_formats: [ videoFormat ],
media_info: {
audioFormats: [ selectedAudioFormat ],
videoFormats: [ selectedVideoFormat ],
mediaInfo: {
/**
* MEDIA_TYPE_DEFAULT = 0,
* MEDIA_TYPE_AUDIO = 1,
* MEDIA_TYPE_VIDEO = 2,
*/
mediaType: MediaType.MEDIA_TYPE_DEFAULT,
startTimeMs: 0
}

View File

@@ -1,9 +1,3 @@
/**
* 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';
@@ -23,21 +17,22 @@ import type { FetchFunction, InitializedFormat, InitOptions, MediaArgs, ServerAb
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;
private fetchFn: FetchFunction;
private serverAbrStreamingUrl: string;
private videoPlaybackUstreamerConfig: string;
private poToken?: string;
private playbackCookie?: PlaybackCookie;
private initializedFormats: InitializedFormat[] = [];
private totalDurationMs: number;
private prevSeqs: Map<string, number[]> = new Map();
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;
this.fetchFn = args.fetch || fetch;
this.serverAbrStreamingUrl = args.serverAbrStreamingUrl;
this.videoPlaybackUstreamerConfig = args.videoPlaybackUstreamerConfig;
this.poToken = args.poToken;
this.totalDurationMs = args.durationMs;
}
public on(event: 'data', listener: (data: ServerAbrResponse) => void): void;
@@ -53,53 +48,61 @@ export class ServerAbrStream extends EventEmitterLike {
}
public async init(args: InitOptions) {
const { audio_formats, video_formats, media_info: initial_media_info } = args;
const { audioFormats, videoFormats, mediaInfo: initialMediaInfo } = args;
const first_video_format = video_formats ? video_formats[0] : undefined;
const media_info: MediaInfo = {
const firstVideoFormat = videoFormats ? videoFormats[0] : undefined;
const mediaInfo: 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,
videoWidth: videoFormats.length === 1 ? firstVideoFormat?.width : 720,
iea: videoFormats.length === 1 ? firstVideoFormat?.width : 720,
startTimeMs: 0,
visibility: 0,
mediaType: MediaInfo_MediaType.MEDIA_TYPE_DEFAULT,
...initial_media_info
...initialMediaInfo
};
const audio_format_ids = audio_formats.map<FormatId>((fmt) => ({
const audioFormatIds = audioFormats.map<FormatId>((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.last_modified_ms),
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
const video_format_ids = video_formats.map<FormatId>((fmt) => ({
const videoFormatIds = videoFormats.map<FormatId>((fmt) => ({
itag: fmt.itag,
lastModified: parseInt(fmt.last_modified_ms),
lastModified: parseInt(fmt.lastModified),
xtags: fmt.xtags
}));
if (typeof media_info.startTimeMs !== 'number')
if (typeof mediaInfo.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 });
while (mediaInfo.startTimeMs < this.totalDurationMs) {
const data = await this.fetchMedia({ mediaInfo, audioFormatIds, videoFormatIds });
this.emit('data', data);
if (data.sabr_error) break;
if (data.sabrError) 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];
const mainFormat =
mediaInfo.mediaType === MediaInfo_MediaType.MEDIA_TYPE_DEFAULT
? data.initializedFormats.find((fmt) => fmt.mimeType?.includes('video'))
: data.initializedFormats[0];
if (!main_format) break;
if (main_format?.sequence_count === main_format.sequence_list[main_format.sequence_list.length - 1].sequence_number) break;
for (const fmt of data.initializedFormats) {
this.prevSeqs.set(`${fmt.formatId.itag};${fmt.formatId.lastModified};`, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0));
}
media_info.startTimeMs += main_format.sequence_list.reduce((acc, seq) => acc + (seq.duration_ms || 0), 0);
if (!mainFormat) break;
if (
mainFormat?.sequenceCount ===
mainFormat.sequenceList[mainFormat.sequenceList.length - 1].sequenceNumber
)
break;
mediaInfo.startTimeMs += mainFormat.sequenceList.reduce((acc, seq) => acc + (seq.durationMs || 0), 0);
}
} catch (error) {
this.emit('error', error);
@@ -107,24 +110,24 @@ export class ServerAbrStream extends EventEmitterLike {
}
private async fetchMedia(args: MediaArgs): Promise<ServerAbrResponse> {
const { media_info, audio_format_ids, video_format_ids } = args;
const { mediaInfo, audioFormatIds, videoFormatIds } = args;
this.initialized_formats.forEach((format) => {
format.sequence_list = [];
format.media_data = new Uint8Array(0);
this.initializedFormats.forEach((format) => {
format.sequenceList = [];
format.mediaData = 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),
mediaInfo: mediaInfo,
formatIds: this.initializedFormats.map((fmt) => fmt.formatId),
audioFormatIds: audioFormatIds,
videoFormatIds: videoFormatIds,
videoPlaybackUstreamerConfig: base64ToU8(this.videoPlaybackUstreamerConfig),
sc: {
field5: [],
field6: [],
poToken: this.po_token ? base64ToU8(this.po_token) : undefined,
playbackCookie: this.playback_cookie ? PlaybackCookie.encode(this.playback_cookie).finish() : undefined,
poToken: this.poToken ? base64ToU8(this.poToken) : undefined,
playbackCookie: this.playbackCookie ? PlaybackCookie.encode(this.playbackCookie).finish() : undefined,
clientInfo: {
clientName: 1,
clientVersion: '2.2040620.05.00',
@@ -132,21 +135,22 @@ export class ServerAbrStream extends EventEmitterLike {
osVersion: '10.0'
}
},
ud: this.initialized_formats.map((fmt) => fmt._state),
ud: this.initializedFormats.map((fmt) => fmt._state),
field1000: []
}).finish();
const response = await this.fetch_fn(this.server_abr_streaming_url, { method: 'POST', body });
const response = await this.fetchFn(this.serverAbrStreamingUrl, { method: 'POST', body });
const data = await response.arrayBuffer();
return this.processUMPResponse(response);
return this.processUMPResponse(new Uint8Array(data));
}
public async processUMPResponse(response: Response) {
let sabr_error: SabrError | undefined;
let stream_protection_status: StreamProtectionStatus | undefined;
public async processUMPResponse(data: Uint8Array): Promise<ServerAbrResponse> {
let sabrError: SabrError | undefined;
let sabrRedirect: SabrRedirect | undefined;
let streamProtectionStatus: StreamProtectionStatus | undefined;
const data = await response.arrayBuffer();
const ump = new UMP(new ChunkedDataBuffer([ new Uint8Array(data) ]));
const ump = new UMP(new ChunkedDataBuffer([ data ]));
ump.parse((part) => {
const data = part.data.chunks[0];
@@ -166,14 +170,14 @@ export class ServerAbrStream extends EventEmitterLike {
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);
sabrError = SabrError.decode(data);
break;
case PART.SABR_REDIRECT:
sabrRedirect = this.processSabrRedirect(data);
break;
case PART.STREAM_PROTECTION_STATUS:
stream_protection_status = StreamProtectionStatus.decode(data);
streamProtectionStatus = StreamProtectionStatus.decode(data);
break;
default:
break;
@@ -181,46 +185,52 @@ export class ServerAbrStream extends EventEmitterLike {
});
return {
initialized_formats: this.initialized_formats,
stream_protection_status,
sabr_error
initializedFormats: this.initializedFormats,
streamProtectionStatus,
sabrRedirect,
sabrError
};
}
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);
const mediaHeader = MediaHeader.decode(data);
const targetFormat = this.initializedFormats.find((fmt) => fmt.formatId.itag === mediaHeader.itag);
if (!target_format) return;
if (!targetFormat) 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;
if (mediaHeader.isInitSeg) {
if (!targetFormat.initSegment) {
targetFormat._initSegmentMediaId = mediaHeader.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);
}
// 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 && this.prevSeqs.get(`${targetFormat.formatId.itag};${targetFormat.formatId.lastModified};`)?.includes(mediaHeader.sequenceNumber))
return;
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
// Save the header's ID so we can identify its media data later.
if (!targetFormat._headerIds.has(mediaHeader.headerId || 0))
targetFormat._headerIds.add(mediaHeader.headerId || 0);
if (
mediaHeader.sequenceNumber &&
!targetFormat.sequenceList.some((seq) => seq.sequenceNumber === mediaHeader.sequenceNumber)
) {
targetFormat.sequenceList.push({
itag: mediaHeader.itag,
formatId: mediaHeader.formatId,
durationMs: mediaHeader.durationMs,
startMs: mediaHeader.startMs,
startDataRange: mediaHeader.startDataRange,
sequenceNumber: mediaHeader.sequenceNumber,
contentLength: mediaHeader.contentLength,
timeRange: mediaHeader.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;
this.initializedFormats.forEach((item) => {
if (item._state && item.formatId.itag === mediaHeader.itag) {
item._state.durationMs += mediaHeader.durationMs || 0;
item._state.field5 += 1;
}
});
@@ -228,58 +238,60 @@ export class ServerAbrStream extends EventEmitterLike {
}
private processMediaData(data: ChunkedDataBuffer) {
const media_data_id = data.getUint8(0);
const new_data = data.split(1).remainingBuffer.chunks[0];
const headerId = data.getUint8(0);
const streamData = data.split(1).remainingBuffer;
const target_format = this.initialized_formats.find((fmt) => fmt._media_data_ids.includes(media_data_id));
const targetFormat = this.initializedFormats.find((fmt) => fmt._headerIds.has(headerId));
if (!targetFormat)
return;
if (!target_format) return;
const isInitSegData = target_format._init_segment_media_id === media_data_id;
if (target_format.init_segment && isInitSegData)
const isInitSegData = targetFormat._initSegmentMediaId === headerId;
if (targetFormat.initSegment && isInitSegData)
return;
if (isInitSegData) {
target_format.init_segment = new_data;
delete target_format._init_segment_media_id;
targetFormat.initSegment = streamData.chunks[0];
delete targetFormat._initSegmentMediaId;
return;
}
const combined_length = target_format.media_data.length + new_data.length;
const temp_media_data = new Uint8Array(combined_length);
const combinedLength = targetFormat.mediaData.length + streamData.chunks[0].length;
const tempMediaData = new Uint8Array(combinedLength);
temp_media_data.set(target_format.media_data);
temp_media_data.set(new_data, target_format.media_data.length);
tempMediaData.set(targetFormat.mediaData);
tempMediaData.set(streamData.chunks[0], targetFormat.mediaData.length);
target_format.media_data = temp_media_data;
targetFormat.mediaData = tempMediaData;
}
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);
const headerId = data.getUint8(0);
const targetFormat = this.initializedFormats.find((fmt) => fmt._headerIds.has(headerId));
if (targetFormat) targetFormat._headerIds.delete(headerId);
}
private processNextRequestPolicy(data: Uint8Array) {
const next_request_policy = NextRequestPolicy.decode(data);
this.playback_cookie = next_request_policy.playbackCookie;
const nextRequestPolicy = NextRequestPolicy.decode(data);
this.playbackCookie = nextRequestPolicy.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(),
const formatInitializationMetadata = FormatInitializationMetadata.decode(data);
if (
formatInitializationMetadata.formatId &&
!this.initializedFormats.some((item) => item.formatId.itag === formatInitializationMetadata.formatId?.itag)
) {
this.initializedFormats.push({
formatId: formatInitializationMetadata.formatId,
durationMs: formatInitializationMetadata.durationMs,
mimeType: formatInitializationMetadata.mimeType,
sequenceCount: formatInitializationMetadata.field4,
sequenceList: [],
mediaData: new Uint8Array(),
// Only meant to be used internally.
_media_data_ids: [],
_headerIds: new Set<number>(),
_state: {
formatId: format_initialization_metadata.formatId,
formatId: formatInitializationMetadata.formatId,
startTimeMs: 0,
durationMs: 0,
field4: 1,
@@ -289,12 +301,10 @@ export class ServerAbrStream extends EventEmitterLike {
}
}
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;
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;
}
}