@oreohq/ytdl-core 4.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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;