UNPKG

9.6 kBJavaScriptView Raw
1const urllib = require('url');
2const querystring = require('querystring');
3const sax = require('sax');
4const miniget = require('miniget');
5const util = require('./util');
6const extras = require('./info-extras');
7const sig = require('./sig');
8const Cache = require('./cache');
9
10
11const VIDEO_URL = 'https://www.youtube.com/watch?v=';
12const EMBED_URL = 'https://www.youtube.com/embed/';
13const VIDEO_EURL = 'https://youtube.googleapis.com/v/';
14const INFO_HOST = 'www.youtube.com';
15const INFO_PATH = '/get_video_info';
16
17
18/**
19 * Gets info from a video without getting additional formats.
20 *
21 * @param {string} id
22 * @param {Object} options
23 * @return {Promise<Object>}
24 */
25exports.getBasicInfo = async (id, options) => {
26 // Try getting config from the video page first.
27 const params = 'hl=' + (options.lang || 'en');
28 let url = VIDEO_URL + id + '&' + params +
29 '&bpctr=' + Math.ceil(Date.now() / 1000);
30
31 // Remove header from watch page request.
32 // Otherwise, it'll use a different framework for rendering content.
33 const reqOptions = Object.assign({}, options.requestOptions);
34 reqOptions.headers = Object.assign({}, reqOptions.headers, {
35 'User-Agent': '',
36 });
37
38 let [, body] = await miniget.promise(url, reqOptions);
39
40 // Check if there are any errors with this video page.
41 const unavailableMsg = util.between(body, '<div id="player-unavailable"', '>');
42 if (unavailableMsg &&
43 !/\bhid\b/.test(util.between(unavailableMsg, 'class="', '"'))) {
44 // Ignore error about age restriction.
45 if (!body.includes('<div id="watch7-player-age-gate-content"')) {
46 throw Error(util.between(body,
47 '<h1 id="unavailable-message" class="message">', '</h1>').trim());
48 }
49 }
50
51 // Parse out additional metadata from this page.
52 const additional = {
53 // Get the author/uploader.
54 author: extras.getAuthor(body),
55
56 // Get the day the vid was published.
57 published: extras.getPublished(body),
58
59 // Get description.
60 description: extras.getVideoDescription(body),
61
62 // Get media info.
63 media: extras.getVideoMedia(body),
64
65 // Get related videos.
66 related_videos: extras.getRelatedVideos(body),
67
68 // Get likes.
69 likes: extras.getLikes(body),
70
71 // Get dislikes.
72 dislikes: extras.getDislikes(body),
73 };
74
75 const jsonStr = util.between(body, 'ytplayer.config = ', '</script>');
76 let config;
77 if (jsonStr) {
78 config = jsonStr.slice(0, jsonStr.lastIndexOf(';ytplayer.load'));
79 return await gotConfig(id, options, additional, config, false);
80
81 } else {
82 // If the video page doesn't work, maybe because it has mature content.
83 // and requires an account logged in to view, try the embed page.
84 url = EMBED_URL + id + '?' + params;
85 let [, body] = await miniget.promise(url, options.requestOptions);
86 config = util.between(body, 't.setConfig({\'PLAYER_CONFIG\': ', /\}(,'|\}\);)/);
87 return await gotConfig(id, options, additional, config, true);
88 }
89};
90
91
92/**
93 * @param {Object} info
94 * @return {Array.<Object>}
95 */
96const parseFormats = (info) => {
97 let formats = [];
98 if (info.player_response.streamingData) {
99 if (info.player_response.streamingData.formats) {
100 formats = formats.concat(info.player_response.streamingData.formats);
101 }
102 if (info.player_response.streamingData.adaptiveFormats) {
103 formats = formats.concat(info.player_response.streamingData.adaptiveFormats);
104 }
105 }
106 return formats;
107};
108
109
110/**
111 * @param {Object} id
112 * @param {Object} options
113 * @param {Object} additional
114 * @param {Object} config
115 * @param {boolean} fromEmbed
116 * @return {Promise<Object>}
117 */
118const gotConfig = async (id, options, additional, config, fromEmbed) => {
119 if (!config) {
120 throw Error('Could not find player config');
121 }
122 try {
123 config = JSON.parse(config + (fromEmbed ? '}' : ''));
124 } catch (err) {
125 throw Error('Error parsing config: ' + err.message);
126 }
127 const url = urllib.format({
128 protocol: 'https',
129 host: INFO_HOST,
130 pathname: INFO_PATH,
131 query: {
132 video_id: id,
133 eurl: VIDEO_EURL + id,
134 ps: 'default',
135 gl: 'US',
136 hl: (options.lang || 'en'),
137 sts: config.sts,
138 },
139 });
140 let [, body] = await miniget.promise(url, options.requestOptions);
141 let info = querystring.parse(body);
142 const player_response = config.args.player_response || info.player_response;
143
144 if (info.status === 'fail') {
145 throw Error(`Code ${info.errorcode}: ${util.stripHTML(info.reason)}`);
146 } else try {
147 info.player_response = JSON.parse(player_response);
148 } catch (err) {
149 throw Error('Error parsing `player_response`: ' + err.message);
150 }
151
152 let playability = info.player_response.playabilityStatus;
153 if (playability && playability.status === 'UNPLAYABLE') {
154 throw Error(util.stripHTML(playability.reason));
155 }
156
157 info.formats = parseFormats(info);
158
159 // Add additional properties to info.
160 Object.assign(info, additional, {
161 video_id: id,
162
163 // Give the standard link to the video.
164 video_url: VIDEO_URL + id,
165
166 // Copy over a few props from `player_response.videoDetails`
167 // for backwards compatibility.
168 title: info.player_response.videoDetails && info.player_response.videoDetails.title,
169 length_seconds: info.player_response.videoDetails && info.player_response.videoDetails.lengthSeconds,
170 });
171
172 info.age_restricted = fromEmbed;
173 info.html5player = config.assets.js;
174
175 return info;
176};
177
178
179/**
180 * Gets info from a video additional formats and deciphered URLs.
181 *
182 * @param {string} id
183 * @param {Object} options
184 * @return {Promise<Object>}
185 */
186exports.getFullInfo = async (id, options) => {
187 let info = await exports.getBasicInfo(id, options);
188 const hasManifest =
189 info.player_response && info.player_response.streamingData && (
190 info.player_response.streamingData.dashManifestUrl ||
191 info.player_response.streamingData.hlsManifestUrl
192 );
193 if (!info.formats.length && !hasManifest) {
194 throw Error('This video is unavailable');
195 }
196 const html5playerfile = urllib.resolve(VIDEO_URL, info.html5player);
197 let tokens = await sig.getTokens(html5playerfile, options);
198
199 sig.decipherFormats(info.formats, tokens, options.debug);
200 let funcs = [];
201 if (hasManifest && info.player_response.streamingData.dashManifestUrl) {
202 let url = info.player_response.streamingData.dashManifestUrl;
203 funcs.push(getDashManifest(url, options));
204 }
205 if (hasManifest && info.player_response.streamingData.hlsManifestUrl) {
206 let url = info.player_response.streamingData.hlsManifestUrl;
207 funcs.push(getM3U8(url, options));
208 }
209
210 let results = await Promise.all(funcs);
211 if (results[0]) { mergeFormats(info, results[0]); }
212 if (results[1]) { mergeFormats(info, results[1]); }
213
214 info.formats = info.formats.map(util.addFormatMeta);
215 info.formats.sort(util.sortFormats);
216 info.full = true;
217 return info;
218};
219
220
221/**
222 * Merges formats from DASH or M3U8 with formats from video info page.
223 *
224 * @param {Object} info
225 * @param {Object} formatsMap
226 */
227const mergeFormats = (info, formatsMap) => {
228 info.formats.forEach((f) => {
229 formatsMap[f.itag] = formatsMap[f.itag] || f;
230 });
231 info.formats = Object.values(formatsMap);
232};
233
234
235/**
236 * Gets additional DASH formats.
237 *
238 * @param {string} url
239 * @param {Object} options
240 * @return {Promise<Array.<Object>>}
241 */
242const getDashManifest = (url, options) => new Promise((resolve, reject) => {
243 let formats = {};
244 const parser = sax.parser(false);
245 parser.onerror = reject;
246 parser.onopentag = (node) => {
247 if (node.name === 'REPRESENTATION') {
248 const itag = node.attributes.ID;
249 formats[itag] = { itag, url };
250 }
251 };
252 parser.onend = () => { resolve(formats); };
253 const req = miniget(urllib.resolve(VIDEO_URL, url), options.requestOptions);
254 req.setEncoding('utf8');
255 req.on('error', reject);
256 req.on('data', (chunk) => { parser.write(chunk); });
257 req.on('end', parser.close.bind(parser));
258});
259
260
261/**
262 * Gets additional formats.
263 *
264 * @param {string} url
265 * @param {Object} options
266 * @return {Promise<Array.<Object>>}
267 */
268const getM3U8 = async (url, options) => {
269 url = urllib.resolve(VIDEO_URL, url);
270 let [, body] = await miniget.promise(url, options.requestOptions);
271 let formats = {};
272 body
273 .split('\n')
274 .filter((line) => /https?:\/\//.test(line))
275 .forEach((line) => {
276 const itag = line.match(/\/itag\/(\d+)\//)[1];
277 formats[itag] = { itag: itag, url: line };
278 });
279 return formats;
280};
281
282
283// Cached for getting basic/full info.
284exports.cache = new Cache();
285
286
287// Cache get info functions.
288// In case a user wants to get a video's info before downloading.
289for (let fnName of ['getBasicInfo', 'getFullInfo']) {
290 /**
291 * @param {string} link
292 * @param {Object} options
293 * @param {Function(Error, Object)} callback
294 */
295 const fn = exports[fnName];
296 exports[fnName] = async (link, options, callback) => {
297 if (typeof options === 'function') {
298 callback = options;
299 options = {};
300 } else if (!options) {
301 options = {};
302 }
303
304 if (callback) {
305 return exports[fnName](link, options)
306 .then(info => callback(null, info), callback);
307 }
308
309 let id = util.getVideoID(link);
310 const key = [fnName, id, options.lang].join('-');
311 if (exports.cache.get(key)) {
312 return exports.cache.get(key);
313 } else {
314 let info = await fn(id, options);
315 exports.cache.set(key, info);
316 return info;
317 }
318 };
319}
320
321
322// Export a few helpers.
323exports.validateID = util.validateID;
324exports.validateURL = util.validateURL;
325exports.getURLVideoID = util.getURLVideoID;
326exports.getVideoID = util.getVideoID;