1 | const urllib = require('url');
|
2 | const querystring = require('querystring');
|
3 | const sax = require('sax');
|
4 | const miniget = require('miniget');
|
5 | const util = require('./util');
|
6 | const extras = require('./info-extras');
|
7 | const sig = require('./sig');
|
8 | const Cache = require('./cache');
|
9 |
|
10 |
|
11 | const VIDEO_URL = 'https://www.youtube.com/watch?v=';
|
12 | const EMBED_URL = 'https://www.youtube.com/embed/';
|
13 | const VIDEO_EURL = 'https://youtube.googleapis.com/v/';
|
14 | const INFO_HOST = 'www.youtube.com';
|
15 | const INFO_PATH = '/get_video_info';
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | exports.getBasicInfo = async (id, options) => {
|
26 |
|
27 | const params = 'hl=' + (options.lang || 'en');
|
28 | let url = VIDEO_URL + id + '&' + params +
|
29 | '&bpctr=' + Math.ceil(Date.now() / 1000);
|
30 |
|
31 |
|
32 |
|
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 |
|
41 | const unavailableMsg = util.between(body, '<div id="player-unavailable"', '>');
|
42 | if (unavailableMsg &&
|
43 | !/\bhid\b/.test(util.between(unavailableMsg, 'class="', '"'))) {
|
44 |
|
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 |
|
52 | const additional = {
|
53 |
|
54 | author: extras.getAuthor(body),
|
55 |
|
56 |
|
57 | published: extras.getPublished(body),
|
58 |
|
59 |
|
60 | description: extras.getVideoDescription(body),
|
61 |
|
62 |
|
63 | media: extras.getVideoMedia(body),
|
64 |
|
65 |
|
66 | related_videos: extras.getRelatedVideos(body),
|
67 |
|
68 |
|
69 | likes: extras.getLikes(body),
|
70 |
|
71 |
|
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 |
|
83 |
|
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 |
|
94 |
|
95 |
|
96 | const 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 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 | const 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 |
|
160 | Object.assign(info, additional, {
|
161 | video_id: id,
|
162 |
|
163 |
|
164 | video_url: VIDEO_URL + id,
|
165 |
|
166 |
|
167 |
|
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 |
|
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 | exports.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 |
|
223 |
|
224 |
|
225 |
|
226 |
|
227 | const 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 |
|
237 |
|
238 |
|
239 |
|
240 |
|
241 |
|
242 | const 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 |
|
263 |
|
264 |
|
265 |
|
266 |
|
267 |
|
268 | const 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 |
|
284 | exports.cache = new Cache();
|
285 |
|
286 |
|
287 |
|
288 |
|
289 | for (let fnName of ['getBasicInfo', 'getFullInfo']) {
|
290 | |
291 |
|
292 |
|
293 |
|
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 |
|
323 | exports.validateID = util.validateID;
|
324 | exports.validateURL = util.validateURL;
|
325 | exports.getURLVideoID = util.getURLVideoID;
|
326 | exports.getVideoID = util.getVideoID;
|