diff --git a/examples/downloader/ffmpeg-example.ts b/examples/downloader/ffmpeg-example.ts index f331cdb..3ee1210 100644 --- a/examples/downloader/ffmpeg-example.ts +++ b/examples/downloader/ffmpeg-example.ts @@ -1,8 +1,16 @@ +import ffmpeg from 'fluent-ffmpeg'; +import cliProgress from 'cli-progress'; 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'; +import GoogleVideo, { type Format, MediaType } from '../../dist/src/index.js'; + +const progressBars = new cliProgress.MultiBar({ + clearOnComplete: false, + hideCursor: true +}, cliProgress.Presets.rect); + +const formatProgressBars = new Map(); const innertube = await Innertube.create({ cache: new UniversalCache(true) }); const info = await innertube.getBasicInfo('wRNnMQEKo7o'); @@ -15,7 +23,7 @@ console.info(` Video ID: ${info.basic_info.id} `); -const durationMs = info.basic_info?.duration ? info.basic_info.duration * 1000 : 0; +const durationMs = (info.basic_info?.duration ?? 0) * 1000; const sanitizedTitle = info.basic_info.title?.replace(/[^a-z0-9]/gi, '_'); let audioOutput: WriteStream | undefined; @@ -56,39 +64,53 @@ const serverAbrStream = new GoogleVideo.ServerAbrStream({ durationMs }); -serverAbrStream.on('data', (data) => { - let progressText = ''; +let downloadedBytesAudio = 0; +let downloadedBytesVideo = 0; - 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); +serverAbrStream.on('data', (streamData) => { + for (const formatData of streamData.initializedFormats) { + const isVideo = formatData.mimeType?.includes('video'); + const mediaFormat = info.streaming_data?.adaptive_formats.find((f) => f.itag === formatData.formatId.itag); + const formatKey = formatData.formatKey; - const data = concatenateChunks(initializedFormat.mediaChunks); + let bar = formatProgressBars.get(formatKey); - 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); + if (!bar) { + bar = progressBars.create(100, 0, undefined, { format: `${isVideo ? 'video' : 'audio'} (${formatData.formatId.itag}) [{bar}] {percentage}% | ETA: {eta}s` }); + formatProgressBars.set(formatKey, bar); } - const fmtIdentifier = `${initializedFormat.formatId.itag}_${initializedFormat.mimeType?.split(';')[0]}`; - const percentage = Math.round((initializedFormat.sequenceList.at(-1)?.startDataRange ?? 0) / (mediaFormat?.content_length ?? 0) * 100); + const mediaChunks = formatData.mediaChunks; - if (percentage) - progressText += `${fmtIdentifier}: ${percentage}% | `; + if (isVideo && mediaChunks.length) { + if (!videoOutput) { + videoOutputFilename = `${sanitizedTitle}.${formatData.formatId.itag}.webm`; + videoOutput = createWriteStream(videoOutputFilename); + } + + for (const chunk of mediaChunks) { + downloadedBytesVideo += chunk.length; + videoOutput.write(chunk); + } + } else if (mediaChunks.length) { + if (!audioOutput) { + audioOutputFilename = `${sanitizedTitle}.${formatData.formatId.itag}.webm`; + audioOutput = createWriteStream(audioOutputFilename); + } + for (const chunk of mediaChunks) { + downloadedBytesAudio += chunk.length; + audioOutput.write(chunk); + } + } + + const contentLength = mediaFormat?.content_length ?? 0; + const downloadedBytes = isVideo ? downloadedBytesVideo : downloadedBytesAudio; + + if (contentLength > 0) { + const percentage = (downloadedBytes / contentLength) * 100; + bar.update(percentage); + } } - - process.stdout.clearLine(0); - process.stdout.cursorTo(0); - process.stdout.write(progressText); }); serverAbrStream.on('error', (error) => { @@ -121,11 +143,6 @@ await new Promise((resolve, reject) => { .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) => { diff --git a/examples/downloader/main.ts b/examples/downloader/main.ts index 9afade3..27f0aef 100644 --- a/examples/downloader/main.ts +++ b/examples/downloader/main.ts @@ -1,10 +1,18 @@ +import cliProgress from 'cli-progress'; import type { WriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs'; import { Innertube, UniversalCache } from 'youtubei.js'; -import GoogleVideo, { concatenateChunks, type Format, MediaType } from '../../dist/src/index.js'; +import GoogleVideo, { type Format, MediaType } from '../../dist/src/index.js'; + +const progressBars = new cliProgress.MultiBar({ + clearOnComplete: false, + hideCursor: true +}, cliProgress.Presets.rect); + +const formatProgressBars = new Map(); const innertube = await Innertube.create({ cache: new UniversalCache(true) }); -const info = await innertube.getBasicInfo('el68RBQBlCs'); +const info = await innertube.getBasicInfo('mzqO7oKTJKI'); console.info(` Title: ${info.basic_info.title} @@ -14,14 +22,14 @@ console.info(` Video ID: ${info.basic_info.id} `); -const durationMs = info.basic_info?.duration ? info.basic_info.duration * 1000 : 0; +const durationMs = (info.basic_info?.duration ?? 0) * 1000; const sanitizedTitle = info.basic_info.title?.replace(/[^a-z0-9]/gi, '_'); let audioOutput: WriteStream | undefined; let videoOutput: WriteStream | undefined; const audioFormat = info.chooseFormat({ quality: 'best', format: 'webm', type: 'audio' }); -const videoFormat = info.chooseFormat({ quality: '1080p', format: 'webm', type: 'video' }); +const videoFormat = info.chooseFormat({ quality: '720p', format: 'webm', type: 'video' }); const selectedAudioFormat: Format = { itag: audioFormat.itag, @@ -47,10 +55,19 @@ if (!serverAbrStreamingUrl) throw new Error('serverAbrStreamingUrl not found'); const determineFileExtension = (mimeType: string) => { - if (mimeType.includes('video')) + if (mimeType.includes('video')) { return mimeType.includes('webm') ? 'webm' : 'mp4'; - else if (mimeType.includes('audio')) + } else if (mimeType.includes('audio')) { return mimeType.includes('webm') ? 'webm' : 'm4a'; + } + return 'bin'; +}; + +const getOutputStream = (isVideo: boolean, mimeType: string, formatId?: number) => { + const type = isVideo ? 'video' : 'audio'; + const extension = determineFileExtension(mimeType); + const stream = createWriteStream(`${sanitizedTitle}.${formatId}.${type}.${extension}`); + return stream; }; const serverAbrStream = new GoogleVideo.ServerAbrStream({ @@ -60,46 +77,57 @@ const serverAbrStream = new GoogleVideo.ServerAbrStream({ durationMs }); -serverAbrStream.on('data', (data) => { - let progressText = ''; +let downloadedBytesAudio = 0; +let downloadedBytesVideo = 0; - 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); +serverAbrStream.on('data', (streamData) => { + for (const formatData of streamData.initializedFormats) { + const isVideo = formatData.mimeType?.includes('video'); + const mediaFormat = info.streaming_data?.adaptive_formats.find((f) => f.itag === formatData.formatId.itag); + const formatKey = formatData.formatKey; - const data = concatenateChunks(initializedFormat.mediaChunks); + let bar = formatProgressBars.get(formatKey); - if (isVideo && data.length) { - if (!videoOutput) - videoOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`); - videoOutput.write(data); - } else if (data.length) { - if (!audioOutput) - audioOutput = createWriteStream(`${sanitizedTitle}.${initializedFormat.formatId.itag}.${determineFileExtension(initializedFormat.mimeType || '')}`); - audioOutput.write(data); + if (!bar) { + bar = progressBars.create(100, 0, undefined, { format: `${isVideo ? 'video' : 'audio'} (${formatData.formatId.itag}) [{bar}] {percentage}% | ETA: {eta}s` }); + formatProgressBars.set(formatKey, bar); } - const fmtIdentifier = `${initializedFormat.formatId.itag}_${initializedFormat.mimeType?.split(';')[0]}`; + const mediaChunks = formatData.mediaChunks; - const percentage = Math.round((initializedFormat.sequenceList.at(-1)?.startDataRange ?? 0) / (mediaFormat?.content_length ?? 0) * 100); + if (isVideo && mediaChunks.length) { + if (!videoOutput) + videoOutput = getOutputStream(true, formatData.mimeType || '', formatData.formatId?.itag); + for (const chunk of mediaChunks) { + downloadedBytesVideo += chunk.length; + videoOutput.write(chunk); + } + } else if (mediaChunks.length) { + if (!audioOutput) + audioOutput = getOutputStream(false, formatData.mimeType || '', formatData.formatId?.itag); + for (const chunk of mediaChunks) { + downloadedBytesAudio += chunk.length; + audioOutput.write(chunk); + } + } - if (percentage) - progressText += `${fmtIdentifier}: ${percentage}% | `; + const contentLength = mediaFormat?.content_length ?? 0; + const downloadedBytes = isVideo ? downloadedBytesVideo : downloadedBytesAudio; + + if (contentLength > 0) { + const percentage = (downloadedBytes / contentLength) * 100; + bar.update(percentage); + } } - - process.stdout.clearLine(0); - process.stdout.cursorTo(0); - process.stdout.write(progressText); }); serverAbrStream.on('error', (error) => { + progressBars.stop(); console.error(error); }); serverAbrStream.on('end', () => { - process.stdout.clearLine(0); - process.stdout.cursorTo(0); - process.stdout.write('Done!'); + progressBars.stop(); if (audioOutput) audioOutput.end(); diff --git a/examples/downloader/package-lock.json b/examples/downloader/package-lock.json index 90d5edf..a8c58eb 100644 --- a/examples/downloader/package-lock.json +++ b/examples/downloader/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "cli-progress": "^3.12.0", "fluent-ffmpeg": "^2.1.3", "shaka-player": "^4.11.2", "youtubei.js": "github:LuanRT/YouTube.js#refactor/update-protos" }, "devDependencies": { + "@types/cli-progress": "^3.11.6", "@types/fluent-ffmpeg": "^2.1.26", "typescript": "^5.6.2" } @@ -31,6 +33,15 @@ "node": ">=14" } }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/fluent-ffmpeg": { "version": "2.1.26", "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.26.tgz", @@ -60,16 +71,40 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "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/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", @@ -82,6 +117,14 @@ "node": ">=18" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -109,6 +152,30 @@ "node": ">=14" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", diff --git a/examples/downloader/package.json b/examples/downloader/package.json index 3f740e7..06b47e2 100644 --- a/examples/downloader/package.json +++ b/examples/downloader/package.json @@ -12,11 +12,13 @@ "author": "", "license": "ISC", "dependencies": { + "cli-progress": "^3.12.0", "fluent-ffmpeg": "^2.1.3", "shaka-player": "^4.11.2", "youtubei.js": "github:LuanRT/YouTube.js#refactor/update-protos" }, "devDependencies": { + "@types/cli-progress": "^3.11.6", "@types/fluent-ffmpeg": "^2.1.26", "typescript": "^5.6.2" } diff --git a/src/core/ServerAbrStream.ts b/src/core/ServerAbrStream.ts index 6e5248b..b73380b 100644 --- a/src/core/ServerAbrStream.ts +++ b/src/core/ServerAbrStream.ts @@ -39,15 +39,15 @@ export class ServerAbrStream extends EventEmitterLike { 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: 'end', listener: (streamData: ServerAbrResponse) => void): void; + public on(event: 'data', listener: (streamData: ServerAbrResponse) => void): void; public on(event: 'error', listener: (error: Error) => 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: 'end', listener: (streamData: ServerAbrResponse) => void): void; + public once(event: 'data', listener: (streamData: 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); @@ -88,8 +88,8 @@ export class ServerAbrStream extends EventEmitterLike { if (typeof mediaInfo.startTimeMs !== 'number') throw new Error('Invalid media start time'); - while (mediaInfo.startTimeMs < this.totalDurationMs) { - try { + try { + while (mediaInfo.startTimeMs < this.totalDurationMs) { const data = await this.fetchMedia({ mediaInfo, audioFormatIds, videoFormatIds }); this.emit('data', data); @@ -115,10 +115,9 @@ export class ServerAbrStream extends EventEmitterLike { } 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); } } @@ -283,6 +282,7 @@ export class ServerAbrStream extends EventEmitterLike { if (!this.formatsByKey.has(formatKey)) { this.initializedFormats.push({ formatId: formatInitializationMetadata.formatId, + formatKey: getFormatKey(formatInitializationMetadata.formatId), durationMs: formatInitializationMetadata.durationMs, mimeType: formatInitializationMetadata.mimeType, sequenceCount: formatInitializationMetadata.field4, diff --git a/src/utils/types.ts b/src/utils/types.ts index 8fcb077..1e37ebd 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -41,6 +41,7 @@ export type Sequence = { export type InitializedFormat = { formatId: FormatId; + formatKey: string; durationMs?: number; mimeType?: string; sequenceCount?: number;