@oreohq/ytdl-core 4.15.1

Sign up to get free protection for your applications and to get access to all the features.
package/lib/info.js ADDED
@@ -0,0 +1,580 @@
1
+ /* eslint-disable no-unused-vars */
2
+ const sax = require("sax");
3
+ const utils = require("./utils");
4
+ // Forces Node JS version of setTimeout for Electron based applications
5
+ const { setTimeout } = require("timers");
6
+ const formatUtils = require("./format-utils");
7
+ const urlUtils = require("./url-utils");
8
+ const extras = require("./info-extras");
9
+ const Cache = require("./cache");
10
+ const sig = require("./sig");
11
+
12
+ const BASE_URL = "https://www.youtube.com/watch?v=";
13
+
14
+ // Cached for storing basic/full info.
15
+ exports.cache = new Cache();
16
+ exports.watchPageCache = new Cache();
17
+
18
+ // List of URLs that show up in `notice_url` for age restricted videos.
19
+ const AGE_RESTRICTED_URLS = ["support.google.com/youtube/?p=age_restrictions", "youtube.com/t/community_guidelines"];
20
+
21
+ /**
22
+ * Gets info from a video without getting additional formats.
23
+ *
24
+ * @param {string} id
25
+ * @param {Object} options
26
+ * @returns {Promise<Object>}
27
+ */
28
+ exports.getBasicInfo = async (id, options) => {
29
+ utils.applyIPv6Rotations(options);
30
+ utils.applyDefaultHeaders(options);
31
+ utils.applyDefaultAgent(options);
32
+ utils.applyOldLocalAddress(options);
33
+ const retryOptions = Object.assign({}, options.requestOptions);
34
+ const { jar, dispatcher } = options.agent;
35
+ utils.setPropInsensitive(
36
+ options.requestOptions.headers,
37
+ "cookie",
38
+ jar.getCookieStringSync("https://www.youtube.com"),
39
+ );
40
+ options.requestOptions.dispatcher = dispatcher;
41
+ const info = await retryFunc(getWatchHTMLPage, [id, options], retryOptions);
42
+
43
+ const playErr = utils.playError(info.player_response);
44
+ if (playErr) throw playErr;
45
+
46
+ Object.assign(info, {
47
+ // Replace with formats from iosPlayerResponse
48
+ // formats: parseFormats(info.player_response),
49
+ related_videos: extras.getRelatedVideos(info),
50
+ });
51
+
52
+ // Add additional properties to info.
53
+ const media = extras.getMedia(info);
54
+ const additional = {
55
+ author: extras.getAuthor(info),
56
+ media,
57
+ likes: extras.getLikes(info),
58
+ age_restricted: !!(
59
+ media && AGE_RESTRICTED_URLS.some(url => Object.values(media).some(v => typeof v === "string" && v.includes(url)))
60
+ ),
61
+
62
+ // Give the standard link to the video.
63
+ video_url: BASE_URL + id,
64
+ storyboards: extras.getStoryboards(info),
65
+ chapters: extras.getChapters(info),
66
+ };
67
+
68
+ info.videoDetails = extras.cleanVideoDetails(
69
+ Object.assign(
70
+ {},
71
+ info.player_response &&
72
+ info.player_response.microformat &&
73
+ info.player_response.microformat.playerMicroformatRenderer,
74
+ info.player_response && info.player_response.videoDetails,
75
+ additional,
76
+ ),
77
+ info,
78
+ );
79
+
80
+ return info;
81
+ };
82
+
83
+ const getWatchHTMLURL = (id, options) =>
84
+ `${BASE_URL + id}&hl=${options.lang || "en"}&bpctr=${Math.ceil(Date.now() / 1000)}&has_verified=1`;
85
+ const getWatchHTMLPageBody = (id, options) => {
86
+ const url = getWatchHTMLURL(id, options);
87
+ return exports.watchPageCache.getOrSet(url, () => utils.request(url, options));
88
+ };
89
+
90
+ const EMBED_URL = "https://www.youtube.com/embed/";
91
+ const getEmbedPageBody = (id, options) => {
92
+ const embedUrl = `${EMBED_URL + id}?hl=${options.lang || "en"}`;
93
+ return utils.request(embedUrl, options);
94
+ };
95
+
96
+ const getHTML5player = body => {
97
+ let html5playerRes =
98
+ /<script\s+src="([^"]+)"(?:\s+type="text\/javascript")?\s+name="player_ias\/base"\s*>|"jsUrl":"([^"]+)"/.exec(body);
99
+ return html5playerRes ? html5playerRes[1] || html5playerRes[2] : null;
100
+ };
101
+
102
+ /**
103
+ * Given a function, calls it with `args` until it's successful,
104
+ * or until it encounters an unrecoverable error.
105
+ * Currently, any error from miniget is considered unrecoverable. Errors such as
106
+ * too many redirects, invalid URL, status code 404, status code 502.
107
+ *
108
+ * @param {Function} func
109
+ * @param {Array.<Object>} args
110
+ * @param {Object} options
111
+ * @param {number} options.maxRetries
112
+ * @param {Object} options.backoff
113
+ * @param {number} options.backoff.inc
114
+ */
115
+ const retryFunc = async (func, args, options) => {
116
+ let currentTry = 0,
117
+ result;
118
+ if (!options.maxRetries) options.maxRetries = 3;
119
+ if (!options.backoff) options.backoff = { inc: 500, max: 5000 };
120
+ while (currentTry <= options.maxRetries) {
121
+ try {
122
+ result = await func(...args);
123
+ break;
124
+ } catch (err) {
125
+ if ((err && err.statusCode < 500) || currentTry >= options.maxRetries) throw err;
126
+ let wait = Math.min(++currentTry * options.backoff.inc, options.backoff.max);
127
+ await new Promise(resolve => setTimeout(resolve, wait));
128
+ }
129
+ }
130
+ return result;
131
+ };
132
+
133
+ const jsonClosingChars = /^[)\]}'\s]+/;
134
+ const parseJSON = (source, varName, json) => {
135
+ if (!json || typeof json === "object") {
136
+ return json;
137
+ } else {
138
+ try {
139
+ json = json.replace(jsonClosingChars, "");
140
+ return JSON.parse(json);
141
+ } catch (err) {
142
+ throw Error(`Error parsing ${varName} in ${source}: ${err.message}`);
143
+ }
144
+ }
145
+ };
146
+
147
+ const findJSON = (source, varName, body, left, right, prependJSON) => {
148
+ let jsonStr = utils.between(body, left, right);
149
+ if (!jsonStr) {
150
+ throw Error(`Could not find ${varName} in ${source}`);
151
+ }
152
+ return parseJSON(source, varName, utils.cutAfterJS(`${prependJSON}${jsonStr}`));
153
+ };
154
+
155
+ const findPlayerResponse = (source, info) => {
156
+ const player_response =
157
+ info &&
158
+ ((info.args && info.args.player_response) ||
159
+ info.player_response ||
160
+ info.playerResponse ||
161
+ info.embedded_player_response);
162
+ return parseJSON(source, "player_response", player_response);
163
+ };
164
+
165
+ const getWatchHTMLPage = async (id, options) => {
166
+ let body = await getWatchHTMLPageBody(id, options);
167
+ let info = { page: "watch" };
168
+ try {
169
+ try {
170
+ info.player_response =
171
+ utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", "}};", "", "}}") ||
172
+ utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", ";var") ||
173
+ utils.tryParseBetween(body, "var ytInitialPlayerResponse = ", ";</script>") ||
174
+ findJSON("watch.html", "player_response", body, /\bytInitialPlayerResponse\s*=\s*\{/i, "</script>", "{");
175
+ } catch (_e) {
176
+ let args = findJSON("watch.html", "player_response", body, /\bytplayer\.config\s*=\s*{/, "</script>", "{");
177
+ info.player_response = findPlayerResponse("watch.html", args);
178
+ }
179
+
180
+ info.response =
181
+ utils.tryParseBetween(body, "var ytInitialData = ", "}};", "", "}}") ||
182
+ utils.tryParseBetween(body, "var ytInitialData = ", ";</script>") ||
183
+ utils.tryParseBetween(body, 'window["ytInitialData"] = ', "}};", "", "}}") ||
184
+ utils.tryParseBetween(body, 'window["ytInitialData"] = ', ";</script>") ||
185
+ findJSON("watch.html", "response", body, /\bytInitialData("\])?\s*=\s*\{/i, "</script>", "{");
186
+ info.html5player = getHTML5player(body);
187
+ } catch (_) {
188
+ throw Error(
189
+ "Error when parsing watch.html, maybe YouTube made a change.\n" +
190
+ `Please report this issue with the "${utils.saveDebugFile(
191
+ "watch.html",
192
+ body,
193
+ )}" file on https://github.com/distubejs/ytdl-core/issues.`,
194
+ );
195
+ }
196
+ return info;
197
+ };
198
+
199
+ /**
200
+ * @param {Object} player_response
201
+ * @returns {Array.<Object>}
202
+ */
203
+ const parseFormats = player_response => {
204
+ let formats = [];
205
+ if (player_response && player_response.streamingData) {
206
+ formats = formats
207
+ .concat(player_response.streamingData.formats || [])
208
+ .concat(player_response.streamingData.adaptiveFormats || []);
209
+ }
210
+ return formats;
211
+ };
212
+
213
+ const parseAdditionalManifests = (player_response, options) => {
214
+ let streamingData = player_response && player_response.streamingData,
215
+ manifests = [];
216
+ if (streamingData) {
217
+ if (streamingData.dashManifestUrl) {
218
+ manifests.push(getDashManifest(streamingData.dashManifestUrl, options));
219
+ }
220
+ if (streamingData.hlsManifestUrl) {
221
+ manifests.push(getM3U8(streamingData.hlsManifestUrl, options));
222
+ }
223
+ }
224
+ return manifests;
225
+ };
226
+
227
+ // TODO: Clean up this function for readability and support more clients
228
+ /**
229
+ * Gets info from a video additional formats and deciphered URLs.
230
+ *
231
+ * @param {string} id
232
+ * @param {Object} options
233
+ * @returns {Promise<Object>}
234
+ */
235
+ exports.getInfo = async (id, options) => {
236
+ utils.applyIPv6Rotations(options);
237
+ utils.applyDefaultHeaders(options);
238
+ utils.applyDefaultAgent(options);
239
+ utils.applyOldLocalAddress(options);
240
+ utils.applyPlayerClients(options);
241
+ const info = await exports.getBasicInfo(id, options);
242
+ let funcs = [];
243
+
244
+ // Fill in HTML5 player URL
245
+ info.html5player =
246
+ info.html5player ||
247
+ getHTML5player(await getWatchHTMLPageBody(id, options)) ||
248
+ getHTML5player(await getEmbedPageBody(id, options));
249
+
250
+ if (!info.html5player) {
251
+ throw Error("Unable to find html5player file");
252
+ }
253
+
254
+ const html5player = new URL(info.html5player, BASE_URL).toString();
255
+
256
+ try {
257
+ if (info.videoDetails.age_restricted) throw Error("Cannot download age restricted videos with mobile clients");
258
+ const promises = [];
259
+ if (options.playerClients.includes("WEB_CREATOR")) promises.push(fetchWebCreatorPlayer(id, html5player, options));
260
+ if (options.playerClients.includes("IOS")) promises.push(fetchIosJsonPlayer(id, options));
261
+ if (options.playerClients.includes("ANDROID")) promises.push(fetchAndroidJsonPlayer(id, options));
262
+ const responses = await Promise.allSettled(promises);
263
+ info.formats = [].concat(...responses.map(r => parseFormats(r.value)));
264
+ if (info.formats.length === 0) throw new Error("Player JSON API failed");
265
+
266
+ funcs.push(sig.decipherFormats(info.formats, html5player, options));
267
+
268
+ for (let resp of responses) {
269
+ if (resp.value) {
270
+ funcs.push(...parseAdditionalManifests(resp.value, options));
271
+ }
272
+ }
273
+
274
+ if (options.playerClients.includes("WEB")) {
275
+ funcs.push(sig.decipherFormats(parseFormats(info.player_response), html5player, options));
276
+ funcs.push(...parseAdditionalManifests(info.player_response));
277
+ }
278
+ } catch (_) {
279
+ funcs.push(sig.decipherFormats(parseFormats(info.player_response), html5player, options));
280
+ funcs.push(...parseAdditionalManifests(info.player_response));
281
+ }
282
+
283
+ let results = await Promise.all(funcs);
284
+ info.formats = Object.values(Object.assign({}, ...results));
285
+ info.formats = info.formats.map(formatUtils.addFormatMeta);
286
+ info.formats.sort(formatUtils.sortFormats);
287
+
288
+ info.full = true;
289
+ return info;
290
+ };
291
+
292
+ const getPlaybackContext = async (html5player, options) => {
293
+ const body = await utils.request(html5player, options);
294
+ let mo = body.match(/signatureTimestamp:(\d+)/);
295
+
296
+ return {
297
+ contentPlaybackContext: {
298
+ html5Preference: "HTML5_PREF_WANTS",
299
+ signatureTimestamp: mo ? mo[1] : undefined,
300
+ },
301
+ };
302
+ };
303
+
304
+ const LOCALE = { hl: "en", timeZone: "UTC", utcOffsetMinutes: 0 },
305
+ CHECK_FLAGS = { contentCheckOk: true, racyCheckOk: true };
306
+
307
+ const WEB_CREATOR_CONTEXT = {
308
+ client: {
309
+ clientName: "WEB_CREATOR",
310
+ clientVersion: "1.20241023.00.01",
311
+ ...LOCALE,
312
+ },
313
+ };
314
+
315
+ const fetchWebCreatorPlayer = async (videoId, html5player, options) => {
316
+ const payload = {
317
+ context: WEB_CREATOR_CONTEXT,
318
+ videoId,
319
+ playbackContext: await getPlaybackContext(html5player, options),
320
+ ...CHECK_FLAGS,
321
+ };
322
+
323
+ return await playerAPI(videoId, payload, undefined, options);
324
+ };
325
+
326
+ const playerAPI = async (videoId, payload, userAgent, options) => {
327
+ const { jar, dispatcher } = options.agent;
328
+ const opts = {
329
+ requestOptions: {
330
+ method: "POST",
331
+ dispatcher,
332
+ query: {
333
+ prettyPrint: false,
334
+ t: utils.generateClientPlaybackNonce(12),
335
+ id: videoId,
336
+ },
337
+ headers: {
338
+ "Content-Type": "application/json",
339
+ cookie: jar.getCookieStringSync("https://www.youtube.com"),
340
+ "User-Agent": userAgent,
341
+ "X-Goog-Api-Format-Version": "2",
342
+ },
343
+ body: JSON.stringify(payload),
344
+ },
345
+ };
346
+ const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts);
347
+ const playErr = utils.playError(response);
348
+ if (playErr) throw playErr;
349
+ if (!response.videoDetails || videoId !== response.videoDetails.videoId) {
350
+ const err = new Error("Malformed response from YouTube");
351
+ err.response = response;
352
+ throw err;
353
+ }
354
+ return response;
355
+ };
356
+
357
+ const IOS_CLIENT_VERSION = "19.42.1",
358
+ IOS_DEVICE_MODEL = "iPhone16,2",
359
+ IOS_USER_AGENT_VERSION = "17_5_1",
360
+ IOS_OS_VERSION = "17.5.1.21F90";
361
+
362
+ const fetchIosJsonPlayer = async (videoId, options) => {
363
+ const payload = {
364
+ videoId,
365
+ cpn: utils.generateClientPlaybackNonce(16),
366
+ contentCheckOk: true,
367
+ racyCheckOk: true,
368
+ context: {
369
+ client: {
370
+ clientName: "IOS",
371
+ clientVersion: IOS_CLIENT_VERSION,
372
+ deviceMake: "Apple",
373
+ deviceModel: IOS_DEVICE_MODEL,
374
+ platform: "MOBILE",
375
+ osName: "iOS",
376
+ osVersion: IOS_OS_VERSION,
377
+ hl: "en",
378
+ gl: "US",
379
+ utcOffsetMinutes: -240,
380
+ },
381
+ request: {
382
+ internalExperimentFlags: [],
383
+ useSsl: true,
384
+ },
385
+ user: {
386
+ lockedSafetyMode: false,
387
+ },
388
+ },
389
+ };
390
+
391
+ const { jar, dispatcher } = options.agent;
392
+ const opts = {
393
+ requestOptions: {
394
+ method: "POST",
395
+ dispatcher,
396
+ query: {
397
+ prettyPrint: false,
398
+ t: utils.generateClientPlaybackNonce(12),
399
+ id: videoId,
400
+ },
401
+ headers: {
402
+ "Content-Type": "application/json",
403
+ cookie: jar.getCookieStringSync("https://www.youtube.com"),
404
+ "User-Agent": `com.google.ios.youtube/${IOS_CLIENT_VERSION}(${
405
+ IOS_DEVICE_MODEL
406
+ }; U; CPU iOS ${IOS_USER_AGENT_VERSION} like Mac OS X; en_US)`,
407
+ "X-Goog-Api-Format-Version": "2",
408
+ },
409
+ body: JSON.stringify(payload),
410
+ },
411
+ };
412
+ const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts);
413
+ const playErr = utils.playError(response);
414
+ if (playErr) throw playErr;
415
+ if (!response.videoDetails || videoId !== response.videoDetails.videoId) {
416
+ const err = new Error("Malformed response from YouTube");
417
+ err.response = response;
418
+ throw err;
419
+ }
420
+ return response;
421
+ };
422
+
423
+ const ANDROID_CLIENT_VERSION = "19.30.36",
424
+ ANDROID_OS_VERSION = "14",
425
+ ANDROID_SDK_VERSION = "34";
426
+
427
+ const fetchAndroidJsonPlayer = async (videoId, options) => {
428
+ const payload = {
429
+ videoId,
430
+ cpn: utils.generateClientPlaybackNonce(16),
431
+ contentCheckOk: true,
432
+ racyCheckOk: true,
433
+ context: {
434
+ client: {
435
+ clientName: "ANDROID",
436
+ clientVersion: ANDROID_CLIENT_VERSION,
437
+ platform: "MOBILE",
438
+ osName: "Android",
439
+ osVersion: ANDROID_OS_VERSION,
440
+ androidSdkVersion: ANDROID_SDK_VERSION,
441
+ hl: "en",
442
+ gl: "US",
443
+ utcOffsetMinutes: -240,
444
+ },
445
+ request: {
446
+ internalExperimentFlags: [],
447
+ useSsl: true,
448
+ },
449
+ user: {
450
+ lockedSafetyMode: false,
451
+ },
452
+ },
453
+ };
454
+
455
+ const { jar, dispatcher } = options.agent;
456
+ const opts = {
457
+ requestOptions: {
458
+ method: "POST",
459
+ dispatcher,
460
+ query: {
461
+ prettyPrint: false,
462
+ t: utils.generateClientPlaybackNonce(12),
463
+ id: videoId,
464
+ },
465
+ headers: {
466
+ "Content-Type": "application/json",
467
+ cookie: jar.getCookieStringSync("https://www.youtube.com"),
468
+ "User-Agent": `com.google.android.youtube/${
469
+ ANDROID_CLIENT_VERSION
470
+ } (Linux; U; Android ${ANDROID_OS_VERSION}; en_US) gzip`,
471
+ "X-Goog-Api-Format-Version": "2",
472
+ },
473
+ body: JSON.stringify(payload),
474
+ },
475
+ };
476
+ const response = await utils.request("https://youtubei.googleapis.com/youtubei/v1/player", opts);
477
+ const playErr = utils.playError(response);
478
+ if (playErr) throw playErr;
479
+ if (!response.videoDetails || videoId !== response.videoDetails.videoId) {
480
+ const err = new Error("Malformed response from YouTube");
481
+ err.response = response;
482
+ throw err;
483
+ }
484
+ return response;
485
+ };
486
+
487
+ /**
488
+ * Gets additional DASH formats.
489
+ *
490
+ * @param {string} url
491
+ * @param {Object} options
492
+ * @returns {Promise<Array.<Object>>}
493
+ */
494
+ const getDashManifest = (url, options) =>
495
+ new Promise((resolve, reject) => {
496
+ let formats = {};
497
+ const parser = sax.parser(false);
498
+ parser.onerror = reject;
499
+ let adaptationSet;
500
+ parser.onopentag = node => {
501
+ if (node.name === "ADAPTATIONSET") {
502
+ adaptationSet = node.attributes;
503
+ } else if (node.name === "REPRESENTATION") {
504
+ const itag = parseInt(node.attributes.ID);
505
+ if (!isNaN(itag)) {
506
+ formats[url] = Object.assign(
507
+ {
508
+ itag,
509
+ url,
510
+ bitrate: parseInt(node.attributes.BANDWIDTH),
511
+ mimeType: `${adaptationSet.MIMETYPE}; codecs="${node.attributes.CODECS}"`,
512
+ },
513
+ node.attributes.HEIGHT
514
+ ? {
515
+ width: parseInt(node.attributes.WIDTH),
516
+ height: parseInt(node.attributes.HEIGHT),
517
+ fps: parseInt(node.attributes.FRAMERATE),
518
+ }
519
+ : {
520
+ audioSampleRate: node.attributes.AUDIOSAMPLINGRATE,
521
+ },
522
+ );
523
+ }
524
+ }
525
+ };
526
+ parser.onend = () => {
527
+ resolve(formats);
528
+ };
529
+ utils
530
+ .request(new URL(url, BASE_URL).toString(), options)
531
+ .then(res => {
532
+ parser.write(res);
533
+ parser.close();
534
+ })
535
+ .catch(reject);
536
+ });
537
+
538
+ /**
539
+ * Gets additional formats.
540
+ *
541
+ * @param {string} url
542
+ * @param {Object} options
543
+ * @returns {Promise<Array.<Object>>}
544
+ */
545
+ const getM3U8 = async (url, options) => {
546
+ url = new URL(url, BASE_URL);
547
+ const body = await utils.request(url.toString(), options);
548
+ let formats = {};
549
+ body
550
+ .split("\n")
551
+ .filter(line => /^https?:\/\//.test(line))
552
+ .forEach(line => {
553
+ const itag = parseInt(line.match(/\/itag\/(\d+)\//)[1]);
554
+ formats[line] = { itag, url: line };
555
+ });
556
+ return formats;
557
+ };
558
+
559
+ // Cache get info functions.
560
+ // In case a user wants to get a video's info before downloading.
561
+ for (let funcName of ["getBasicInfo", "getInfo"]) {
562
+ /**
563
+ * @param {string} link
564
+ * @param {Object} options
565
+ * @returns {Promise<Object>}
566
+ */
567
+ const func = exports[funcName];
568
+ exports[funcName] = async (link, options = {}) => {
569
+ utils.checkForUpdates();
570
+ let id = await urlUtils.getVideoID(link);
571
+ const key = [funcName, id, options.lang].join("-");
572
+ return exports.cache.getOrSet(key, () => func(id, options));
573
+ };
574
+ }
575
+
576
+ // Export a few helpers.
577
+ exports.validateID = urlUtils.validateID;
578
+ exports.validateURL = urlUtils.validateURL;
579
+ exports.getURLVideoID = urlUtils.getURLVideoID;
580
+ exports.getVideoID = urlUtils.getVideoID;