From d9fb9431ed1b10858b3aef8a23b67e61880a8d99 Mon Sep 17 00:00:00 2001 From: Luan Date: Sat, 14 Sep 2024 12:03:06 -0300 Subject: [PATCH] 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. --- examples/downloader/main.ts | 76 +++++----- src/core/ServerAbrStream.ts | 268 +++++++++++++++++++----------------- 2 files changed, 182 insertions(+), 162 deletions(-) diff --git a/examples/downloader/main.ts b/examples/downloader/main.ts index 8d0d5a5..3c888a9 100644 --- a/examples/downloader/main.ts +++ b/examples/downloader/main.ts @@ -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 } diff --git a/src/core/ServerAbrStream.ts b/src/core/ServerAbrStream.ts index 1c4af52..cabcc3f 100644 --- a/src/core/ServerAbrStream.ts +++ b/src/core/ServerAbrStream.ts @@ -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 = 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 firstVideoFormat = videoFormats ? videoFormats[0] : undefined; - const first_video_format = video_formats ? video_formats[0] : undefined; - - const media_info: MediaInfo = { + 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((fmt) => ({ + const audioFormatIds = audioFormats.map((fmt) => ({ itag: fmt.itag, - lastModified: parseInt(fmt.last_modified_ms), + lastModified: parseInt(fmt.lastModified), xtags: fmt.xtags })); - const video_format_ids = video_formats.map((fmt) => ({ + const videoFormatIds = videoFormats.map((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)); + } + + if (!mainFormat) break; + if ( + mainFormat?.sequenceCount === + mainFormat.sequenceList[mainFormat.sequenceList.length - 1].sequenceNumber + ) + break; - media_info.startTimeMs += main_format.sequence_list.reduce((acc, seq) => acc + (seq.duration_ms || 0), 0); + 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 { - 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 { + 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)); - - if (!target_format) return; - - const isInitSegData = target_format._init_segment_media_id === media_data_id; + const targetFormat = this.initializedFormats.find((fmt) => fmt._headerIds.has(headerId)); + if (!targetFormat) + return; - 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(), _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; } } \ No newline at end of file