mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-07-03 09:35:05 +00:00
feat: download video directly from VideoInfo
As suggested in #45, this also implements a new "best" and "bestefficiency" format selector.
This commit is contained in:
222
lib/Innertube.js
222
lib/Innertube.js
@@ -168,12 +168,13 @@ class Innertube {
|
||||
*/
|
||||
async getInfo(video_id) {
|
||||
Utils.throwIfMissing({ video_id });
|
||||
const cpn = Utils.generateRandomString(16);
|
||||
|
||||
const initial_info = this.actions.getVideoInfo(video_id);
|
||||
const initial_info = this.actions.getVideoInfo(video_id, cpn);
|
||||
const continuation = this.actions.next({ video_id });
|
||||
|
||||
const response = await Promise.all([ initial_info, continuation ]);
|
||||
return new VideoInfo(response, this.actions, this.#player);
|
||||
return new VideoInfo(response, this.actions, this.#player, cpn);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,68 +405,6 @@ class Innertube {
|
||||
return new Playlist(this.actions, response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to process and filter formats.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} video_data
|
||||
* @returns {{ selected_format: object, formats: any[] }}
|
||||
*/
|
||||
#chooseFormat(options, video_data) {
|
||||
let formats = [];
|
||||
|
||||
formats = formats
|
||||
.concat(video_data.streamingData.formats || [])
|
||||
.concat(video_data.streamingData.adaptiveFormats || []);
|
||||
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new Signature(format.url, this.#player.signature_decipher).decipher();
|
||||
}
|
||||
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
if (url_components.searchParams.get('n')) {
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_decipher, url_components.searchParams.get('n')).transform());
|
||||
}
|
||||
|
||||
format.url = url_components.toString();
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
let format;
|
||||
let bitrates;
|
||||
let filtered_formats;
|
||||
|
||||
filtered_formats = ({
|
||||
'video': formats.filter((format) => format.has_video && !format.has_audio),
|
||||
'audio': formats.filter((format) => format.has_audio && !format.has_video),
|
||||
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
|
||||
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
|
||||
|
||||
let streams;
|
||||
|
||||
options.type != 'audio' &&
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
!streams || !streams.length &&
|
||||
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
|
||||
return { selected_format: format, formats };
|
||||
}
|
||||
|
||||
/**
|
||||
* An alternative to {@link download}.
|
||||
* Returns deciphered streaming data.
|
||||
@@ -475,22 +414,12 @@ class Innertube {
|
||||
* @param {string} options.quality - video quality; 360p, 720p, 1080p, etc...
|
||||
* @param {string} options.type - download type, can be: video, audio or videoandaudio
|
||||
* @param {string} options.format - file format
|
||||
* @returns {Promise.<{ selected_format: object, formats: object[] }>}
|
||||
* @returns {Promise.<object>}
|
||||
*/
|
||||
async getStreamingData(video_id, options = {}) {
|
||||
Utils.throwIfMissing({ video_id });
|
||||
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
const info = await this.getInfo(video_id);
|
||||
|
||||
const data = await this.actions.getVideoInfo(video_id);
|
||||
const streaming_data = this.#chooseFormat(options, data);
|
||||
|
||||
if (!streaming_data.selected_format)
|
||||
throw new Utils.NoStreamingDataError('Could not find any suitable format.', { video_id, options });
|
||||
|
||||
return streaming_data;
|
||||
return info.chooseFormat(options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,140 +437,17 @@ class Innertube {
|
||||
*/
|
||||
download(video_id, options = {}) {
|
||||
Utils.throwIfMissing({ video_id });
|
||||
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
const stream = new PassThrough();
|
||||
|
||||
let cancel;
|
||||
let cancelled = false;
|
||||
|
||||
const cpn = Utils.generateRandomString(16);
|
||||
|
||||
const stream = new Stream.PassThrough();
|
||||
this.actions.getVideoInfo(video_id, cpn).then(async (video_data) => {
|
||||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
|
||||
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
|
||||
if (!video_data.streamingData)
|
||||
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
|
||||
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
|
||||
|
||||
if (!format)
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
|
||||
stream.emit('info', { video_details, selected_format: format, formats });
|
||||
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(`${format.url}&cpn=${cpn}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
} else {
|
||||
stream.emit('start');
|
||||
}
|
||||
|
||||
let downloaded_size = 0;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: true });
|
||||
} else {
|
||||
const chunk_size = 1048576 * 10; // 10MB
|
||||
|
||||
let chunk_start = (options.range && options.range.start || 0);
|
||||
let chunk_end = (options.range && options.range.end || chunk_size);
|
||||
let downloaded_size = 0;
|
||||
let must_end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
const downloadChunk = async () => {
|
||||
(chunk_end >= format.contentLength || options.range) && (must_end = true);
|
||||
options.range && (format.contentLength = options.range.end);
|
||||
|
||||
const response = await Axios.get(`${format.url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
}
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
|
||||
let size = (format.contentLength / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / format.contentLength) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!must_end && !options.range) {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
let underlying_stream;
|
||||
(async () => {
|
||||
const info = await this.getInfo(video_id);
|
||||
underlying_stream = info.download(options);
|
||||
underlying_stream.pipe(stream);
|
||||
})
|
||||
|
||||
stream.cancel = () => {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
underlying_stream?.cancel();
|
||||
};
|
||||
|
||||
return stream;
|
||||
|
||||
@@ -40,16 +40,15 @@ class Format {
|
||||
decipher(player) {
|
||||
this.url = this.url || this.signature_cipher || this.cipher;
|
||||
|
||||
const args = QueryString.parse(this.url);
|
||||
const url_components = new URL(args.url);
|
||||
const url_components = new URL(this.url);
|
||||
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
if (this.signature_cipher || this.cipher) {
|
||||
const signature = new Signature(this.url, player.signature_decipher).decipherBeta();
|
||||
|
||||
args.sp ?
|
||||
url_components.searchParams.set(args.sp, signature) :
|
||||
url_components.searchParams.get('sp') ?
|
||||
url_components.searchParams.set(url_components.searchParams.get('sp'), signature) :
|
||||
url_components.searchParams.set('signature', signature);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
const Parser = require('../contents');
|
||||
const { InnertubeError } = require('../../utils/Utils');
|
||||
const { Stream, PassThrough } = require('stream');
|
||||
const Axios = require('axios');
|
||||
const Constants = require('../../utils/Constants');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
|
||||
/** namespace */
|
||||
class VideoInfo {
|
||||
#page;
|
||||
#actions;
|
||||
#player;
|
||||
#cpn;
|
||||
|
||||
#watch_next_continuation;
|
||||
|
||||
@@ -16,9 +21,10 @@ class VideoInfo {
|
||||
* @param {import('../../core/Actions')} actions
|
||||
* @param {import('../../core/Player')} player
|
||||
*/
|
||||
constructor(data, actions, player) {
|
||||
constructor(data, actions, player, cpn) {
|
||||
this.#actions = actions;
|
||||
this.#player = player;
|
||||
this.#cpn = cpn;
|
||||
|
||||
const info = Parser.parseResponse(data[0]);
|
||||
const next = Parser.parseResponse(data[1].data);
|
||||
@@ -244,6 +250,204 @@ class VideoInfo {
|
||||
if (is_music_section) songs.push(current_song);
|
||||
return songs;
|
||||
}
|
||||
|
||||
chooseFormat(options) {
|
||||
let formats = [
|
||||
...(this.streaming_data.formats || []),
|
||||
...(this.streaming_data.adaptive_formats || [])
|
||||
];
|
||||
|
||||
const requires_audio = options.type.includes('audio');
|
||||
const requires_video = options.type.includes('video');
|
||||
let best_width = -1;
|
||||
const is_best = ['best','bestefficiency'].includes(options.quality);
|
||||
const use_most_efficient = options.quality !== 'best';
|
||||
let candidates = formats.filter(format => {
|
||||
if (best_width < format.width)
|
||||
best_width = format.width;
|
||||
if (requires_audio && !format.has_audio)
|
||||
return false;
|
||||
if (requires_video && !format.has_video)
|
||||
return false;
|
||||
if (options.format !== 'any' && !format.mime_type.includes(options.format))
|
||||
return false;
|
||||
if (!is_best && format.quality_label !== options.quality)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new InnertubeError('No matching formats found', {
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
if (is_best)
|
||||
candidates = candidates.filter(format => format.width === best_width);
|
||||
|
||||
if (use_most_efficient)
|
||||
// sort by bitrate (lower is better)
|
||||
candidates.sort((a, b) => a.bitrate - b.bitrate);
|
||||
else
|
||||
// sort by bitrate (higher is better)
|
||||
candidates.sort((a, b) => b.bitrate - a.bitrate);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} options - download options.
|
||||
* @param {string} [options.quality] - video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
|
||||
* @param {string} [options.type] - download type, can be: video, audio or videoandaudio
|
||||
* @param {string} [options.format] - file format
|
||||
* @param {object} [options.range] - download range, indicates which bytes should be downloaded.
|
||||
* @param {number} options.range.start - the beginning of the range.
|
||||
* @param {number} options.range.end - the end of the range.
|
||||
* @returns {PassThrough}
|
||||
*/
|
||||
download(options = {}) {
|
||||
const stream = new PassThrough();
|
||||
|
||||
let cancel;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
if (this.playability_status === 'UNPLAYABLE')
|
||||
return stream.emit('error', new InnertubeError('Video is unplayable', { video: this, error_type: 'UNPLAYABLE' }));
|
||||
if (this.playability_status === 'LOGIN_REQUIRED')
|
||||
return stream.emit('error', new InnertubeError('Video is login required', { video: this, error_type: 'LOGIN_REQUIRED' }));
|
||||
if (!this.streaming_data)
|
||||
return stream.emit('error', new InnertubeError('Streaming data not available.', { video: this, error_type: 'NO_STREAMING_DATA' }));
|
||||
|
||||
const opts = {
|
||||
quality: '360p',
|
||||
type: 'videoandaudio',
|
||||
format: 'mp4',
|
||||
range: undefined,
|
||||
...options
|
||||
};
|
||||
|
||||
const format = this.chooseFormat(opts);
|
||||
|
||||
const format_url = format.decipher(this.#player);
|
||||
|
||||
if (opts.type === 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(`${format_url}&cpn=${this.#cpn}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
} else {
|
||||
stream.emit('start');
|
||||
}
|
||||
|
||||
let downloaded_size = 0;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: true });
|
||||
} else {
|
||||
const chunk_size = 1048576 * 10; // 10MB
|
||||
|
||||
let chunk_start = (options.range && options.range.start || 0);
|
||||
let chunk_end = (options.range && options.range.end || chunk_size);
|
||||
let downloaded_size = 0;
|
||||
let must_end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
const downloadChunk = async () => {
|
||||
(chunk_end >= format.content_length || options.range) && (must_end = true);
|
||||
options.range && (format.content_length = options.range.end);
|
||||
|
||||
const response = await Axios.get(`${format_url}&cpn=${this.#cpn}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
}
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
|
||||
let size = (format.content_length / 1024 / 1024).toFixed(2);
|
||||
let percentage = Math.floor((downloaded_size / format.content_length) * 100);
|
||||
|
||||
stream.emit('progress', {
|
||||
size,
|
||||
percentage,
|
||||
chunk_size: chunk.length,
|
||||
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
|
||||
raw_data: {
|
||||
chunk_size: chunk.length,
|
||||
downloaded: downloaded_size,
|
||||
size: response.headers['content-length']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!must_end && !options.range) {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
|
||||
downloadChunk();
|
||||
}
|
||||
|
||||
})().catch(err => {
|
||||
stream.emit('error', err);
|
||||
})
|
||||
|
||||
stream.cancel = () => {
|
||||
cancelled = true;
|
||||
cancel && cancel();
|
||||
};
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VideoInfo;
|
||||
Reference in New Issue
Block a user