function ytdl(link, options) { var stream = createStream(options); getInfo(link, options, function(err, info) { if (err) { stream.emit('error', err); return; } downloadFromInfoCallback(stream, info, options); }); return stream; }
n/a
chooseFormat = function (formats, options) { if (typeof options.format === 'object') { return options.format; } if (options.filter) { formats = exports.filterFormats(formats, options.filter); if (formats.length === 0) { return new Error('No formats found with custom filter'); } } var format; var quality = options.quality || 'highest'; switch (quality) { case 'highest': format = formats[0]; break; case 'lowest': format = formats[formats.length - 1]; break; default: var getFormat = function(itag) { for (var i = 0, len = formats.length; i < len; i++) { if (formats[i].itag === '' + itag) { return formats[i]; } } return null; }; if (Array.isArray(quality)) { for (var i = 0, len = quality.length; i < len; i++) { format = getFormat(quality[i]); if (format) { break; } } } else { format = getFormat(quality); } } if (!format) { return new Error('No such format found: ' + quality); } else if (format.rtmp) { return new Error('rtmp protocol not supported'); } return format; }
...
Use this if you only want to get metainfo from a video. If `callback` isn't given, returns a promise.
### ytdl.downloadFromInfo(info, options)
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
### ytdl.chooseFormat(formats, options)
Can be used if you'd like to choose a format yourself with the [options above](#ytdlurl-options).
### ytdl.filterFormats(formats, filter)
If you'd like to work with only some formats, you can use the [`filter` option above](#ytdlurl-options).
...
downloadFromInfo = function (info, options) { var stream = createStream(options); setImmediate(function() { downloadFromInfoCallback(stream, info, options); }); return stream; }
...
Destroys the underlying connection.
### ytdl.getInfo(url, [options], [callback(err, info)])
Use this if you only want to get metainfo from a video. If `callback` isn't given, returns a promise.
### ytdl.downloadFromInfo(info, options)
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
### ytdl.chooseFormat(formats, options)
Can be used if you'd like to choose a format yourself with the [options above](#ytdlurl-options).
...
filterFormats = function (formats, filter) { var fn; switch (filter) { case 'video': fn = function(format) { return format.bitrate; }; break; case 'videoonly': fn = function(format) { return format.bitrate && !format.audioBitrate; }; break; case 'audio': fn = function(format) { return format.audioBitrate; }; break; case 'audioonly': fn = function(format) { return !format.bitrate && format.audioBitrate; }; break; default: fn = filter; } return formats.filter(fn); }
...
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
### ytdl.chooseFormat(formats, options)
Can be used if you'd like to choose a format yourself with the [options above](#ytdlurl-options).
### ytdl.filterFormats(formats, filter)
If you'd like to work with only some formats, you can use the [`filter` option above](#ytdlurl-options).
## Limitations
ytdl cannot download videos that fall into the following
* Regionally restricted (requires a [proxy](example/proxy.js))
...
function getInfo(link, options, callback) { if (typeof options === 'function') { callback = options; options = {}; } else if (!options) { options = {}; } if (!callback) { return new Promise(function(resolve, reject) { getInfo(link, options, function(err, info) { if (err) return reject(err); resolve(info); }); }); } if (options.request) { console.warn('`options.request` is deprecated, please use `options.requestOptions.transform`'); } var id = util.getVideoID(link); if (id instanceof Error) return callback(id); // Try getting config from the video page first. var url = VIDEO_URL + id; request(url, options.requestOptions, function(err, res, body) { if (err) return callback(err); // Check if there are any errors with this video page. var unavailableMsg = util.between(body, '<div id="player-unavailable"', '>'); if (!/\bhid\b/.test(util.between(unavailableMsg, 'class="', '"'))) { // Ignore error about age restriction. if (body.indexOf('<div id="watch7-player-age-gate-content"') < 0) { return callback(new Error(util.between(body, '<h1 id="unavailable-message" class="message">', '</h1>').trim())); } } // Parse out some additional informations since we already load that page. var additional = { // Get informations about the author/uploader. author: util.getAuthor(body), // Get the day the vid was published. published: util.getPublished(body), // Get description from #eow-description. description: util.getVideoDescription(body), // Get related videos. related_videos: util.getRelatedVideos(body), // Give the canonical link to the video. video_url: url, // Thumbnails. iurlsd : THUMBNAIL_URL + id + '/sddefault.jpg', iurlmq : THUMBNAIL_URL + id + '/mqdefault.jpg', iurlhq : THUMBNAIL_URL + id + '/hqdefault.jpg', iurlmaxres : THUMBNAIL_URL + id + '/maxresdefault.jpg', }; var jsonStr = util.between(body, 'ytplayer.config = ', '</script>'); if (jsonStr) { jsonStr = jsonStr.slice(0, jsonStr.lastIndexOf(';ytplayer.load')); var config; try { config = JSON.parse(jsonStr); } catch (err) { return callback(new Error('Error parsing config: ' + err.message)); } if (!config) { return callback(new Error('Could not parse video page config')); } gotConfig(id, options, additional, config, callback); } else { // If the video page doesn't work, maybe because it has mature content. // and requires an account logged into view, try the embed page. url = EMBED_URL + id; request(url, options.requestOptions, function(err, res, body) { if (err) return callback(err); config = util.between(body, 't.setConfig({\'PLAYER_CONFIG\': ', '},\''); if (!config) { return callback(new Error('Could not find `player config`')); } try { config = JSON.parse(config + '}'); } catch (err) { return callback(new Error('Error parsing config: ' + err.message)); } gotConfig(id, options, additional, config, callback); }); } }); }
...
Emitted whenever a new chunk is received. Passes values descriping the download progress and the parsed percentage.
### Stream#destroy()
Destroys the underlying connection.
### ytdl.getInfo(url, [options], [callback(err, info)])
Use this if you only want to get metainfo from a video. If `callback` isn't given, returns a promise.
### ytdl.downloadFromInfo(info, options)
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
...
get = function (key) { return exports.store[key]; }
...
*/
exports.getTokens = function(html5playerfile, options, callback) {
var key, cachedTokens;
var rs = /(?:html5)?player-([a-zA-Z0-9\-_]+)(?:\.js|\/)/
.exec(html5playerfile);
if (rs) {
key = rs[1];
cachedTokens = cache.get(key);
} else {
console.warn('Could not extract html5player key:', html5playerfile);
}
if (cachedTokens) {
callback(null, cachedTokens);
} else {
request(html5playerfile, options.requestOptions, function(err, res, body) {
...
reset = function () { exports.store = {}; }
n/a
set = function (key, value) { exports.store[key] = value; }
...
var tokens = exports.extractActions(body);
if (key && (!tokens || !tokens.length)) {
callback(new Error('Could not extract signature deciphering actions'));
return;
}
cache.set(key, tokens);
callback(null, tokens);
});
}
};
/**
...
decipher = function (tokens, sig) { sig = sig.split(''); var pos; for (var i = 0, len = tokens.length; i < len; i++) { var token = tokens[i]; switch (token[0]) { case 'r': sig = sig.reverse(); break; case 'w': pos = ~~token.slice(1); sig = swapHeadAndPosition(sig, pos); break; case 's': pos = ~~token.slice(1); sig = sig.slice(pos); break; case 'p': pos = ~~token.slice(1); sig.splice(0, pos); break; } } return sig.join(''); }
...
/**
* @param {String} url
* @param {Array.<String>} tokens
*/
function decipherURL(url, tokens) {
return url.replace(/\/s\/([a-fA-F0-9\.]+)/, function(_, s) {
return '/signature/' + sig.decipher(tokens, s);
});
}
/**
* Merges formats from DASH or M3U8 with formats from video info page.
*
...
decipherFormats = function (formats, tokens, debug) { formats.forEach(function(format) { var sig = tokens && format.s ? exports.decipher(tokens, format.s) : null; exports.setDownloadURL(format, sig, debug); }); }
...
if (info.formats.some(function(f) { return !!f.s; }) ||
config.args.dashmpd || info.dashmpd || info.hlsvp) {
var html5playerfile = urllib.resolve(VIDEO_URL, config.assets.js);
sig.getTokens(html5playerfile, options, function(err, tokens) {
if (err) return callback(err);
sig.decipherFormats(info.formats, tokens, options.debug);
var funcs = [];
var dashmpd;
if (config.args.dashmpd) {
dashmpd = decipherURL(config.args.dashmpd, tokens);
funcs.push(getDashManifest.bind(null, dashmpd, options));
}
...
extractActions = function (body) { var objResult = actionsObjRegexp.exec(body); var funcResult = actionsFuncRegexp.exec(body); if (!objResult || !funcResult) { return null; } var obj = objResult[1].replace(/\$/g, '\\$'); var objBody = objResult[2].replace(/\$/g, '\\$'); var funcbody = funcResult[1].replace(/\$/g, '\\$'); var result = reverseRegexp.exec(objBody); var reverseKey = result && result[1].replace(/\$/g, '\\$'); result = sliceRegexp.exec(objBody); var sliceKey = result && result[1].replace(/\$/g, '\\$'); result = spliceRegexp.exec(objBody); var spliceKey = result && result[1].replace(/\$/g, '\\$'); result = swapRegexp.exec(objBody); var swapKey = result && result[1].replace(/\$/g, '\\$'); var myreg = '(?:a=)?' + obj + '\\.(' + [reverseKey, sliceKey, spliceKey, swapKey].join('|') + ')\\(a,(\\d+)\\)'; var tokenizeRegexp = new RegExp(myreg, 'g'); var tokens = []; while ((result = tokenizeRegexp.exec(funcbody)) !== null) { switch (result[1]) { case swapKey: tokens.push('w' + result[2]); break; case reverseKey: tokens.push('r'); break; case sliceKey: tokens.push('s' + result[2]); break; case spliceKey: tokens.push('p' + result[2]); break; } } return tokens; }
...
}
if (cachedTokens) {
callback(null, cachedTokens);
} else {
request(html5playerfile, options.requestOptions, function(err, res, body) {
if (err) return callback(err);
var tokens = exports.extractActions(body);
if (key && (!tokens || !tokens.length)) {
callback(new Error('Could not extract signature deciphering actions'));
return;
}
cache.set(key, tokens);
callback(null, tokens);
...
getTokens = function (html5playerfile, options, callback) { var key, cachedTokens; var rs = /(?:html5)?player-([a-zA-Z0-9\-_]+)(?:\.js|\/)/ .exec(html5playerfile); if (rs) { key = rs[1]; cachedTokens = cache.get(key); } else { console.warn('Could not extract html5player key:', html5playerfile); } if (cachedTokens) { callback(null, cachedTokens); } else { request(html5playerfile, options.requestOptions, function(err, res, body) { if (err) return callback(err); var tokens = exports.extractActions(body); if (key && (!tokens || !tokens.length)) { callback(new Error('Could not extract signature deciphering actions')); return; } cache.set(key, tokens); callback(null, tokens); }); } }
...
// Add additional properties to info.
info = util.objectAssign(info, additional, false);
if (info.formats.some(function(f) { return !!f.s; }) ||
config.args.dashmpd || info.dashmpd || info.hlsvp) {
var html5playerfile = urllib.resolve(VIDEO_URL, config.assets.js);
sig.getTokens(html5playerfile, options, function(err, tokens) {
if (err) return callback(err);
sig.decipherFormats(info.formats, tokens, options.debug);
var funcs = [];
var dashmpd;
if (config.args.dashmpd) {
...
setDownloadURL = function (format, sig, debug) { var decodedUrl; if (format.url) { decodedUrl = format.url; } else { if (debug) { console.warn('Download url not found for itag ' + format.itag); } return; } try { decodedUrl = decodeURIComponent(decodedUrl); } catch (err) { if (debug) { console.warn('Could not decode url: ' + err.message); } return; } // Make some adjustments to the final url. var parsedUrl = url.parse(decodedUrl, true); // Deleting the `search` part is necessary otherwise changes to // `query` won't reflect when running `url.format()` delete parsedUrl.search; var query = parsedUrl.query; // This is needed for a speedier download. // See https://github.com/fent/node-ytdl-core/issues/127 query.ratebypass = 'yes'; if (sig) { query.signature = sig; } format.url = url.format(parsedUrl); }
...
* @param {Array.<Object>} formats
* @param {Array.<String>} tokens
* @param {Boolean} debug
*/
exports.decipherFormats = function(formats, tokens, debug) {
formats.forEach(function(format) {
var sig = tokens && format.s ? exports.decipher(tokens, format.s) : null;
exports.setDownloadURL(format, sig, debug);
});
};
...
addFormatMeta = function (format) { var meta = FORMATS[format.itag]; for (var key in meta) { format[key] = meta[key]; } if (/\/live\/1\//.test(format.url)) { format.live = true; } }
n/a
between = function (haystack, left, right) { var pos; pos = haystack.indexOf(left); if (pos === -1) { return ''; } haystack = haystack.slice(pos + left.length); pos = haystack.indexOf(right); if (pos === -1) { return ''; } haystack = haystack.slice(0, pos); return haystack; }
...
// Try getting config from the video page first.
var url = VIDEO_URL + id;
request(url, options.requestOptions, function(err, res, body) {
if (err) return callback(err);
// Check if there are any errors with this video page.
var unavailableMsg = util.between(body, '<div id="player-unavailable
x22;', '>');
if (!/\bhid\b/.test(util.between(unavailableMsg, 'class="', '"'))) {
// Ignore error about age restriction.
if (body.indexOf('<div id="watch7-player-age-gate-content"') < 0) {
return callback(new Error(util.between(body,
'<h1 id="unavailable-message" class="message">', '</h1>').trim
()));
}
}
...
chooseFormat = function (formats, options) { if (typeof options.format === 'object') { return options.format; } if (options.filter) { formats = exports.filterFormats(formats, options.filter); if (formats.length === 0) { return new Error('No formats found with custom filter'); } } var format; var quality = options.quality || 'highest'; switch (quality) { case 'highest': format = formats[0]; break; case 'lowest': format = formats[formats.length - 1]; break; default: var getFormat = function(itag) { for (var i = 0, len = formats.length; i < len; i++) { if (formats[i].itag === '' + itag) { return formats[i]; } } return null; }; if (Array.isArray(quality)) { for (var i = 0, len = quality.length; i < len; i++) { format = getFormat(quality[i]); if (format) { break; } } } else { format = getFormat(quality); } } if (!format) { return new Error('No such format found: ' + quality); } else if (format.rtmp) { return new Error('rtmp protocol not supported'); } return format; }
...
Use this if you only want to get metainfo from a video. If `callback` isn't given, returns a promise.
### ytdl.downloadFromInfo(info, options)
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
### ytdl.chooseFormat(formats, options)
Can be used if you'd like to choose a format yourself with the [options above](#ytdlurl-options).
### ytdl.filterFormats(formats, filter)
If you'd like to work with only some formats, you can use the [`filter` option above](#ytdlurl-options).
...
filterFormats = function (formats, filter) { var fn; switch (filter) { case 'video': fn = function(format) { return format.bitrate; }; break; case 'videoonly': fn = function(format) { return format.bitrate && !format.audioBitrate; }; break; case 'audio': fn = function(format) { return format.audioBitrate; }; break; case 'audioonly': fn = function(format) { return !format.bitrate && format.audioBitrate; }; break; default: fn = filter; } return formats.filter(fn); }
...
Once you have received metadata from a video with the `getInfo` function,
you may pass that `info`, along with other `options` to `downloadFromInfo`.
### ytdl.chooseFormat(formats, options)
Can be used if you'd like to choose a format yourself with the [options above](#ytdlurl-options).
### ytdl.filterFormats(formats, filter)
If you'd like to work with only some formats, you can use the [`filter` option above](#ytdlurl-options).
## Limitations
ytdl cannot download videos that fall into the following
* Regionally restricted (requires a [proxy](example/proxy.js))
...
fromHumanTime = function (time) { if (typeof time === 'number') { return time; } if (numberFormat.test(time)) { return +time; } var firstFormat = timeFormat.exec(time); if (firstFormat) { return +(firstFormat[1] || 0) * timeUnits.h + +(firstFormat[2] || 0) * timeUnits.m + +(firstFormat[3] || 0) * timeUnits.s + +(firstFormat[4] || 0); } else { var total = 0; var r = /(\d+)(ms|s|m|h)/g; var rs; while ((rs = r.exec(time)) != null) { total += +rs[1] * timeUnits[rs[2]]; } return total; } }
...
});
req.on('error', stream.emit.bind(stream, 'error'));
stream.destroy = req.end.bind(req);
req.pipe(stream);
} else {
if (options.begin) {
url += '&begin=' + util.fromHumanTime(options.begin);
}
doDownload(stream, url, options, {
trys: options.retries || 5,
range: {
start: options.range && options.range.start ? options.range.start : 0,
end: options.range && options.range.end ? options.range.end : -1,
},
...
getAuthor = function (body) { var ownerinfo = exports.between(body, '<div id="watch7-user-header" class=" spf-link ">', '<div id="watch8-action-buttons" class ="watch-action-buttons clearfix">'); if (ownerinfo === '') { return {}; } ownerinfo = new Entities().decode(ownerinfo); var channelMatch = ownerinfo.match(authorRegexp); var userMatch = ownerinfo.match(aliasRegExp); return { id: channelMatch[1], name: channelMatch[2], avatar: url.resolve(VIDEO_URL, exports.between(ownerinfo, 'data-thumb="', '"')), user: userMatch ? userMatch[1] : null, channel_url: 'https://www.youtube.com/channel/' + channelMatch[1], user_url: userMatch ? 'https://www.youtube.com/user/' + userMatch[1] : null, }; }
...
'<h1 id="unavailable-message" class="message">', '</h1>').trim()));
}
}
// Parse out some additional informations since we already load that page.
var additional = {
// Get informations about the author/uploader.
author: util.getAuthor(body),
// Get the day the vid was published.
published: util.getPublished(body),
// Get description from #eow-description.
description: util.getVideoDescription(body),
...
getPublished = function (body) { return Date.parse(exports.between(body, '<meta itemprop="datePublished" content="', '">')); }
...
// Parse out some additional informations since we already load that page.
var additional = {
// Get informations about the author/uploader.
author: util.getAuthor(body),
// Get the day the vid was published.
published: util.getPublished(body),
// Get description from #eow-description.
description: util.getVideoDescription(body),
// Get related videos.
related_videos: util.getRelatedVideos(body),
...
getRelatedVideos = function (body) { var jsonStr = exports.between(body, '\'RELATED_PLAYER_ARGS\': {"rvs":', '},'); try { jsonStr = JSON.parse(jsonStr); } catch (err) { return []; } return jsonStr.split(',').map(function(link) { return qs.parse(link); }); }
...
// Get the day the vid was published.
published: util.getPublished(body),
// Get description from #eow-description.
description: util.getVideoDescription(body),
// Get related videos.
related_videos: util.getRelatedVideos(body),
// Give the canonical link to the video.
video_url: url,
// Thumbnails.
iurlsd : THUMBNAIL_URL + id + '/sddefault.jpg',
iurlmq : THUMBNAIL_URL + id + '/mqdefault.jpg',
...
getVideoDescription = function (html) { var regex = /<p.*?id="eow-description".*?>(.+?)<\/p>[\n\r\s]*?<\/div>/im; var description = html.match(regex); return description ? new Entities().decode(description[1] .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() : '' ; }
...
// Get informations about the author/uploader.
author: util.getAuthor(body),
// Get the day the vid was published.
published: util.getPublished(body),
// Get description from #eow-description.
description: util.getVideoDescription(body),
// Get related videos.
related_videos: util.getRelatedVideos(body),
// Give the canonical link to the video.
video_url: url,
...
getVideoID = function (link) { if (idRegex.test(link)) { return link; } var parsed = url.parse(link, true); var id = parsed.query.v; if (parsed.hostname === 'youtu.be' || (parsed.hostname === 'youtube.com' || parsed.hostname === 'www.youtube.com') && !id) { var s = parsed.pathname.split('/'); id = s[s.length - 1]; } if (!id) { return new Error('No video id found: ' + link); } if (!idRegex.test(id)) { return new Error('Video id (' + id + ') does not match expected format (' + idRegex.toString() + ')'); } return id; }
...
});
}
if (options.request) {
console.warn('`options.request` is deprecated, please use `options.requestOptions.transform`');
}
var id = util.getVideoID(link);
if (id instanceof Error) return callback(id);
// Try getting config from the video page first.
var url = VIDEO_URL + id;
request(url, options.requestOptions, function(err, res, body) {
if (err) return callback(err);
...
objectAssign = function (target, source, deep) { for (var key in source) { if (deep && typeof source[key] === 'object' && source[key] != null && target[key]) { exports.objectAssign(target[key], source[key]); } else { target[key] = source[key]; } } return target; }
...
info.fmt_list.map(function(format) {
return format.split('/');
}) : [];
info.formats = util.parseFormats(info);
// Add additional properties to info.
info = util.objectAssign(info, additional, false);
if (info.formats.some(function(f) { return !!f.s; }) ||
config.args.dashmpd || info.dashmpd || info.hlsvp) {
var html5playerfile = urllib.resolve(VIDEO_URL, config.assets.js);
sig.getTokens(html5playerfile, options, function(err, tokens) {
if (err) return callback(err);
...
parallel = function (funcs, callback) { var funcsDone = 0; var len = funcs.length; var errGiven = false; var results = []; function 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) { for (var i = 0; i < len; i++) { funcs[i](checkDone.bind(null, i)); } } else { callback(null, results); } }
...
}
if (info.hlsvp) {
info.hlsvp = decipherURL(info.hlsvp, tokens);
funcs.push(getM3U8.bind(null, info.hlsvp, options));
}
util.parallel(funcs, function(err, results) {
if (err) return callback(err);
if (results[0]) { mergeFormats(info, results[0]); }
if (results[1]) { mergeFormats(info, results[1]); }
if (results[2]) { mergeFormats(info, results[2]); }
if (!info.formats.length) {
callback(new Error('No formats found'));
return;
...
parseFormats = function (info) { var formats = []; if (info.url_encoded_fmt_stream_map) { formats = formats .concat(info.url_encoded_fmt_stream_map.split(',')); } if (info.adaptive_fmts) { formats = formats.concat(info.adaptive_fmts.split(',')); } formats = formats .map(function(format) { return qs.parse(format); }); delete info.url_encoded_fmt_stream_map; delete info.adaptive_fmts; return formats; }
...
});
info.fmt_list = info.fmt_list ?
info.fmt_list.map(function(format) {
return format.split('/');
}) : [];
info.formats = util.parseFormats(info);
// Add additional properties to info.
info = util.objectAssign(info, additional, false);
if (info.formats.some(function(f) { return !!f.s; }) ||
config.args.dashmpd || info.dashmpd || info.hlsvp) {
var html5playerfile = urllib.resolve(VIDEO_URL, config.assets.js);
...
parseTime = function (time) { var result = timeRegexp.exec(time.toString()); var hours = result[1] || 0; var mins = result[2] || 0; var secs = result[3] || 0; var ms = result[4] || 0; return hours * 3600000 + mins * 60000 + secs * 1000 + parseInt(ms, 10); }
n/a
sortFormats = function (a, b) { var ares = a.resolution ? parseInt(a.resolution.slice(0, -1), 10) : 0; var bres = b.resolution ? parseInt(b.resolution.slice(0, -1), 10) : 0; var afeats = ~~!!ares * 2 + ~~!!a.audioBitrate; var bfeats = ~~!!bres * 2 + ~~!!b.audioBitrate; function getBitrate(c) { if (c.bitrate) { var s = c.bitrate.split('-'); return parseFloat(s[s.length - 1], 10); } else { return 0; } } function audioScore(c) { var abitrate = c.audioBitrate || 0; var aenc = audioEncodingRanks[c.audioEncoding] || 0; return abitrate + aenc / 10; } if (afeats === bfeats) { if (ares === bres) { var avbitrate = getBitrate(a); var bvbitrate = getBitrate(b); if (avbitrate === bvbitrate) { var aascore = audioScore(a); var bascore = audioScore(b); if (aascore === bascore) { var avenc = videoEncodingRanks[a.encoding] || 0; var bvenc = videoEncodingRanks[b.encoding] || 0; return bvenc - avenc; } else { return bascore - aascore; } } else { return bvbitrate - avbitrate; } } else { return bres - ares; } } else { return bfeats - afeats; } }
n/a