diff --git a/examples/README.md b/examples/README.md index 615724d..ed75bc8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,4 +22,7 @@ deno run --allow-net --allow-read examples/browser/proxy/deno.ts cd examples/downloader npm install npx tsx main.ts + +## Example with ffmpeg +npx tsx ffmpeg-example.ts ``` \ No newline at end of file diff --git a/examples/downloader/ffmpeg-example.ts b/examples/downloader/ffmpeg-example.ts new file mode 100644 index 0000000..f331cdb --- /dev/null +++ b/examples/downloader/ffmpeg-example.ts @@ -0,0 +1,147 @@ +import type { WriteStream } from 'node:fs'; +import { createWriteStream, unlink } from 'node:fs'; +import { Innertube, UniversalCache } from 'youtubei.js'; +import GoogleVideo, { concatenateChunks, type Format, MediaType } from '../../dist/src/index.js'; +import ffmpeg from 'fluent-ffmpeg'; + +const innertube = await Innertube.create({ cache: new UniversalCache(true) }); +const info = await innertube.getBasicInfo('wRNnMQEKo7o'); + +console.info(` + Title: ${info.basic_info.title} + Duration: ${info.basic_info.duration} + Views: ${info.basic_info.view_count} + Author: ${info.basic_info.author} + Video ID: ${info.basic_info.id} +`); + +const durationMs = info.basic_info?.duration ? info.basic_info.duration * 1000 : 0; +const sanitizedTitle = info.basic_info.title?.replace(/[^a-z0-9]/gi, '_'); + +let audioOutput: WriteStream | undefined; +let videoOutput: WriteStream | undefined; +let audioOutputFilename: string | undefined; +let videoOutputFilename: string | undefined; + +const audioFormat = info.chooseFormat({ quality: 'best', format: 'webm', type: 'audio' }); +const videoFormat = info.chooseFormat({ quality: '1080p', format: 'webm', 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 +}; + +const serverAbrStreamingUrl = innertube.session.player?.decipher(info.page[0].streaming_data?.server_abr_streaming_url); +const videoPlaybackUstreamerConfig = info.page[0].player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config; + +if (!videoPlaybackUstreamerConfig) + throw new Error('ustreamerConfig not found'); + +if (!serverAbrStreamingUrl) + throw new Error('serverAbrStreamingUrl not found'); + +const serverAbrStream = new GoogleVideo.ServerAbrStream({ + fetch: innertube.session.http.fetch_function, + serverAbrStreamingUrl, + videoPlaybackUstreamerConfig: videoPlaybackUstreamerConfig, + durationMs +}); + +serverAbrStream.on('data', (data) => { + let progressText = ''; + + 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); + + const data = concatenateChunks(initializedFormat.mediaChunks); + + if (isVideo && data.length) { + if (!videoOutput) { + videoOutputFilename = `${sanitizedTitle}.${initializedFormat.formatId.itag}.webm`; + videoOutput = createWriteStream(videoOutputFilename); + } + videoOutput.write(data); + } else if (data.length) { + if (!audioOutput) { + audioOutputFilename = `${sanitizedTitle}.${initializedFormat.formatId.itag}.webm`; + audioOutput = createWriteStream(audioOutputFilename); + } + audioOutput.write(data); + } + + const fmtIdentifier = `${initializedFormat.formatId.itag}_${initializedFormat.mimeType?.split(';')[0]}`; + const percentage = Math.round((initializedFormat.sequenceList.at(-1)?.startDataRange ?? 0) / (mediaFormat?.content_length ?? 0) * 100); + + if (percentage) + progressText += `${fmtIdentifier}: ${percentage}% | `; + } + + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write(progressText); +}); + +serverAbrStream.on('error', (error) => { + console.error(error); +}); + +await serverAbrStream.init({ + audioFormats: [ selectedAudioFormat ], + videoFormats: [ selectedVideoFormat ], + mediaInfo: { + mediaType: MediaType.MEDIA_TYPE_DEFAULT, + startTimeMs: 0 + } +}); + +if (audioOutput) + audioOutput.end(); + +if (videoOutput) + videoOutput.end(); + +const outputFilename = `${sanitizedTitle}_final.webm`; + +await new Promise((resolve, reject) => { + if (!videoOutputFilename || !audioOutputFilename) + return reject(new Error('No video or audio output filename')); + + ffmpeg() + .input(videoOutputFilename) + .input(audioOutputFilename) + .videoCodec('copy') + .audioCodec('copy') + .on('progress', (progress) => { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write(`Processing: ${progress.timemark} (${progress.percent?.toFixed(2)}%)`); + }) + .on('end', () => { + if (videoOutputFilename) { + unlink(videoOutputFilename, (err) => { + if (err) console.error(`Error deleting video temp file: ${err}`); + }); + } + if (audioOutputFilename) { + unlink(audioOutputFilename, (err) => { + if (err) console.error(`Error deleting audio temp file: ${err}`); + }); + } + resolve(); + }) + .on('error', (err: Error) => { + console.error('Error processing video:', err); + reject(err); + }) + .save(outputFilename); +}); \ No newline at end of file diff --git a/examples/downloader/main.ts b/examples/downloader/main.ts index 3c888a9..9afade3 100644 --- a/examples/downloader/main.ts +++ b/examples/downloader/main.ts @@ -1,38 +1,27 @@ import type { WriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs'; import { Innertube, UniversalCache } from 'youtubei.js'; -import GoogleVideo, { type Format, MediaType } from '../../dist/src/index.js'; +import GoogleVideo, { concatenateChunks, type Format, MediaType } from '../../dist/src/index.js'; const innertube = await Innertube.create({ cache: new UniversalCache(true) }); +const info = await innertube.getBasicInfo('el68RBQBlCs'); -const determineFileExtension = (mimeType: string) => { - if (mimeType.includes('video')) - return mimeType.includes('webm') ? 'webm' : 'mp4'; - else if (mimeType.includes('audio')) - return mimeType.includes('webm') ? 'webm' : 'm4a'; -}; - -const info = await innertube.getBasicInfo('qQ_-toSu29Q'); - -console.log('\n'); -console.info(`Title: ${info.basic_info.title}`); -console.info(`Duration: ${info.basic_info.duration}`); -console.info(`Views: ${info.basic_info.view_count}`); -console.info(`Author: ${info.basic_info.author}`); -console.info(`Video ID: ${info.basic_info.id}`); +console.info(` + Title: ${info.basic_info.title} + Duration: ${info.basic_info.duration} + Views: ${info.basic_info.view_count} + Author: ${info.basic_info.author} + Video ID: ${info.basic_info.id} +`); +const durationMs = info.basic_info?.duration ? info.basic_info.duration * 1000 : 0; const sanitizedTitle = info.basic_info.title?.replace(/[^a-z0-9]/gi, '_'); -let wroteAudioInitSegment = false; -let wroteVideoInitSegment = false; - let audioOutput: WriteStream | undefined; 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 videoFormat = info.chooseFormat({ quality: '720p', format: 'webm', type: 'video' }); +const videoFormat = info.chooseFormat({ quality: '1080p', format: 'webm', type: 'video' }); const selectedAudioFormat: Format = { itag: audioFormat.itag, @@ -48,20 +37,22 @@ const selectedVideoFormat: Format = { xtags: videoFormat.xtags }; -console.info(`Selected audio format: ${audioFormat.itag} (${audioFormat.audio_quality})`); -console.info(`Selected video format: ${videoFormat.itag} (${videoFormat.quality_label})`); -console.log('\n'); - +const serverAbrStreamingUrl = innertube.session.player?.decipher(info.page[0].streaming_data?.server_abr_streaming_url); const videoPlaybackUstreamerConfig = info.page[0].player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config; if (!videoPlaybackUstreamerConfig) throw new Error('ustreamerConfig not found'); -const serverAbrStreamingUrl = innertube.session.player?.decipher(info.page[0].streaming_data?.server_abr_streaming_url); - if (!serverAbrStreamingUrl) throw new Error('serverAbrStreamingUrl not found'); +const determineFileExtension = (mimeType: string) => { + if (mimeType.includes('video')) + return mimeType.includes('webm') ? 'webm' : 'mp4'; + else if (mimeType.includes('audio')) + return mimeType.includes('webm') ? 'webm' : 'm4a'; +}; + const serverAbrStream = new GoogleVideo.ServerAbrStream({ fetch: innertube.session.http.fetch_function, serverAbrStreamingUrl, @@ -76,26 +67,16 @@ serverAbrStream.on('data', (data) => { const isVideo = initializedFormat.mimeType?.includes('video'); const mediaFormat = info.streaming_data?.adaptive_formats.find((f) => f.itag === initializedFormat.formatId.itag); - if (isVideo && initializedFormat.mediaData) { + const data = concatenateChunks(initializedFormat.mediaChunks); + + if (isVideo && data.length) { if (!videoOutput) videoOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`); - - if (initializedFormat.initSegment && !wroteVideoInitSegment) { - videoOutput.write(initializedFormat.initSegment); - wroteVideoInitSegment = true; - } - - videoOutput.write(initializedFormat.mediaData); - } else if (initializedFormat.mediaData) { + videoOutput.write(data); + } else if (data.length) { if (!audioOutput) audioOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`); - - if (initializedFormat.initSegment && !wroteAudioInitSegment) { - audioOutput.write(initializedFormat.initSegment); - wroteAudioInitSegment = true; - } - - audioOutput.write(initializedFormat.mediaData); + audioOutput.write(data); } const fmtIdentifier = `${initializedFormat.formatId.itag}_${initializedFormat.mimeType?.split(';')[0]}`; @@ -115,6 +96,18 @@ serverAbrStream.on('error', (error) => { console.error(error); }); +serverAbrStream.on('end', () => { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write('Done!'); + + if (audioOutput) + audioOutput.end(); + + if (videoOutput) + videoOutput.end(); +}); + await serverAbrStream.init({ audioFormats: [ selectedAudioFormat ], videoFormats: [ selectedVideoFormat ], @@ -127,12 +120,4 @@ await serverAbrStream.init({ mediaType: MediaType.MEDIA_TYPE_DEFAULT, startTimeMs: 0 } -}); - -process.stdout.write('Done!'); - -if (audioOutput) - audioOutput.end(); - -if (videoOutput) - videoOutput.end(); \ No newline at end of file +}); \ No newline at end of file diff --git a/examples/downloader/package-lock.json b/examples/downloader/package-lock.json index b6e8ee2..90d5edf 100644 --- a/examples/downloader/package-lock.json +++ b/examples/downloader/package-lock.json @@ -9,10 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "fluent-ffmpeg": "^2.1.3", "shaka-player": "^4.11.2", "youtubei.js": "github:LuanRT/YouTube.js#refactor/update-protos" }, "devDependencies": { + "@types/fluent-ffmpeg": "^2.1.26", "typescript": "^5.6.2" } }, @@ -29,6 +31,24 @@ "node": ">=14" } }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.26.tgz", + "integrity": "sha512-0JVF3wdQG+pN0ImwWD0bNgJiKF2OHg/7CDBHw5UIbRTvlnkgGHK6V5doE54ltvhud4o31/dEiHm23CAlxFiUQg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.5.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", + "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -40,11 +60,33 @@ "node": ">=0.4.0" } }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/eme-encryption-scheme-polyfill": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/eme-encryption-scheme-polyfill/-/eme-encryption-scheme-polyfill-2.1.5.tgz", "integrity": "sha512-z9BKXV4TCYjmar0wiZLObZ0J8HE13VIg7Zq/iyPWdbEfROtxVXEJalknWKtBR5XNezzy15/zWS964TGbcAWlPg==" }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, "node_modules/jintr": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/jintr/-/jintr-2.1.1.tgz", @@ -96,6 +138,23 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/youtubei.js": { "version": "10.4.0", "resolved": "git+ssh://git@github.com/LuanRT/YouTube.js.git#9bcbdb06b834067886689d25a82f890a96fcf0f7", diff --git a/examples/downloader/package.json b/examples/downloader/package.json index 22dc89b..3f740e7 100644 --- a/examples/downloader/package.json +++ b/examples/downloader/package.json @@ -12,10 +12,12 @@ "author": "", "license": "ISC", "dependencies": { + "fluent-ffmpeg": "^2.1.3", "shaka-player": "^4.11.2", "youtubei.js": "github:LuanRT/YouTube.js#refactor/update-protos" }, "devDependencies": { + "@types/fluent-ffmpeg": "^2.1.26", "typescript": "^5.6.2" } } diff --git a/src/core/ServerAbrStream.ts b/src/core/ServerAbrStream.ts index cabcc3f..6e5248b 100644 --- a/src/core/ServerAbrStream.ts +++ b/src/core/ServerAbrStream.ts @@ -1,5 +1,5 @@ import { UMP } from './UMP.js'; -import { EventEmitterLike, PART, base64ToU8 } from '../utils/index.js'; +import { EventEmitterLike, PART, base64ToU8, getFormatKey } 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'; @@ -16,37 +16,47 @@ import type { MediaInfo } from '../../protos/generated/video_streaming/video_pla import type { FetchFunction, InitializedFormat, InitOptions, MediaArgs, ServerAbrResponse, ServerAbrStreamOptions } from '../utils/types.js'; import { ChunkedDataBuffer } from './ChunkedDataBuffer.js'; +const DEFAULT_VIDEO_WIDTH = 720; + export class ServerAbrStream extends EventEmitterLike { - private fetchFn: FetchFunction; + private fetchFunction: FetchFunction; private serverAbrStreamingUrl: string; private videoPlaybackUstreamerConfig: string; private poToken?: string; private playbackCookie?: PlaybackCookie; - private initializedFormats: InitializedFormat[] = []; private totalDurationMs: number; - private prevSeqs: Map = new Map(); + private initializedFormats: InitializedFormat[] = []; + private formatsByKey: Map = new Map(); + private headerIdToFormatKeyMap: Map = new Map(); + private previousSequences: Map = new Map(); constructor(args: ServerAbrStreamOptions) { super(); - this.fetchFn = args.fetch || fetch; + 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: (data: ServerAbrResponse) => void): void; 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 { + public on(event: string, listener: (...data: any[]) => void): void { super.on(event, listener); } + public once(event: 'end', listener: (data: ServerAbrResponse) => void): void; 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); } + /** + * 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; @@ -55,8 +65,8 @@ export class ServerAbrStream extends EventEmitterLike { const mediaInfo: MediaInfo = { lastManualDirection: 0, timeSinceLastManualFormatSelectionMs: 0, - videoWidth: videoFormats.length === 1 ? firstVideoFormat?.width : 720, - iea: videoFormats.length === 1 ? firstVideoFormat?.width : 720, + videoWidth: videoFormats.length === 1 ? firstVideoFormat?.width : DEFAULT_VIDEO_WIDTH, + iea: videoFormats.length === 1 ? firstVideoFormat?.width : DEFAULT_VIDEO_WIDTH, startTimeMs: 0, visibility: 0, mediaType: MediaInfo_MediaType.MEDIA_TYPE_DEFAULT, @@ -78,8 +88,8 @@ export class ServerAbrStream extends EventEmitterLike { if (typeof mediaInfo.startTimeMs !== 'number') throw new Error('Invalid media start time'); - try { - while (mediaInfo.startTimeMs < this.totalDurationMs) { + while (mediaInfo.startTimeMs < this.totalDurationMs) { + try { const data = await this.fetchMedia({ mediaInfo, audioFormatIds, videoFormatIds }); this.emit('data', data); @@ -92,31 +102,29 @@ export class ServerAbrStream extends EventEmitterLike { : data.initializedFormats[0]; for (const fmt of data.initializedFormats) { - this.prevSeqs.set(`${fmt.formatId.itag};${fmt.formatId.lastModified};`, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0)); + this.previousSequences.set(`${fmt.formatId.itag};${fmt.formatId.lastModified};`, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0)); } - if (!mainFormat) break; if ( - mainFormat?.sequenceCount === + !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); + break; } - } catch (error) { - this.emit('error', error); } } private async fetchMedia(args: MediaArgs): Promise { const { mediaInfo, audioFormatIds, videoFormatIds } = args; - this.initializedFormats.forEach((format) => { - format.sequenceList = []; - format.mediaData = new Uint8Array(0); - }); - const body = VideoPlaybackAbrRequest.encode({ mediaInfo: mediaInfo, formatIds: this.initializedFormats.map((fmt) => fmt.formatId), @@ -139,13 +147,24 @@ export class ServerAbrStream extends EventEmitterLike { field1000: [] }).finish(); - const response = await this.fetchFn(this.serverAbrStreamingUrl, { method: 'POST', body }); + const response = await this.fetchFunction(this.serverAbrStreamingUrl, { method: 'POST', body }); const data = await response.arrayBuffer(); - return this.processUMPResponse(new Uint8Array(data)); + return this.parseUMPResponse(new Uint8Array(data)); } - public async processUMPResponse(data: Uint8Array): Promise { + /** + * 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 { + this.headerIdToFormatKeyMap.clear(); + + this.initializedFormats.forEach((format) => { + format.sequenceList = []; + format.mediaChunks = []; + }); + let sabrError: SabrError | undefined; let sabrRedirect: SabrRedirect | undefined; let streamProtectionStatus: StreamProtectionStatus | undefined; @@ -194,32 +213,29 @@ export class ServerAbrStream extends EventEmitterLike { private processMediaHeader(data: Uint8Array) { const mediaHeader = MediaHeader.decode(data); - const targetFormat = this.initializedFormats.find((fmt) => fmt.formatId.itag === mediaHeader.itag); + if (!mediaHeader.formatId) return; - if (!targetFormat) return; + const formatKey = getFormatKey(mediaHeader.formatId); - // Skip processing if this is an init segment and we've already received it. - if (mediaHeader.isInitSeg) { - if (!targetFormat.initSegment) { - targetFormat._initSegmentMediaId = mediaHeader.headerId; - } else return; - } + const currentFormat = this.formatsByKey.get(formatKey); + 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 && this.prevSeqs.get(`${targetFormat.formatId.itag};${targetFormat.formatId.lastModified};`)?.includes(mediaHeader.sequenceNumber)) + if (mediaHeader.sequenceNumber !== undefined && this.previousSequences.get(formatKey)?.includes(mediaHeader.sequenceNumber)) return; - // 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); + // 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 ( - mediaHeader.sequenceNumber && - !targetFormat.sequenceList.some((seq) => seq.sequenceNumber === mediaHeader.sequenceNumber) - ) { - targetFormat.sequenceList.push({ + 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, @@ -228,12 +244,10 @@ export class ServerAbrStream extends EventEmitterLike { timeRange: mediaHeader.timeRange }); - this.initializedFormats.forEach((item) => { - if (item._state && item.formatId.itag === mediaHeader.itag) { - item._state.durationMs += mediaHeader.durationMs || 0; - item._state.field5 += 1; - } - }); + if (typeof mediaHeader.sequenceNumber === 'number') { + currentFormat._state.durationMs += mediaHeader.durationMs || 0; + currentFormat._state.sequenceNumber += 1; + } } } @@ -241,33 +255,18 @@ export class ServerAbrStream extends EventEmitterLike { const headerId = data.getUint8(0); const streamData = data.split(1).remainingBuffer; - const targetFormat = this.initializedFormats.find((fmt) => fmt._headerIds.has(headerId)); - if (!targetFormat) - return; + const formatKey = this.headerIdToFormatKeyMap.get(headerId); + if (!formatKey) return; - const isInitSegData = targetFormat._initSegmentMediaId === headerId; - if (targetFormat.initSegment && isInitSegData) - return; + const currentFormat = this.formatsByKey.get(formatKey); + if (!currentFormat) return; - if (isInitSegData) { - targetFormat.initSegment = streamData.chunks[0]; - delete targetFormat._initSegmentMediaId; - return; - } - - const combinedLength = targetFormat.mediaData.length + streamData.chunks[0].length; - const tempMediaData = new Uint8Array(combinedLength); - - tempMediaData.set(targetFormat.mediaData); - tempMediaData.set(streamData.chunks[0], targetFormat.mediaData.length); - - targetFormat.mediaData = tempMediaData; + currentFormat.mediaChunks.push(streamData.chunks[0]); } private processEndOfMedia(data: ChunkedDataBuffer) { const headerId = data.getUint8(0); - const targetFormat = this.initializedFormats.find((fmt) => fmt._headerIds.has(headerId)); - if (targetFormat) targetFormat._headerIds.delete(headerId); + this.headerIdToFormatKeyMap.delete(headerId); } private processNextRequestPolicy(data: Uint8Array) { @@ -277,27 +276,28 @@ export class ServerAbrStream extends EventEmitterLike { private processFormatInitialization(data: Uint8Array) { const formatInitializationMetadata = FormatInitializationMetadata.decode(data); - if ( - formatInitializationMetadata.formatId && - !this.initializedFormats.some((item) => item.formatId.itag === formatInitializationMetadata.formatId?.itag) - ) { + if (!formatInitializationMetadata.formatId) return; + + const formatKey = getFormatKey(formatInitializationMetadata.formatId); + + if (!this.formatsByKey.has(formatKey)) { 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. - _headerIds: new Set(), + mediaChunks: [], _state: { formatId: formatInitializationMetadata.formatId, startTimeMs: 0, durationMs: 0, field4: 1, - field5: 0 + sequenceNumber: 0 } }); + + this.formatsByKey.set(formatKey, this.initializedFormats[this.initializedFormats.length - 1]); } } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 93400b9..b0aae89 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,3 +1,5 @@ +import type { FormatId } from '../../protos/generated/misc/common.js'; + export enum PART { ONESIE_HEADER = 10, ONESIE_DATA = 11, @@ -50,6 +52,21 @@ export function base64ToU8(base64: string): Uint8Array { return new Uint8Array(atob(padded_base64).split('').map((char) => char.charCodeAt(0))); } +export function getFormatKey(formatId: FormatId): string { + return `${formatId.itag};${formatId.lastModified};`; +} + +export function concatenateChunks(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + // See https://github.com/nodejs/node/issues/40678#issuecomment-1126944677 export class CustomEvent extends Event { #detail; diff --git a/src/utils/types.ts b/src/utils/types.ts index 2dddbbd..8fcb077 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -30,6 +30,7 @@ export type ServerAbrResponse = { export type Sequence = { itag?: number; formatId?: FormatId; + isInitSegment?: boolean; durationMs?: number; startMs?: number; startDataRange?: number; @@ -43,11 +44,8 @@ export type InitializedFormat = { durationMs?: number; mimeType?: string; sequenceCount?: number; - initSegment?: Uint8Array; sequenceList: Sequence[]; - mediaData: Uint8Array; - _initSegmentMediaId?: number; - _headerIds: Set; + mediaChunks: Uint8Array[]; _state: Zpa; }