Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 161 additions & 29 deletions examples/download/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,177 @@
import { Innertube, UniversalCache, Utils } from 'youtubei.js';
import { existsSync, mkdirSync, createWriteStream } from 'fs';
import { createWriteStream, type WriteStream } from 'node:fs';

(async () => {
const yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
import { BG, type BgConfig } from 'bgutils-js';
import { JSDOM } from 'jsdom';

const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
import { Constants, Innertube, Platform, UniversalCache, type Types } from 'youtubei.js';

if (!search.results)
throw new Error('Filter "type" must be used');
import { SabrStream, type SabrPlaybackOptions } from 'googlevideo/sabr-stream';
import { buildSabrFormat, EnabledTrackTypes } from 'googlevideo/utils';
import type { SabrFormat } from 'googlevideo/shared-types';
import type { ReloadPlaybackContext } from 'googlevideo/protos';

const album = await yt.music.getAlbum(search.results[0].id as string);
// Modern YouTube streams use the SABR protocol: instead of one downloadable URL per
// format, the server streams media segments over a single endpoint and requires a
// Proof of Origin (po_token). The old `yt.download()` GET flow no longer works for
// these formats, so this example uses the `googlevideo` SABR client together with
// YouTube.js for the player request and signature deciphering.

if (!album.contents)
throw new Error('Album appears to be empty');
// Deciphering the streaming URL requires executing YouTube's obfuscated player code.
// YouTube.js does not ship a JavaScript interpreter, so we provide one here.
// See https://ytjs.dev/guide/getting-started.html#providing-a-custom-javascript-interpreter
Platform.shim.eval = async (data: Types.BuildScriptResult) => new Function(data.output)();

console.info(`Album "${album.header?.title.toString()}"`, '\n');
/**
* Generates a Proof of Origin token bound to the given video id using BotGuard.
* A DOM is required because BotGuard expects a browser-like environment.
*/
async function generatePoToken(contentBinding: string): Promise<string> {
const dom = new JSDOM();
Object.assign(globalThis, { window: dom.window, document: dom.window.document });

for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'mp4', // media container format,
client: 'YTMUSIC'
});
const bgConfig: BgConfig = {
fetch: (input, init) => fetch(input, init),
globalObj: globalThis,
identifier: contentBinding,
requestKey: 'O43z0dpjhgX20SCx4KAo'
};

console.info(`Downloading ${song.title} (${song.id})`);
const challenge = await BG.Challenge.create(bgConfig);
if (!challenge)
throw new Error('Could not get BotGuard challenge');

const dir = `./${album.header?.title.toString()}`;
const interpreterJavascript = challenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (!interpreterJavascript)
throw new Error('Could not load BotGuard VM');

if (!existsSync(dir)) {
mkdirSync(dir);
}
new Function(interpreterJavascript)();

const result = await BG.PoToken.generate({
program: challenge.program,
globalName: challenge.globalName,
bgConfig
});

return result.poToken ?? BG.PoToken.generatePlaceholder(contentBinding);
}

const file = createWriteStream(`${dir}/${song.title?.replace(/\//g, '')}.m4a`);
function fileExtension(mimeType: string): string {
if (mimeType.includes('video'))
return mimeType.includes('webm') ? 'webm' : 'mp4';
return mimeType.includes('webm') ? 'webm' : 'm4a';
}

