mirror of
https://github.com/musix-org/musix-oss
synced 2024-12-23 16:13:18 +00:00
383 lines
8.7 KiB
JavaScript
383 lines
8.7 KiB
JavaScript
const url = require('url');
|
|
const FORMATS = require('./formats');
|
|
|
|
|
|
// Use these to help sort formats, higher is better.
|
|
const audioEncodingRanks = [
|
|
'mp4a',
|
|
'mp3',
|
|
'vorbis',
|
|
'aac',
|
|
'opus',
|
|
'flac',
|
|
];
|
|
const videoEncodingRanks = [
|
|
'mp4v',
|
|
'avc1',
|
|
'Sorenson H.283',
|
|
'MPEG-4 Visual',
|
|
'VP8',
|
|
'VP9',
|
|
'H.264',
|
|
];
|
|
|
|
const getBitrate = (format) => parseInt(format.bitrate) || 0;
|
|
const audioScore = (format) => {
|
|
const abitrate = format.audioBitrate || 0;
|
|
const aenc = audioEncodingRanks.findIndex(enc => format.codecs && format.codecs.includes(enc));
|
|
return abitrate + aenc / 10;
|
|
};
|
|
|
|
|
|
/**
|
|
* Sort formats from highest quality to lowest.
|
|
* By resolution, then video bitrate, then audio bitrate.
|
|
*
|
|
* @param {Object} a
|
|
* @param {Object} b
|
|
*/
|
|
exports.sortFormats = (a, b) => {
|
|
const ares = a.qualityLabel ? parseInt(a.qualityLabel.slice(0, -1)) : 0;
|
|
const bres = b.qualityLabel ? parseInt(b.qualityLabel.slice(0, -1)) : 0;
|
|
const afeats = ~~!!ares * 2 + ~~!!a.audioBitrate;
|
|
const bfeats = ~~!!bres * 2 + ~~!!b.audioBitrate;
|
|
|
|
if (afeats === bfeats) {
|
|
if (ares === bres) {
|
|
let avbitrate = getBitrate(a);
|
|
let bvbitrate = getBitrate(b);
|
|
if (avbitrate === bvbitrate) {
|
|
let aascore = audioScore(a);
|
|
let bascore = audioScore(b);
|
|
if (aascore === bascore) {
|
|
const avenc = videoEncodingRanks.findIndex(enc => a.codecs && a.codecs.includes(enc));
|
|
const bvenc = videoEncodingRanks.findIndex(enc => b.codecs && b.codecs.includes(enc));
|
|
return bvenc - avenc;
|
|
} else {
|
|
return bascore - aascore;
|
|
}
|
|
} else {
|
|
return bvbitrate - avbitrate;
|
|
}
|
|
} else {
|
|
return bres - ares;
|
|
}
|
|
} else {
|
|
return bfeats - afeats;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Choose a format depending on the given options.
|
|
*
|
|
* @param {Array.<Object>} formats
|
|
* @param {Object} options
|
|
* @return {Object|Error}
|
|
*/
|
|
exports.chooseFormat = (formats, options) => {
|
|
if (typeof options.format === 'object') {
|
|
return options.format;
|
|
}
|
|
|
|
if (options.filter) {
|
|
formats = exports.filterFormats(formats, options.filter);
|
|
if (formats.length === 0) {
|
|
return Error('No formats found with custom filter');
|
|
}
|
|
}
|
|
|
|
let format;
|
|
const quality = options.quality || 'highest';
|
|
switch (quality) {
|
|
case 'highest':
|
|
format = formats[0];
|
|
break;
|
|
|
|
case 'lowest':
|
|
format = formats[formats.length - 1];
|
|
break;
|
|
|
|
case 'highestaudio':
|
|
formats = exports.filterFormats(formats, 'audio');
|
|
format = null;
|
|
for (let f of formats) {
|
|
if (!format
|
|
|| audioScore(f) > audioScore(format))
|
|
format = f;
|
|
}
|
|
break;
|
|
|
|
case 'lowestaudio':
|
|
formats = exports.filterFormats(formats, 'audio');
|
|
format = null;
|
|
for (let f of formats) {
|
|
if (!format
|
|
|| audioScore(f) < audioScore(format))
|
|
format = f;
|
|
}
|
|
break;
|
|
|
|
case 'highestvideo':
|
|
formats = exports.filterFormats(formats, 'video');
|
|
format = null;
|
|
for (let f of formats) {
|
|
if (!format
|
|
|| getBitrate(f) > getBitrate(format))
|
|
format = f;
|
|
}
|
|
break;
|
|
|
|
case 'lowestvideo':
|
|
formats = exports.filterFormats(formats, 'video');
|
|
format = null;
|
|
for (let f of formats) {
|
|
if (!format
|
|
|| getBitrate(f) < getBitrate(format))
|
|
format = f;
|
|
}
|
|
break;
|
|
|
|
default: {
|
|
let getFormat = (itag) => {
|
|
return formats.find((format) => '' + format.itag === '' + itag);
|
|
};
|
|
if (Array.isArray(quality)) {
|
|
quality.find((q) => format = getFormat(q));
|
|
} else {
|
|
format = getFormat(quality);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
if (!format) {
|
|
return Error('No such format found: ' + quality);
|
|
}
|
|
return format;
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {Array.<Object>} formats
|
|
* @param {Function} filter
|
|
* @return {Array.<Object>}
|
|
*/
|
|
exports.filterFormats = (formats, filter) => {
|
|
let fn;
|
|
const hasVideo = format => !!format.qualityLabel;
|
|
const hasAudio = format => !!format.audioBitrate;
|
|
switch (filter) {
|
|
case 'audioandvideo':
|
|
fn = (format) => hasVideo(format) && hasAudio(format);
|
|
break;
|
|
|
|
case 'video':
|
|
fn = hasVideo;
|
|
break;
|
|
|
|
case 'videoonly':
|
|
fn = (format) => hasVideo(format) && !hasAudio(format);
|
|
break;
|
|
|
|
case 'audio':
|
|
fn = hasAudio;
|
|
break;
|
|
|
|
case 'audioonly':
|
|
fn = (format) => !hasVideo(format) && hasAudio(format);
|
|
break;
|
|
|
|
default:
|
|
if (typeof filter === 'function') {
|
|
fn = filter;
|
|
} else {
|
|
throw TypeError(`Given filter (${filter}) is not supported`);
|
|
}
|
|
}
|
|
return formats.filter(fn);
|
|
};
|
|
|
|
|
|
/**
|
|
* String#indexOf() that supports regex too.
|
|
*
|
|
* @param {string} haystack
|
|
* @param {string|RegExp} needle
|
|
* @return {number}
|
|
*/
|
|
const indexOf = (haystack, needle) => {
|
|
return needle instanceof RegExp ?
|
|
haystack.search(needle) : haystack.indexOf(needle);
|
|
};
|
|
|
|
|
|
/**
|
|
* Extract string inbetween another.
|
|
*
|
|
* @param {string} haystack
|
|
* @param {string} left
|
|
* @param {string} right
|
|
* @return {string}
|
|
*/
|
|
exports.between = (haystack, left, right) => {
|
|
let pos = indexOf(haystack, left);
|
|
if (pos === -1) { return ''; }
|
|
haystack = haystack.slice(pos + left.length);
|
|
pos = indexOf(haystack, right);
|
|
if (pos === -1) { return ''; }
|
|
haystack = haystack.slice(0, pos);
|
|
return haystack;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get video ID.
|
|
*
|
|
* There are a few type of video URL formats.
|
|
* - https://www.youtube.com/watch?v=VIDEO_ID
|
|
* - https://m.youtube.com/watch?v=VIDEO_ID
|
|
* - https://youtu.be/VIDEO_ID
|
|
* - https://www.youtube.com/v/VIDEO_ID
|
|
* - https://www.youtube.com/embed/VIDEO_ID
|
|
* - https://music.youtube.com/watch?v=VIDEO_ID
|
|
* - https://gaming.youtube.com/watch?v=VIDEO_ID
|
|
*
|
|
* @param {string} link
|
|
* @return {string|Error}
|
|
*/
|
|
const validQueryDomains = new Set([
|
|
'youtube.com',
|
|
'www.youtube.com',
|
|
'm.youtube.com',
|
|
'music.youtube.com',
|
|
'gaming.youtube.com',
|
|
]);
|
|
const validPathDomains = new Set([
|
|
'youtu.be',
|
|
'youtube.com',
|
|
'www.youtube.com',
|
|
]);
|
|
exports.getURLVideoID = (link) => {
|
|
const parsed = url.parse(link, true);
|
|
let id = parsed.query.v;
|
|
if (validPathDomains.has(parsed.hostname) && !id) {
|
|
const paths = parsed.pathname.split('/');
|
|
id = paths[paths.length - 1];
|
|
} else if (parsed.hostname && !validQueryDomains.has(parsed.hostname)) {
|
|
return Error('Not a YouTube domain');
|
|
}
|
|
if (!id) {
|
|
return Error('No video id found: ' + link);
|
|
}
|
|
id = id.substring(0, 11);
|
|
if (!exports.validateID(id)) {
|
|
return TypeError(`Video id (${id}) does not match expected ` +
|
|
`format (${idRegex.toString()})`);
|
|
}
|
|
return id;
|
|
};
|
|
|
|
|
|
/**
|
|
* Gets video ID either from a url or by checking if the given string
|
|
* matches the video ID format.
|
|
*
|
|
* @param {string} str
|
|
* @return {string|Error}
|
|
*/
|
|
exports.getVideoID = (str) => {
|
|
if (exports.validateID(str)) {
|
|
return str;
|
|
} else {
|
|
return exports.getURLVideoID(str);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Returns true if given id satifies YouTube's id format.
|
|
*
|
|
* @param {string} id
|
|
* @return {boolean}
|
|
*/
|
|
const idRegex = /^[a-zA-Z0-9-_]{11}$/;
|
|
exports.validateID = (id) => {
|
|
return idRegex.test(id);
|
|
};
|
|
|
|
|
|
/**
|
|
* Checks wether the input string includes a valid id.
|
|
*
|
|
* @param {string} string
|
|
* @return {boolean}
|
|
*/
|
|
exports.validateURL = (string) => {
|
|
return !(exports.getURLVideoID(string) instanceof Error);
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {Object} format
|
|
* @return {Object}
|
|
*/
|
|
exports.addFormatMeta = (format) => {
|
|
format = Object.assign({}, FORMATS[format.itag], format);
|
|
format.container = format.mimeType ?
|
|
format.mimeType.split(';')[0].split('/')[1] : null;
|
|
format.codecs = format.mimeType ?
|
|
exports.between(format.mimeType, 'codecs="', '"') : null;
|
|
format.live = /\/source\/yt_live_broadcast\//.test(format.url);
|
|
format.isHLS = /\/manifest\/hls_(variant|playlist)\//.test(format.url);
|
|
format.isDashMPD = /\/manifest\/dash\//.test(format.url);
|
|
return format;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get only the string from an HTML string.
|
|
*
|
|
* @param {string} html
|
|
* @return {string}
|
|
*/
|
|
exports.stripHTML = (html) => {
|
|
return html
|
|
.replace(/\n/g, ' ')
|
|
.replace(/\s*<\s*br\s*\/?\s*>\s*/gi, '\n')
|
|
.replace(/<\s*\/\s*p\s*>\s*<\s*p[^>]*>/gi, '\n')
|
|
.replace(/<.*?>/gi, '')
|
|
.trim();
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {Array.<Function>} funcs
|
|
* @param {Function(!Error, Array.<Object>)} callback
|
|
*/
|
|
exports.parallel = (funcs, callback) => {
|
|
let funcsDone = 0;
|
|
let errGiven = false;
|
|
let results = [];
|
|
const len = funcs.length;
|
|
|
|
const checkDone = (index, err, result) => {
|
|
if (errGiven) { return; }
|
|
if (err) {
|
|
errGiven = true;
|
|
callback(err);
|
|
return;
|
|
}
|
|
results[index] = result;
|
|
if (++funcsDone === len) {
|
|
callback(null, results);
|
|
}
|
|
};
|
|
|
|
if (len > 0) {
|
|
funcs.forEach((f, i) => { f(checkDone.bind(null, i)); });
|
|
} else {
|
|
callback(null, results);
|
|
}
|
|
};
|