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:
Daniel Wykerd
2022-06-16 02:12:34 +02:00
committed by LuanRT
parent 75e0453f69
commit 6d7609c32a
3 changed files with 222 additions and 213 deletions

View File

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

View File

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

View File

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