for await (const chunk of Utils.streamToIterable(stream)) {
file.write(chunk);
/**
* Creates a WritableStream that writes media chunks to disk and logs progress.
*/
function createFileSink(format: SabrFormat, title: string): { sink: WritableStream<Uint8Array>; filePath: string } {
const kind = format.mimeType?.includes('video') ? 'video' : 'audio';
const safeTitle = title.replace(/[^a-z0-9]/gi, '_') || 'video';
const filePath = `${safeTitle}.${kind}.${fileExtension(format.mimeType ?? '')}`;

const output: WriteStream = createWriteStream(filePath, { flags: 'w' });
const totalBytes = Number(format.contentLength ?? 0);
let written = 0;

const sink = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise((resolve, reject) => {
written += chunk.length;
const progress = totalBytes ? ` (${((written / totalBytes) * 100).toFixed(0)}%)` : '';
process.stdout.write(`\r${kind}: ${(written / 1024 / 1024).toFixed(2)} MB${progress} `);
output.write(chunk, (err) => (err ? reject(err) : resolve()));
});
},
close() {
process.stdout.write('\n');
output.end();
}
});

return { sink, filePath };
}

console.info(`${song.id} - Done!`, '\n');
async function main() {
const videoId = process.argv[2] || 'dQw4w9WgXcQ';
const videoQuality = process.argv[3] || '720p';

const innertube = await Innertube.create({ cache: new UniversalCache(true) });

console.info(`Generating Proof of Origin token for ${videoId}...`);
const poToken = await generatePoToken(videoId);

const info = await innertube.getBasicInfo(videoId);

if (info.playability_status?.status !== 'OK') {
throw new Error(
`Video is not playable: ${info.playability_status?.status} ` +
`(${info.playability_status?.reason ?? 'no reason given'})`
);
}

console.info(`Done!`);
})();
const title = info.basic_info.title ?? videoId;
console.info(`\nTitle: ${title}`);

const serverAbrStreamingUrl = await innertube.session.player?.decipher(
info.streaming_data?.server_abr_streaming_url
);
const ustreamerConfig = info.player_config
?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config;

if (!ustreamerConfig)
throw new Error('Could not find the ustreamer config in the player response.');
if (!serverAbrStreamingUrl)
throw new Error('This video has no SABR streaming URL (it may use the legacy protocol).');

const formats = info.streaming_data?.adaptive_formats.map(buildSabrFormat) ?? [];

const stream = new SabrStream({
formats,
serverAbrStreamingUrl,
videoPlaybackUstreamerConfig: ustreamerConfig,
poToken,
clientInfo: {
clientName: parseInt(
Constants.CLIENT_NAME_IDS[innertube.session.context.client.clientName as keyof typeof Constants.CLIENT_NAME_IDS]
),
clientVersion: innertube.session.context.client.clientVersion
}
});

// The server may ask us to reload the player response (e.g. when formats expire).
stream.on('reloadPlayerResponse', async (_reloadPlaybackContext: ReloadPlaybackContext) => {
const reloaded = await innertube.getBasicInfo(videoId);
const url = await innertube.session.player?.decipher(reloaded.streaming_data?.server_abr_streaming_url);
const config = reloaded.player_config
?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config;
if (url && config) {
stream.setStreamingURL(url);
stream.setUstreamerConfig(config);
}
});

const options: SabrPlaybackOptions = {
videoQuality,
preferMP4: true,
preferH264: true,
enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
};

console.info('Starting SABR download...\n');
const { videoStream, audioStream, selectedFormats } = await stream.start(options);

const video = createFileSink(selectedFormats.videoFormat, title);
const audio = createFileSink(selectedFormats.audioFormat, title);

await Promise.all([
videoStream.pipeTo(video.sink),
audioStream.pipeTo(audio.sink)
]);

console.info(`\nDone! Saved:\n ${video.filePath}\n ${audio.filePath}`);
}

main().catch((err) => {
console.error('\nDownload failed:', err);
process.exit(1);
});
22 changes: 22 additions & 0 deletions examples/download/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "youtube-sabr-download-example",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Downloads a YouTube video using the SABR streaming protocol.",
"scripts": {
"start": "tsx index.ts"
},
"dependencies": {
"bgutils-js": "^3.1.2",
"googlevideo": "^4.0.4",
"jsdom": "^25.0.0",
"youtubei.js": "file:../.."
},
"devDependencies": {
"@types/jsdom": "^21.1.7",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}