refactor(ServerAbrStream): Clean up

+ Add an example with ffmpeg.
This commit is contained in:
Luan
2024-09-15 08:52:54 -03:00
parent b20dd43b50
commit f273a416c7
8 changed files with 344 additions and 133 deletions

View File

@@ -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
```

View File

@@ -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<void>((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);
});

View File

@@ -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();
});

View File

@@ -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",

View File

@@ -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"
}
}