UNPKG

8.59 kBJavaScriptView Raw
1const url = require('url');
2const FORMATS = require('./formats');
3
4
5// Use these to help sort formats, higher is better.
6const audioEncodingRanks = [
7 'mp4a',
8 'mp3',
9 'vorbis',
10 'aac',
11 'opus',
12 'flac',
13];
14const videoEncodingRanks = [
15 'mp4v',
16 'avc1',
17 'Sorenson H.283',
18 'MPEG-4 Visual',
19 'VP8',
20 'VP9',
21 'H.264',
22];
23
24const getBitrate = (format) => parseInt(format.bitrate) || 0;
25const audioScore = (format) => {
26 const abitrate = format.audioBitrate || 0;
27 const aenc = audioEncodingRanks.findIndex(enc => format.codecs && format.codecs.includes(enc));
28 return abitrate + aenc / 10;
29};
30
31
32/**
33 * Sort formats from highest quality to lowest.
34 * By resolution, then video bitrate, then audio bitrate.
35 *
36 * @param {Object} a
37 * @param {Object} b
38 */
39exports.sortFormats = (a, b) => {
40 const ares = a.qualityLabel ? parseInt(a.qualityLabel.slice(0, -1)) : 0;
41 const bres = b.qualityLabel ? parseInt(b.qualityLabel.slice(0, -1)) : 0;
42 const afeats = ~~!!ares * 2 + ~~!!a.audioBitrate;
43 const bfeats = ~~!!bres * 2 + ~~!!b.audioBitrate;
44
45 if (afeats === bfeats) {
46 if (ares === bres) {
47 let avbitrate = getBitrate(a);
48 let bvbitrate = getBitrate(b);
49 if (avbitrate === bvbitrate) {
50 let aascore = audioScore(a);
51 let bascore = audioScore(b);
52 if (aascore === bascore) {
53 const avenc = videoEncodingRanks.findIndex(enc => a.codecs && a.codecs.includes(enc));
54 const bvenc = videoEncodingRanks.findIndex(enc => b.codecs && b.codecs.includes(enc));
55 return bvenc - avenc;
56 } else {
57 return bascore - aascore;
58 }
59 } else {
60 return bvbitrate - avbitrate;
61 }
62 } else {
63 return bres - ares;
64 }
65 } else {
66 return bfeats - afeats;
67 }
68};
69
70
71/**
72 * Choose a format depending on the given options.
73 *
74 * @param {Array.<Object>} formats
75 * @param {Object} options
76 * @return {Object}
77 * @throws {Error} when no format matches the filter/format rules
78 */
79exports.chooseFormat = (formats, options) => {
80 if (typeof options.format === 'object') {
81 return options.format;
82 }
83
84 if (options.filter) {
85 formats = exports.filterFormats(formats, options.filter);
86 if (formats.length === 0) {
87 throw Error('No formats found with custom filter');
88 }
89 }
90
91 let format;
92 const quality = options.quality || 'highest';
93 switch (quality) {
94 case 'highest':
95 format = formats[0];
96 break;
97
98 case 'lowest':
99 format = formats[formats.length - 1];
100 break;
101
102 case 'highestaudio':
103 formats = exports.filterFormats(formats, 'audio');
104 format = null;
105 for (let f of formats) {
106 if (!format
107 || audioScore(f) > audioScore(format))
108 format = f;
109 }
110 break;
111
112 case 'lowestaudio':
113 formats = exports.filterFormats(formats, 'audio');
114 format = null;
115 for (let f of formats) {
116 if (!format
117 || audioScore(f) < audioScore(format))
118 format = f;
119 }
120 break;
121
122 case 'highestvideo':
123 formats = exports.filterFormats(formats, 'video');
124 format = null;
125 for (let f of formats) {
126 if (!format
127 || getBitrate(f) > getBitrate(format))
128 format = f;
129 }
130 break;
131
132 case 'lowestvideo':
133 formats = exports.filterFormats(formats, 'video');
134 format = null;
135 for (let f of formats) {
136 if (!format
137 || getBitrate(f) < getBitrate(format))
138 format = f;
139 }
140 break;
141
142 default: {
143 let getFormat = (itag) => {
144 return formats.find((format) => '' + format.itag === '' + itag);
145 };
146 if (Array.isArray(quality)) {
147 quality.find((q) => format = getFormat(q));
148 } else {
149 format = getFormat(quality);
150 }
151 }
152
153 }
154
155 if (!format) {
156 throw Error('No such format found: ' + quality);
157 }
158 return format;
159};
160
161
162/**
163 * @param {Array.<Object>} formats
164 * @param {Function} filter
165 * @return {Array.<Object>}
166 */
167exports.filterFormats = (formats, filter) => {
168 let fn;
169 const hasVideo = format => !!format.qualityLabel;
170 const hasAudio = format => !!format.audioBitrate;
171 switch (filter) {
172 case 'audioandvideo':
173 fn = (format) => hasVideo(format) && hasAudio(format);
174 break;
175
176 case 'video':
177 fn = hasVideo;
178 break;
179
180 case 'videoonly':
181 fn = (format) => hasVideo(format) && !hasAudio(format);
182 break;
183
184 case 'audio':
185 fn = hasAudio;
186 break;
187
188 case 'audioonly':
189 fn = (format) => !hasVideo(format) && hasAudio(format);
190 break;
191
192 default:
193 if (typeof filter === 'function') {
194 fn = filter;
195 } else {
196 throw TypeError(`Given filter (${filter}) is not supported`);
197 }
198 }
199 return formats.filter(fn);
200};
201
202
203/**
204 * String#indexOf() that supports regex too.
205 *
206 * @param {string} haystack
207 * @param {string|RegExp} needle
208 * @return {number}
209 */
210const indexOf = (haystack, needle) => {
211 return needle instanceof RegExp ?
212 haystack.search(needle) : haystack.indexOf(needle);
213};
214
215
216/**
217 * Extract string inbetween another.
218 *
219 * @param {string} haystack
220 * @param {string} left
221 * @param {string} right
222 * @return {string}
223 */
224exports.between = (haystack, left, right) => {
225 let pos = indexOf(haystack, left);
226 if (pos === -1) { return ''; }
227 haystack = haystack.slice(pos + left.length);
228 pos = indexOf(haystack, right);
229 if (pos === -1) { return ''; }
230 haystack = haystack.slice(0, pos);
231 return haystack;
232};
233
234
235/**
236 * Get video ID.
237 *
238 * There are a few type of video URL formats.
239 * - https://www.youtube.com/watch?v=VIDEO_ID
240 * - https://m.youtube.com/watch?v=VIDEO_ID
241 * - https://youtu.be/VIDEO_ID
242 * - https://www.youtube.com/v/VIDEO_ID
243 * - https://www.youtube.com/embed/VIDEO_ID
244 * - https://music.youtube.com/watch?v=VIDEO_ID
245 * - https://gaming.youtube.com/watch?v=VIDEO_ID
246 *
247 * @param {string} link
248 * @return {string}
249 * @throws {Error} If unable to find a id
250 * @throws {TypeError} If videoid doesn't match specs
251 */
252const validQueryDomains = new Set([
253 'youtube.com',
254 'www.youtube.com',
255 'm.youtube.com',
256 'music.youtube.com',
257 'gaming.youtube.com',
258]);
259const validPathDomains = /^https?:\/\/(youtu\.be\/|(www\.)?youtube.com\/(embed|v)\/)/;
260exports.getURLVideoID = (link) => {
261 const parsed = url.parse(link, true);
262 let id = parsed.query.v;
263 if (validPathDomains.test(link) && !id) {
264 const paths = parsed.pathname.split('/');
265 id = paths[paths.length - 1];
266 } else if (parsed.hostname && !validQueryDomains.has(parsed.hostname)) {
267 throw Error('Not a YouTube domain');
268 }
269 if (!id) {
270 throw Error('No video id found: ' + link);
271 }
272 id = id.substring(0, 11);
273 if (!exports.validateID(id)) {
274 throw TypeError(`Video id (${id}) does not match expected ` +
275 `format (${idRegex.toString()})`);
276 }
277 return id;
278};
279
280
281/**
282 * Gets video ID either from a url or by checking if the given string
283 * matches the video ID format.
284 *
285 * @param {string} str
286 * @return {string}
287 * @throws {Error} If unable to find a id
288 * @throws {TypeError} If videoid doesn't match specs
289 */
290exports.getVideoID = (str) => {
291 if (exports.validateID(str)) {
292 return str;
293 } else {
294 return exports.getURLVideoID(str);
295 }
296};
297
298
299/**
300 * Returns true if given id satifies YouTube's id format.
301 *
302 * @param {string} id
303 * @return {boolean}
304 */
305const idRegex = /^[a-zA-Z0-9-_]{11}$/;
306exports.validateID = (id) => {
307 return idRegex.test(id);
308};
309
310
311/**
312 * Checks wether the input string includes a valid id.
313 *
314 * @param {string} string
315 * @return {boolean}
316 */
317exports.validateURL = (string) => {
318 try {
319 exports.getURLVideoID(string);
320 return true;
321 } catch(e) {
322 return false;
323 }
324};
325
326
327/**
328 * @param {Object} format
329 * @return {Object}
330 */
331exports.addFormatMeta = (format) => {
332 format = Object.assign({}, FORMATS[format.itag], format);
333 format.container = format.mimeType ?
334 format.mimeType.split(';')[0].split('/')[1] : null;
335 format.codecs = format.mimeType ?
336 exports.between(format.mimeType, 'codecs="', '"') : null;
337 format.live = /\/source\/yt_live_broadcast\//.test(format.url);
338 format.isHLS = /\/manifest\/hls_(variant|playlist)\//.test(format.url);
339 format.isDashMPD = /\/manifest\/dash\//.test(format.url);
340 return format;
341};
342
343
344/**
345 * Get only the string from an HTML string.
346 *
347 * @param {string} html
348 * @return {string}
349 */
350exports.stripHTML = (html) => {
351 return html
352 .replace(/[\n\r]/g, ' ')
353 .replace(/\s*<\s*br\s*\/?\s*>\s*/gi, '\n')
354 .replace(/<\s*\/\s*p\s*>\s*<\s*p[^>]*>/gi, '\n')
355 .replace(/<.*?>/gi, '')
356 .trim();
357};