191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
import { createWriteStream, type WriteStream } from 'node:fs';
|
|
import { Constants, Innertube, type IPlayerResponse, UniversalCache, YTNodes } from 'youtubei.js';
|
|
|
|
import { generateWebPoToken } from './webpo-helper.js';
|
|
import type { SabrFormat } from 'googlevideo/shared-types';
|
|
import type { ReloadPlaybackContext } from 'googlevideo/protos';
|
|
import { SabrStream, type SabrPlaybackOptions } from 'googlevideo/sabr-stream';
|
|
import { buildSabrFormat } from 'googlevideo/utils';
|
|
|
|
import * as hr from '@tsmx/human-readable'
|
|
|
|
export interface DownloadOutput {
|
|
stream: WriteStream;
|
|
filePath: string;
|
|
}
|
|
|
|
export interface StreamResults {
|
|
videoStream: ReadableStream;
|
|
audioStream: ReadableStream;
|
|
selectedFormats: {
|
|
videoFormat: SabrFormat;
|
|
audioFormat: SabrFormat;
|
|
};
|
|
videoTitle: string;
|
|
}
|
|
|
|
/**
|
|
* Fetches video details and streaming information from YouTube.
|
|
*/
|
|
export async function makePlayerRequest(innertube: Innertube, videoId: string, reloadPlaybackContext?: ReloadPlaybackContext): Promise<IPlayerResponse> {
|
|
const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
|
|
|
|
const extraArgs: Record<string, any> = {
|
|
playbackContext: {
|
|
adPlaybackContext: { pyv: true },
|
|
contentPlaybackContext: {
|
|
vis: 0,
|
|
splay: false,
|
|
lactMilliseconds: '-1',
|
|
signatureTimestamp: innertube.session.player?.sts
|
|
}
|
|
},
|
|
contentCheckOk: true,
|
|
racyCheckOk: true
|
|
};
|
|
|
|
if (reloadPlaybackContext) {
|
|
extraArgs.playbackContext.reloadPlaybackContext = reloadPlaybackContext;
|
|
}
|
|
|
|
return await watchEndpoint.call<IPlayerResponse>(innertube.actions, { ...extraArgs, parse: true });
|
|
}
|
|
|
|
export function determineFileExtension(mimeType: string): string {
|
|
if (mimeType.includes('video')) {
|
|
return mimeType.includes('webm') ? 'webm' : 'mp4';
|
|
} else if (mimeType.includes('audio')) {
|
|
return mimeType.includes('webm') ? 'webm' : 'm4a';
|
|
}
|
|
return 'bin';
|
|
}
|
|
|
|
export function createOutputStream(videoId: string, mimeType: string): DownloadOutput {
|
|
const type = mimeType.includes('video') ? 'video' : 'audio';
|
|
const extension = determineFileExtension(mimeType);
|
|
const fileName = `./output/${videoId}_${type}.${extension}`;
|
|
|
|
return {
|
|
stream: createWriteStream(fileName, { flags: 'w', encoding: 'binary' }),
|
|
filePath: fileName
|
|
};
|
|
}
|
|
|
|
export function bytesToMB(bytes: number): string {
|
|
return (bytes / (1024 * 1024)).toFixed(2);
|
|
}
|
|
|
|
/**
|
|
* Creates a WritableStream that tracks download progress.
|
|
*/
|
|
export function createStreamSink(format: SabrFormat, outputStream: WriteStream, ws: any, type: string) {
|
|
let size = 0;
|
|
const totalSize = Number(format.contentLength || 0);
|
|
const videoStartTime = Date.now();
|
|
const videoPrecentages: string[] = []
|
|
|
|
return new WritableStream({
|
|
write(chunk) {
|
|
return new Promise((resolve, reject) => {
|
|
size += chunk.length;
|
|
|
|
if (totalSize > 0) {
|
|
let elapsedTime = (Date.now() - videoStartTime) / 1000;
|
|
let progress = size / totalSize;
|
|
let speedInMBps = (size / (1024 * 1024)) / elapsedTime;
|
|
let remainingTime = (totalSize - size) / (speedInMBps * 1024 * 1024);
|
|
|
|
if (!videoPrecentages.includes((progress * 100).toFixed(0))) {
|
|
videoPrecentages.push((progress * 100).toFixed(0))
|
|
ws.send(`[${type}] ${(progress * 100).toFixed(2)}% of ${hr.fromBytes(totalSize, {})} at ${speedInMBps.toFixed(2)} MB/s ETA ${secondsToTime(parseInt(remainingTime.toFixed(0)))}`)
|
|
}
|
|
}
|
|
|
|
outputStream.write(chunk, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
},
|
|
close() {
|
|
outputStream.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initializes Innertube client and sets up SABR streaming for a YouTube video.
|
|
*/
|
|
export async function createSabrStream(
|
|
videoId: string,
|
|
options: SabrPlaybackOptions
|
|
): Promise<{
|
|
innertube: Innertube;
|
|
streamResults: StreamResults;
|
|
}> {
|
|
const innertube = await Innertube.create({ cache: new UniversalCache(true) });
|
|
const webPoTokenResult = await generateWebPoToken(innertube.session.context.client.visitorData || '');
|
|
console.log(`debugging -> ${JSON.stringify(webPoTokenResult)}`)
|
|
|
|
// Get video metadata.
|
|
const playerResponse = await makePlayerRequest(innertube, videoId);
|
|
const videoTitle = playerResponse.video_details?.title || 'Unknown Video';
|
|
|
|
// Now get the streaming information.
|
|
const serverAbrStreamingUrl = innertube.session.player?.decipher(playerResponse.streaming_data?.server_abr_streaming_url);
|
|
const videoPlaybackUstreamerConfig = playerResponse.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 sabrFormats = playerResponse.streaming_data?.adaptive_formats
|
|
.filter(f => {
|
|
if (f.is_auto_dubbed) return false
|
|
if (f.audio_track && !f.audio_track.display_name.endsWith('original')) return false
|
|
return true
|
|
})
|
|
.map(buildSabrFormat) || [];
|
|
|
|
const serverAbrStream = new SabrStream({
|
|
formats: sabrFormats,
|
|
serverAbrStreamingUrl,
|
|
videoPlaybackUstreamerConfig,
|
|
poToken: webPoTokenResult.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
|
|
}
|
|
});
|
|
|
|
// Handle player response reload events (e.g, when IP changes, or formats expire).
|
|
serverAbrStream.on('reloadPlayerResponse', async (reloadPlaybackContext) => {
|
|
const playerResponse = await makePlayerRequest(innertube, videoId, reloadPlaybackContext);
|
|
|
|
const serverAbrStreamingUrl = innertube.session.player?.decipher(playerResponse.streaming_data?.server_abr_streaming_url);
|
|
const videoPlaybackUstreamerConfig = playerResponse.player_config?.media_common_config.media_ustreamer_request_config?.video_playback_ustreamer_config;
|
|
|
|
if (serverAbrStreamingUrl && videoPlaybackUstreamerConfig) {
|
|
serverAbrStream.setStreamingURL(serverAbrStreamingUrl);
|
|
serverAbrStream.setUstreamerConfig(videoPlaybackUstreamerConfig);
|
|
}
|
|
});
|
|
|
|
const { videoStream, audioStream, selectedFormats } = await serverAbrStream.start(options);
|
|
|
|
return {
|
|
innertube,
|
|
streamResults: {
|
|
videoStream,
|
|
audioStream,
|
|
selectedFormats,
|
|
videoTitle
|
|
}
|
|
};
|
|
}
|
|
|
|
function secondsToTime(seconds: number) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
const formattedSeconds = remainingSeconds < 10 ? '0' + remainingSeconds : remainingSeconds;
|
|
return `${minutes}:${formattedSeconds}`;
|
|
} |