ytgrab 0.1.0

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/bin/ytgrab.js +194 -0
  4. package/dist/downloader/common.d.ts +22 -0
  5. package/dist/downloader/common.d.ts.map +1 -0
  6. package/dist/downloader/common.js +125 -0
  7. package/dist/downloader/common.js.map +1 -0
  8. package/dist/downloader/hls.d.ts +11 -0
  9. package/dist/downloader/hls.d.ts.map +1 -0
  10. package/dist/downloader/hls.js +134 -0
  11. package/dist/downloader/hls.js.map +1 -0
  12. package/dist/downloader/http.d.ts +10 -0
  13. package/dist/downloader/http.d.ts.map +1 -0
  14. package/dist/downloader/http.js +132 -0
  15. package/dist/downloader/http.js.map +1 -0
  16. package/dist/downloader/index.d.ts +10 -0
  17. package/dist/downloader/index.d.ts.map +1 -0
  18. package/dist/downloader/index.js +24 -0
  19. package/dist/downloader/index.js.map +1 -0
  20. package/dist/extractor/common.d.ts +48 -0
  21. package/dist/extractor/common.d.ts.map +1 -0
  22. package/dist/extractor/common.js +324 -0
  23. package/dist/extractor/common.js.map +1 -0
  24. package/dist/extractor/nsig.d.ts +17 -0
  25. package/dist/extractor/nsig.d.ts.map +1 -0
  26. package/dist/extractor/nsig.js +200 -0
  27. package/dist/extractor/nsig.js.map +1 -0
  28. package/dist/extractor/youtube.d.ts +51 -0
  29. package/dist/extractor/youtube.d.ts.map +1 -0
  30. package/dist/extractor/youtube.js +1113 -0
  31. package/dist/extractor/youtube.js.map +1 -0
  32. package/dist/index.d.ts +36 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +72 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/networking/index.d.ts +33 -0
  37. package/dist/networking/index.d.ts.map +1 -0
  38. package/dist/networking/index.js +171 -0
  39. package/dist/networking/index.js.map +1 -0
  40. package/dist/postprocessor/common.d.ts +21 -0
  41. package/dist/postprocessor/common.d.ts.map +1 -0
  42. package/dist/postprocessor/common.js +42 -0
  43. package/dist/postprocessor/common.js.map +1 -0
  44. package/dist/postprocessor/ffmpeg.d.ts +44 -0
  45. package/dist/postprocessor/ffmpeg.d.ts.map +1 -0
  46. package/dist/postprocessor/ffmpeg.js +286 -0
  47. package/dist/postprocessor/ffmpeg.js.map +1 -0
  48. package/dist/types.d.ts +157 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +3 -0
  51. package/dist/types.js.map +1 -0
  52. package/dist/utils/index.d.ts +57 -0
  53. package/dist/utils/index.d.ts.map +1 -0
  54. package/dist/utils/index.js +403 -0
  55. package/dist/utils/index.js.map +1 -0
  56. package/dist/utils/traversal.d.ts +22 -0
  57. package/dist/utils/traversal.d.ts.map +1 -0
  58. package/dist/utils/traversal.js +112 -0
  59. package/dist/utils/traversal.js.map +1 -0
  60. package/dist/ytgrab.d.ts +48 -0
  61. package/dist/ytgrab.d.ts.map +1 -0
  62. package/dist/ytgrab.js +450 -0
  63. package/dist/ytgrab.js.map +1 -0
  64. package/package.json +45 -0
@@ -0,0 +1,1113 @@
1
+ "use strict";
2
+ /**
3
+ * YouTube extractor - ported from yt_dlp/extractor/youtube/
4
+ *
5
+ * Extracts video info, formats, subtitles from YouTube URLs
6
+ * using the InnerTube API.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.EXTRACTORS = exports.YoutubeSearchIE = exports.YoutubePlaylistIE = exports.YoutubeIE = void 0;
10
+ const common_js_1 = require("./common.js");
11
+ const index_js_1 = require("../networking/index.js");
12
+ const index_js_2 = require("../utils/index.js");
13
+ const nsig_js_1 = require("./nsig.js");
14
+ const index_js_3 = require("../networking/index.js");
15
+ const INNERTUBE_CLIENTS = {
16
+ web: {
17
+ INNERTUBE_CONTEXT: {
18
+ client: {
19
+ clientName: 'WEB',
20
+ clientVersion: '2.20260114.08.00',
21
+ },
22
+ },
23
+ INNERTUBE_CONTEXT_CLIENT_NAME: 1,
24
+ SUPPORTS_COOKIES: true,
25
+ },
26
+ web_safari: {
27
+ INNERTUBE_CONTEXT: {
28
+ client: {
29
+ clientName: 'WEB',
30
+ clientVersion: '2.20260114.08.00',
31
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15,gzip(gfe)',
32
+ },
33
+ },
34
+ INNERTUBE_CONTEXT_CLIENT_NAME: 1,
35
+ SUPPORTS_COOKIES: true,
36
+ },
37
+ web_embedded: {
38
+ INNERTUBE_CONTEXT: {
39
+ client: {
40
+ clientName: 'WEB_EMBEDDED_PLAYER',
41
+ clientVersion: '1.20260115.01.00',
42
+ },
43
+ },
44
+ INNERTUBE_CONTEXT_CLIENT_NAME: 56,
45
+ SUPPORTS_COOKIES: true,
46
+ },
47
+ android_vr: {
48
+ INNERTUBE_CONTEXT: {
49
+ client: {
50
+ clientName: 'ANDROID_VR',
51
+ clientVersion: '1.65.10',
52
+ deviceMake: 'Oculus',
53
+ deviceModel: 'Quest 3',
54
+ androidSdkVersion: 32,
55
+ userAgent: 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
56
+ osName: 'Android',
57
+ osVersion: '12L',
58
+ },
59
+ },
60
+ INNERTUBE_CONTEXT_CLIENT_NAME: 28,
61
+ REQUIRE_JS_PLAYER: false,
62
+ },
63
+ ios: {
64
+ INNERTUBE_CONTEXT: {
65
+ client: {
66
+ clientName: 'IOS',
67
+ clientVersion: '21.02.3',
68
+ deviceMake: 'Apple',
69
+ deviceModel: 'iPhone16,2',
70
+ userAgent: 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)',
71
+ osName: 'iPhone',
72
+ osVersion: '18.3.2.22D82',
73
+ },
74
+ },
75
+ INNERTUBE_CONTEXT_CLIENT_NAME: 5,
76
+ REQUIRE_JS_PLAYER: false,
77
+ },
78
+ mweb: {
79
+ INNERTUBE_CONTEXT: {
80
+ client: {
81
+ clientName: 'MWEB',
82
+ clientVersion: '2.20260115.01.00',
83
+ userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)',
84
+ },
85
+ },
86
+ INNERTUBE_CONTEXT_CLIENT_NAME: 2,
87
+ SUPPORTS_COOKIES: true,
88
+ },
89
+ tv: {
90
+ INNERTUBE_CONTEXT: {
91
+ client: {
92
+ clientName: 'TVHTML5',
93
+ clientVersion: '7.20260114.12.00',
94
+ userAgent: 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/25.lts.30.1034943-gold (unlike Gecko), Unknown_TV_Unknown_0/Unknown (Unknown, Unknown)',
95
+ },
96
+ },
97
+ INNERTUBE_CONTEXT_CLIENT_NAME: 7,
98
+ SUPPORTS_COOKIES: true,
99
+ },
100
+ };
101
+ const INNERTUBE_API_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
102
+ const INNERTUBE_HOST = 'www.youtube.com';
103
+ // YouTube itag quality map
104
+ const ITAG_QUALITIES = {
105
+ // Video+Audio
106
+ 18: { height: 360, ext: 'mp4' },
107
+ 22: { height: 720, ext: 'mp4' },
108
+ // Video only
109
+ 134: { height: 360, ext: 'mp4' },
110
+ 135: { height: 480, ext: 'mp4' },
111
+ 136: { height: 720, ext: 'mp4' },
112
+ 137: { height: 1080, ext: 'mp4' },
113
+ 160: { height: 144, ext: 'mp4' },
114
+ 298: { height: 720, fps: 60, ext: 'mp4' },
115
+ 299: { height: 1080, fps: 60, ext: 'mp4' },
116
+ 264: { height: 1440, ext: 'mp4' },
117
+ 266: { height: 2160, ext: 'mp4' },
118
+ // VP9 video only
119
+ 243: { height: 360, ext: 'webm' },
120
+ 244: { height: 480, ext: 'webm' },
121
+ 247: { height: 720, ext: 'webm' },
122
+ 248: { height: 1080, ext: 'webm' },
123
+ 271: { height: 1440, ext: 'webm' },
124
+ 313: { height: 2160, ext: 'webm' },
125
+ 302: { height: 720, fps: 60, ext: 'webm' },
126
+ 303: { height: 1080, fps: 60, ext: 'webm' },
127
+ 308: { height: 1440, fps: 60, ext: 'webm' },
128
+ 315: { height: 2160, fps: 60, ext: 'webm' },
129
+ // AV1 video only
130
+ 394: { height: 144, ext: 'mp4' },
131
+ 395: { height: 240, ext: 'mp4' },
132
+ 396: { height: 360, ext: 'mp4' },
133
+ 397: { height: 480, ext: 'mp4' },
134
+ 398: { height: 720, ext: 'mp4' },
135
+ 399: { height: 1080, ext: 'mp4' },
136
+ 400: { height: 1440, ext: 'mp4' },
137
+ 401: { height: 2160, ext: 'mp4' },
138
+ 571: { height: 4320, ext: 'mp4' },
139
+ // Audio only
140
+ 139: { abr: 48, acodec: 'mp4a.40.5', ext: 'm4a' },
141
+ 140: { abr: 128, acodec: 'mp4a.40.2', ext: 'm4a' },
142
+ 141: { abr: 256, acodec: 'mp4a.40.2', ext: 'm4a' },
143
+ 171: { abr: 128, acodec: 'vorbis', ext: 'webm' },
144
+ 172: { abr: 256, acodec: 'vorbis', ext: 'webm' },
145
+ 249: { abr: 50, acodec: 'opus', ext: 'webm' },
146
+ 250: { abr: 70, acodec: 'opus', ext: 'webm' },
147
+ 251: { abr: 160, acodec: 'opus', ext: 'webm' },
148
+ };
149
+ // Subtitle formats
150
+ const SUBTITLE_FORMATS = ['json3', 'srv1', 'srv2', 'srv3', 'ttml', 'srt', 'vtt'];
151
+ class YoutubeIE extends common_js_1.InfoExtractor {
152
+ IE_NAME = 'youtube';
153
+ _VALID_URL = /^(?:https?:\/\/)?(?:(?:www|m|music)\.)?(?:youtube\.com\/(?:watch\?.*?v=|embed\/|v\/|shorts\/|live\/)|youtu\.be\/)([0-9A-Za-z_-]{11})/;
154
+ _defaultClients = ['android_vr', 'web_safari'];
155
+ async _realExtract(url, match) {
156
+ const videoId = match[1];
157
+ // Step 1: Try to get initial data from webpage
158
+ let webpage = null;
159
+ let ytcfg = {};
160
+ let initialData = {};
161
+ let playerResponse = null;
162
+ try {
163
+ webpage = await this._downloadWebpage(`https://www.youtube.com/watch?v=${videoId}`, videoId, 'Downloading webpage');
164
+ ytcfg = this._extractYtcfg(webpage, videoId);
165
+ initialData = this._extractInitialData(webpage, videoId);
166
+ playerResponse = this._extractInitialPlayerResponse(webpage, videoId);
167
+ }
168
+ catch (err) {
169
+ this._warn(`Failed to download webpage: ${err.message}`, videoId);
170
+ }
171
+ // Extract player URL and signatureTimestamp for API calls
172
+ const earlyPlayerUrl = webpage ? (0, nsig_js_1.extractPlayerUrl)(webpage) : null;
173
+ let signatureTimestamp = null;
174
+ if (earlyPlayerUrl) {
175
+ try {
176
+ const playerResp = await (0, index_js_3.makeRequest)(earlyPlayerUrl, {
177
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' },
178
+ });
179
+ const playerJs = playerResp.text();
180
+ const stsMatch = playerJs.match(/(?:signatureTimestamp|sts)\s*:\s*(\d{5})/);
181
+ if (stsMatch) {
182
+ signatureTimestamp = parseInt(stsMatch[1]);
183
+ this._log(`Extracted signatureTimestamp: ${signatureTimestamp}`, videoId);
184
+ }
185
+ }
186
+ catch { /* ignore */ }
187
+ }
188
+ // Step 2: Fetch player response(s) via InnerTube API
189
+ // Track which client produced each response for correct UA headers
190
+ const playerResponses = [];
191
+ if (playerResponse) {
192
+ playerResponses.push({ response: playerResponse, clientName: 'web' });
193
+ }
194
+ for (const clientName of this._defaultClients) {
195
+ try {
196
+ const query = {
197
+ videoId,
198
+ contentCheckOk: true,
199
+ racyCheckOk: true,
200
+ playbackContext: {
201
+ contentPlaybackContext: {
202
+ html5Preference: 'HTML5_PREF_WANTS',
203
+ ...(signatureTimestamp ? { signatureTimestamp } : {}),
204
+ },
205
+ },
206
+ };
207
+ const response = await this._callApi('player', videoId, clientName, query);
208
+ if (response && typeof response === 'object') {
209
+ playerResponses.push({ response: response, clientName });
210
+ }
211
+ }
212
+ catch (err) {
213
+ this._warn(`${clientName} client failed: ${err.message}`, videoId);
214
+ }
215
+ }
216
+ if (playerResponses.length === 0) {
217
+ throw new index_js_2.ExtractorError(`Failed to extract player response for ${videoId}`);
218
+ }
219
+ // Step 3: Extract video details from best response
220
+ const responses = playerResponses.map(p => p.response);
221
+ const videoDetails = this._extractVideoDetails(responses, initialData);
222
+ const microformat = this._extractMicroformat(responses);
223
+ // Check playability
224
+ const playability = this._checkPlayability(responses, videoId);
225
+ if (playability.error) {
226
+ this._warn(playability.error, videoId);
227
+ }
228
+ // Step 4: Extract formats from all responses (with client-specific UA)
229
+ const allFormats = [];
230
+ const allSubtitles = {};
231
+ for (const { response: pr, clientName } of playerResponses) {
232
+ const { formats, subtitles } = this._extractFormats(pr, videoId, clientName);
233
+ allFormats.push(...formats);
234
+ this._mergeSubtitles(allSubtitles, subtitles);
235
+ }
236
+ // Extract subtitles/captions
237
+ for (const { response: pr } of playerResponses) {
238
+ const { subtitles, automaticCaptions } = this._extractCaptions(pr, videoId);
239
+ this._mergeSubtitles(allSubtitles, subtitles);
240
+ // Store auto-captions separately (will merge into result)
241
+ if (Object.keys(automaticCaptions).length > 0) {
242
+ videoDetails._automatic_captions = automaticCaptions;
243
+ }
244
+ }
245
+ // Deduplicate formats
246
+ const seenFormatIds = new Set();
247
+ const uniqueFormats = allFormats.filter(f => {
248
+ const key = `${f.format_id}-${f.height || 0}-${f.tbr || 0}-${f.url?.slice(0, 100)}`;
249
+ if (seenFormatIds.has(key))
250
+ return false;
251
+ seenFormatIds.add(key);
252
+ return true;
253
+ });
254
+ this._sortFormats(uniqueFormats);
255
+ // Step 4b: Solve n-parameter challenges for format URLs
256
+ const playerUrl = webpage ? (0, nsig_js_1.extractPlayerUrl)(webpage) : null;
257
+ if (playerUrl) {
258
+ this._log('Solving n-parameter challenges', videoId);
259
+ for (let i = 0; i < uniqueFormats.length; i++) {
260
+ const fmt = uniqueFormats[i];
261
+ if (fmt.url) {
262
+ try {
263
+ uniqueFormats[i].url = await (0, nsig_js_1.solveNChallenge)(fmt.url, playerUrl);
264
+ }
265
+ catch {
266
+ this._warn(`Failed to solve n-challenge for format ${fmt.format_id}`, videoId);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ else {
272
+ this._warn('Could not find player URL - downloads may be throttled or fail', videoId);
273
+ }
274
+ // Step 5: Extract thumbnails
275
+ const thumbnails = this._extractThumbnails(responses, videoId);
276
+ // Step 6: Extract chapters
277
+ const chapters = this._extractChapters(initialData, videoDetails.description || '');
278
+ // Step 7: Determine live status
279
+ const liveStatus = this._extractLiveStatus(videoDetails, responses);
280
+ // Build the result
281
+ const title = videoDetails.title || this._extractTitle(webpage, initialData);
282
+ const description = videoDetails.description || '';
283
+ const duration = videoDetails.duration;
284
+ const uploadDate = microformat.uploadDate;
285
+ const timestamp = microformat.timestamp;
286
+ const result = {
287
+ id: videoId,
288
+ title,
289
+ description,
290
+ upload_date: uploadDate ?? undefined,
291
+ timestamp: timestamp ?? undefined,
292
+ uploader: videoDetails.uploader ?? undefined,
293
+ uploader_id: videoDetails.uploaderId ?? undefined,
294
+ uploader_url: videoDetails.uploaderUrl ?? undefined,
295
+ channel: videoDetails.channel ?? undefined,
296
+ channel_id: videoDetails.channelId ?? undefined,
297
+ channel_url: videoDetails.channelId ? `https://www.youtube.com/channel/${videoDetails.channelId}` : undefined,
298
+ channel_follower_count: videoDetails.channelFollowerCount ?? undefined,
299
+ duration,
300
+ view_count: videoDetails.viewCount ?? undefined,
301
+ like_count: videoDetails.likeCount ?? undefined,
302
+ age_limit: videoDetails.ageLimit,
303
+ webpage_url: `https://www.youtube.com/watch?v=${videoId}`,
304
+ categories: videoDetails.categories,
305
+ tags: videoDetails.tags,
306
+ thumbnails,
307
+ subtitles: allSubtitles,
308
+ automatic_captions: videoDetails._automatic_captions || {},
309
+ formats: uniqueFormats,
310
+ chapters: chapters.length > 0 ? chapters : undefined,
311
+ live_status: liveStatus,
312
+ availability: videoDetails.availability,
313
+ };
314
+ return result;
315
+ }
316
+ // --- InnerTube API ---
317
+ async _callApi(endpoint, videoId, clientName, query) {
318
+ const client = INNERTUBE_CLIENTS[clientName];
319
+ if (!client) {
320
+ throw new index_js_2.ExtractorError(`Unknown client: ${clientName}`);
321
+ }
322
+ const context = {
323
+ client: {
324
+ ...client.INNERTUBE_CONTEXT.client,
325
+ hl: 'en',
326
+ gl: 'US',
327
+ },
328
+ };
329
+ const data = {
330
+ context,
331
+ ...query,
332
+ };
333
+ const host = client.INNERTUBE_HOST || INNERTUBE_HOST;
334
+ const url = `https://${host}/youtubei/v1/${endpoint}`;
335
+ const ua = client.INNERTUBE_CONTEXT.client.userAgent ||
336
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
337
+ const headers = {
338
+ 'Content-Type': 'application/json',
339
+ 'User-Agent': ua,
340
+ 'X-YouTube-Client-Name': String(client.INNERTUBE_CONTEXT_CLIENT_NAME),
341
+ 'X-YouTube-Client-Version': String(client.INNERTUBE_CONTEXT.client.clientVersion),
342
+ 'Origin': `https://${host}`,
343
+ 'Referer': `https://${host}/`,
344
+ };
345
+ this._log(`Fetching ${endpoint} via ${clientName} client`, videoId);
346
+ const resp = await (0, index_js_1.makeRequest)(url, {
347
+ method: 'POST',
348
+ headers,
349
+ data: JSON.stringify(data),
350
+ query: { key: INNERTUBE_API_KEY, prettyPrint: 'false' },
351
+ });
352
+ if (resp.status >= 400) {
353
+ throw new index_js_2.ExtractorError(`InnerTube API error (${clientName}): HTTP ${resp.status}`);
354
+ }
355
+ return resp.json();
356
+ }
357
+ // --- Extraction from webpage ---
358
+ _extractYtcfg(webpage, videoId) {
359
+ const match = webpage.match(/ytcfg\.set\s*\(\s*(\{[\s\S]*?\})\s*\)\s*;/);
360
+ if (!match)
361
+ return {};
362
+ try {
363
+ return JSON.parse((0, index_js_2.jsToJson)(match[1]));
364
+ }
365
+ catch {
366
+ return {};
367
+ }
368
+ }
369
+ _extractInitialData(webpage, videoId) {
370
+ const patterns = [
371
+ /var\s+ytInitialData\s*=\s*(\{[\s\S]*?\})\s*;/,
372
+ /window\["ytInitialData"\]\s*=\s*(\{[\s\S]*?\})\s*;/,
373
+ ];
374
+ for (const pattern of patterns) {
375
+ const match = webpage.match(pattern);
376
+ if (match) {
377
+ try {
378
+ return JSON.parse(match[1]);
379
+ }
380
+ catch {
381
+ continue;
382
+ }
383
+ }
384
+ }
385
+ return {};
386
+ }
387
+ _extractInitialPlayerResponse(webpage, videoId) {
388
+ const patterns = [
389
+ /var\s+ytInitialPlayerResponse\s*=\s*(\{[\s\S]*?\})\s*;/,
390
+ /window\["ytInitialPlayerResponse"\]\s*=\s*(\{[\s\S]*?\})\s*;/,
391
+ ];
392
+ for (const pattern of patterns) {
393
+ const match = webpage.match(pattern);
394
+ if (match) {
395
+ try {
396
+ return JSON.parse(match[1]);
397
+ }
398
+ catch {
399
+ continue;
400
+ }
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+ // --- Video details extraction ---
406
+ _extractVideoDetails(playerResponses, initialData) {
407
+ let title = '';
408
+ let description = '';
409
+ let duration;
410
+ let uploader = null;
411
+ let uploaderId = null;
412
+ let uploaderUrl = null;
413
+ let channel = null;
414
+ let channelId = null;
415
+ let channelFollowerCount = null;
416
+ let viewCount = null;
417
+ let likeCount = null;
418
+ let ageLimit = 0;
419
+ let categories = [];
420
+ let tags = [];
421
+ let availability = 'public';
422
+ for (const pr of playerResponses) {
423
+ const vd = pr.videoDetails;
424
+ if (!vd)
425
+ continue;
426
+ title = title || String(vd.title || '');
427
+ description = description || String(vd.shortDescription || '');
428
+ duration = duration || ((0, index_js_2.intOrNone)(vd.lengthSeconds) ?? undefined);
429
+ channelId = channelId || (0, index_js_2.strOrNone)(vd.channelId);
430
+ channel = channel || (0, index_js_2.strOrNone)(vd.author);
431
+ viewCount = viewCount || (0, index_js_2.intOrNone)(vd.viewCount);
432
+ if (Array.isArray(vd.keywords)) {
433
+ tags = vd.keywords;
434
+ }
435
+ if (vd.isLiveContent) {
436
+ // Live content detected
437
+ }
438
+ }
439
+ // Extract from microformat
440
+ for (const pr of playerResponses) {
441
+ const mf = (0, index_js_2.traverseObj)(pr, ['microformat', 'playerMicroformatRenderer']);
442
+ if (!mf)
443
+ continue;
444
+ title = title || String(mf.title && typeof mf.title === 'object' ? mf.title.simpleText : mf.title || '');
445
+ description = description || String(mf.description && typeof mf.description === 'object'
446
+ ? mf.description.simpleText
447
+ : mf.description || '');
448
+ channelId = channelId || (0, index_js_2.strOrNone)(mf.externalChannelId);
449
+ channel = channel || (0, index_js_2.strOrNone)(mf.ownerChannelName);
450
+ uploaderId = uploaderId || (0, index_js_2.strOrNone)(mf.ownerProfileUrl)?.split('/').pop() || null;
451
+ uploaderUrl = uploaderUrl || (0, index_js_2.strOrNone)(mf.ownerProfileUrl);
452
+ if (mf.isFamilySafe === false)
453
+ ageLimit = 18;
454
+ if (Array.isArray(mf.category)) {
455
+ categories = mf.category;
456
+ }
457
+ else if (typeof mf.category === 'string') {
458
+ categories = [mf.category];
459
+ }
460
+ if (mf.isUnlisted)
461
+ availability = 'unlisted';
462
+ }
463
+ // Extract uploader/owner from initial data
464
+ const owner = (0, index_js_2.traverseObj)(initialData, [
465
+ 'contents', 'twoColumnWatchNextResults', 'results', 'results',
466
+ 'contents',
467
+ ]);
468
+ if (Array.isArray(owner)) {
469
+ for (const item of owner) {
470
+ const vso = (0, index_js_2.traverseObj)(item, ['videoSecondaryInfoRenderer', 'owner', 'videoOwnerRenderer']);
471
+ if (vso) {
472
+ uploader = uploader || this._getText(vso.title);
473
+ const navEndpoint = (0, index_js_2.traverseObj)(vso, ['navigationEndpoint', 'browseEndpoint']);
474
+ if (navEndpoint) {
475
+ channelId = channelId || (0, index_js_2.strOrNone)(navEndpoint.browseId);
476
+ const canonicalUrl = (0, index_js_2.strOrNone)(navEndpoint.canonicalBaseUrl);
477
+ if (canonicalUrl) {
478
+ uploaderUrl = `https://www.youtube.com${canonicalUrl}`;
479
+ const handle = canonicalUrl.match(/@([\w.-]+)/)?.[1];
480
+ if (handle)
481
+ uploaderId = `@${handle}`;
482
+ }
483
+ }
484
+ const subCount = this._getText(vso.subscriberCountText);
485
+ if (subCount) {
486
+ channelFollowerCount = this._parseCount(subCount);
487
+ }
488
+ }
489
+ // Like count from primary info
490
+ const vpi = item.videoPrimaryInfoRenderer;
491
+ if (vpi) {
492
+ const likeBtn = (0, index_js_2.traverseObj)(vpi, [
493
+ 'videoActions', 'menuRenderer', 'topLevelButtons',
494
+ ]);
495
+ if (Array.isArray(likeBtn)) {
496
+ for (const btn of likeBtn) {
497
+ const toggleBtn = (0, index_js_2.traverseObj)(btn, ['segmentedLikeDislikeButtonViewModel', 'likeButtonViewModel', 'likeButtonViewModel', 'toggleButtonViewModel', 'toggleButtonViewModel', 'defaultButtonViewModel', 'buttonViewModel']);
498
+ if (toggleBtn) {
499
+ const accessText = (0, index_js_2.strOrNone)(toggleBtn.accessibilityText);
500
+ if (accessText) {
501
+ likeCount = this._parseCount(accessText);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ }
508
+ }
509
+ uploader = uploader || channel;
510
+ return {
511
+ title, description, duration, uploader, uploaderId, uploaderUrl,
512
+ channel, channelId, channelFollowerCount, viewCount, likeCount,
513
+ ageLimit, categories, tags, availability,
514
+ };
515
+ }
516
+ _extractMicroformat(playerResponses) {
517
+ for (const pr of playerResponses) {
518
+ const mf = (0, index_js_2.traverseObj)(pr, ['microformat', 'playerMicroformatRenderer']);
519
+ if (!mf)
520
+ continue;
521
+ const publishDate = (0, index_js_2.strOrNone)(mf.publishDate) || (0, index_js_2.strOrNone)(mf.uploadDate);
522
+ if (publishDate) {
523
+ const timestamp = (0, index_js_2.unifiedTimestamp)(publishDate);
524
+ const uploadDate = publishDate.replace(/-/g, '').slice(0, 8);
525
+ return { uploadDate, timestamp };
526
+ }
527
+ }
528
+ return { uploadDate: null, timestamp: null };
529
+ }
530
+ // --- Format extraction ---
531
+ _extractFormats(playerResponse, videoId, clientName = 'web') {
532
+ const formats = [];
533
+ const subtitles = {};
534
+ const streamingData = playerResponse.streamingData;
535
+ if (!streamingData)
536
+ return { formats, subtitles };
537
+ // Extract adaptive formats
538
+ const adaptiveFormats = (streamingData.adaptiveFormats || []);
539
+ const regularFormats = (streamingData.formats || []);
540
+ for (const fmt of [...regularFormats, ...adaptiveFormats]) {
541
+ const url = (0, index_js_2.strOrNone)(fmt.url);
542
+ const signatureCipher = (0, index_js_2.strOrNone)(fmt.signatureCipher) || (0, index_js_2.strOrNone)(fmt.cipher);
543
+ let streamUrl = url;
544
+ if (!streamUrl && signatureCipher) {
545
+ // Parse signature cipher
546
+ const params = new URLSearchParams(signatureCipher);
547
+ streamUrl = params.get('url');
548
+ // Note: Signature decryption requires JS player analysis
549
+ // For now, we try the URL as-is (works for some clients)
550
+ const sig = params.get('s');
551
+ const sp = params.get('sp') || 'signature';
552
+ if (streamUrl && sig) {
553
+ // Without JS player, we can't decrypt. Skip encrypted formats.
554
+ this._warn(`Skipping encrypted format (itag ${fmt.itag}) - signature decryption not available`, videoId);
555
+ continue;
556
+ }
557
+ }
558
+ if (!streamUrl)
559
+ continue;
560
+ const itag = (0, index_js_2.intOrNone)(fmt.itag);
561
+ const itagInfo = itag ? ITAG_QUALITIES[itag] : undefined;
562
+ const mimeType = (0, index_js_2.strOrNone)(fmt.mimeType) || '';
563
+ const codecInfo = this._parseMimeType(mimeType);
564
+ const width = (0, index_js_2.intOrNone)(fmt.width);
565
+ const height = (0, index_js_2.intOrNone)(fmt.height);
566
+ const fps = (0, index_js_2.intOrNone)(fmt.fps);
567
+ const bitrate = (0, index_js_2.intOrNone)(fmt.bitrate);
568
+ const averageBitrate = (0, index_js_2.intOrNone)(fmt.averageBitrate);
569
+ const contentLength = (0, index_js_2.intOrNone)(fmt.contentLength);
570
+ const approxDuration = (0, index_js_2.floatOrNone)(fmt.approxDurationMs, 1000);
571
+ const quality = (0, index_js_2.strOrNone)(fmt.quality) || '';
572
+ const qualityLabel = (0, index_js_2.strOrNone)(fmt.qualityLabel) || '';
573
+ const audioQuality = (0, index_js_2.strOrNone)(fmt.audioQuality) || '';
574
+ const audioSampleRate = (0, index_js_2.intOrNone)(fmt.audioSampleRate);
575
+ const audioChannels = (0, index_js_2.intOrNone)(fmt.audioChannels);
576
+ const isVideo = codecInfo.vcodec !== 'none';
577
+ const isAudio = codecInfo.acodec !== 'none';
578
+ const isDrc = (0, index_js_2.strOrNone)(fmt.isDrc) === 'true' || fmt.isDrc === true;
579
+ // Format ID
580
+ let formatId = String(itag || formats.length);
581
+ if (isDrc)
582
+ formatId += '-drc';
583
+ // Determine ext
584
+ let ext = itagInfo?.ext || codecInfo.ext || 'mp4';
585
+ if (!isVideo && isAudio) {
586
+ ext = itagInfo?.ext || 'm4a';
587
+ if (codecInfo.acodec?.startsWith('opus') || codecInfo.acodec?.startsWith('vorb')) {
588
+ ext = 'webm';
589
+ }
590
+ }
591
+ const format = {
592
+ format_id: formatId,
593
+ url: streamUrl,
594
+ ext,
595
+ width: isVideo ? (width ?? itagInfo?.width) ?? undefined : undefined,
596
+ height: isVideo ? (height ?? itagInfo?.height) ?? undefined : undefined,
597
+ fps: isVideo ? (fps ?? itagInfo?.fps) ?? undefined : undefined,
598
+ tbr: averageBitrate ? Math.round(averageBitrate / 1000) : (bitrate ? Math.round(bitrate / 1000) : undefined),
599
+ abr: !isVideo && isAudio ? (averageBitrate ? Math.round(averageBitrate / 1000) : undefined) : undefined,
600
+ vbr: isVideo && !isAudio ? (averageBitrate ? Math.round(averageBitrate / 1000) : undefined) : undefined,
601
+ asr: audioSampleRate ?? undefined,
602
+ audio_channels: audioChannels ?? undefined,
603
+ vcodec: codecInfo.vcodec || (isVideo ? undefined : 'none'),
604
+ acodec: codecInfo.acodec || (isAudio ? undefined : 'none'),
605
+ filesize: contentLength ?? undefined,
606
+ format_note: [
607
+ qualityLabel || quality,
608
+ isDrc ? 'DRC' : '',
609
+ audioQuality.replace('AUDIO_QUALITY_', '').toLowerCase(),
610
+ ].filter(Boolean).join(', ') || undefined,
611
+ quality: this._qualityScore(quality),
612
+ protocol: 'https',
613
+ dynamic_range: isDrc ? 'SDR' : ((0, index_js_2.strOrNone)(fmt.colorInfo && fmt.colorInfo.transferCharacteristics) === 'TRANSFER_CHARACTERISTICS_BT2020_10_BIT' ? 'HDR' : undefined),
614
+ http_headers: this._getClientHeaders(clientName),
615
+ };
616
+ formats.push(format);
617
+ }
618
+ // HLS manifest
619
+ const hlsUrl = (0, index_js_2.strOrNone)(streamingData.hlsManifestUrl);
620
+ if (hlsUrl) {
621
+ // We'll parse HLS separately if needed
622
+ formats.push({
623
+ format_id: 'hls-manifest',
624
+ url: hlsUrl,
625
+ ext: 'mp4',
626
+ protocol: 'm3u8_native',
627
+ format_note: 'HLS manifest',
628
+ quality: -1,
629
+ });
630
+ }
631
+ return { formats, subtitles };
632
+ }
633
+ _getClientHeaders(clientName) {
634
+ const client = INNERTUBE_CLIENTS[clientName];
635
+ const ua = client?.INNERTUBE_CONTEXT?.client?.userAgent ||
636
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
637
+ return {
638
+ 'User-Agent': ua,
639
+ 'Accept': '*/*',
640
+ 'Accept-Language': 'en-us,en;q=0.5',
641
+ 'Origin': 'https://www.youtube.com',
642
+ 'Referer': 'https://www.youtube.com/',
643
+ };
644
+ }
645
+ _parseMimeType(mimeType) {
646
+ // e.g., video/mp4; codecs="avc1.640028" or audio/webm; codecs="opus"
647
+ const match = mimeType.match(/^(video|audio)\/([\w]+)(?:;\s*codecs="([^"]+)")?/);
648
+ if (!match)
649
+ return { vcodec: 'none', acodec: 'none', ext: 'mp4' };
650
+ const type = match[1]; // video or audio
651
+ const container = match[2]; // mp4, webm, etc.
652
+ const codecs = match[3] || '';
653
+ const codecList = codecs.split(',').map(c => c.trim());
654
+ let vcodec = 'none';
655
+ let acodec = 'none';
656
+ for (const codec of codecList) {
657
+ if (/^(avc|hev|hvc|vp[089]|av01)/i.test(codec)) {
658
+ vcodec = codec;
659
+ }
660
+ else if (/^(mp4a|opus|vorb|flac|ac-3|ec-3)/i.test(codec)) {
661
+ acodec = codec;
662
+ }
663
+ }
664
+ // If type is video and no video codec found, assume it's there
665
+ if (type === 'video' && vcodec === 'none' && codecList.length > 0) {
666
+ vcodec = codecList[0];
667
+ }
668
+ if (type === 'audio' && acodec === 'none' && codecList.length > 0) {
669
+ acodec = codecList[0];
670
+ }
671
+ return { vcodec, acodec, ext: container };
672
+ }
673
+ _qualityScore(quality) {
674
+ const map = {
675
+ 'tiny': -2, 'small': -1, 'medium': 0, 'large': 1,
676
+ 'hd720': 2, 'hd1080': 3, 'hd1440': 4, 'hd2160': 5,
677
+ 'highres': 6,
678
+ };
679
+ return map[quality] ?? 0;
680
+ }
681
+ // --- Caption/subtitle extraction ---
682
+ _extractCaptions(playerResponse, videoId) {
683
+ const subtitles = {};
684
+ const automaticCaptions = {};
685
+ const captions = (0, index_js_2.traverseObj)(playerResponse, [
686
+ 'captions', 'playerCaptionsTracklistRenderer',
687
+ ]);
688
+ if (!captions)
689
+ return { subtitles, automaticCaptions };
690
+ const captionTracks = (captions.captionTracks || []);
691
+ for (const track of captionTracks) {
692
+ const baseUrl = (0, index_js_2.strOrNone)(track.baseUrl);
693
+ if (!baseUrl)
694
+ continue;
695
+ const lang = (0, index_js_2.strOrNone)(track.languageCode) || 'und';
696
+ const name = this._getText(track.name) || lang;
697
+ const kind = (0, index_js_2.strOrNone)(track.kind);
698
+ const isAutoGenerated = kind === 'asr';
699
+ const target = isAutoGenerated ? automaticCaptions : subtitles;
700
+ // Add multiple subtitle formats
701
+ for (const fmt of SUBTITLE_FORMATS) {
702
+ const subUrl = new URL(baseUrl);
703
+ subUrl.searchParams.set('fmt', fmt);
704
+ // Remove exp parameter which triggers PO token requirement
705
+ subUrl.searchParams.delete('exp');
706
+ // Remove xosf which causes undesirable text positioning
707
+ subUrl.searchParams.delete('xosf');
708
+ // Update sparams to remove 'exp' reference
709
+ const sparams = subUrl.searchParams.get('sparams');
710
+ if (sparams) {
711
+ subUrl.searchParams.set('sparams', sparams.split(',').filter(p => p !== 'exp').join(','));
712
+ }
713
+ if (!target[lang])
714
+ target[lang] = [];
715
+ target[lang].push({
716
+ url: subUrl.toString(),
717
+ ext: fmt === 'json3' ? 'json' : fmt,
718
+ name,
719
+ });
720
+ }
721
+ // Extract translation languages
722
+ const translationLanguages = (captions.translationLanguages || []);
723
+ if (isAutoGenerated && translationLanguages.length > 0) {
724
+ for (const tl of translationLanguages) {
725
+ const tlCode = (0, index_js_2.strOrNone)(tl.languageCode);
726
+ if (!tlCode || tlCode === lang)
727
+ continue;
728
+ for (const fmt of ['vtt', 'srt']) {
729
+ const transUrl = new URL(baseUrl);
730
+ transUrl.searchParams.set('fmt', fmt === 'srt' ? 'srv3' : fmt);
731
+ transUrl.searchParams.set('tlang', tlCode);
732
+ transUrl.searchParams.delete('exp');
733
+ transUrl.searchParams.delete('xosf');
734
+ const sp = transUrl.searchParams.get('sparams');
735
+ if (sp)
736
+ transUrl.searchParams.set('sparams', sp.split(',').filter(p => p !== 'exp').join(','));
737
+ if (!automaticCaptions[tlCode])
738
+ automaticCaptions[tlCode] = [];
739
+ automaticCaptions[tlCode].push({
740
+ url: transUrl.toString(),
741
+ ext: fmt,
742
+ name: this._getText(tl.languageName) || tlCode,
743
+ });
744
+ }
745
+ }
746
+ }
747
+ }
748
+ return { subtitles, automaticCaptions };
749
+ }
750
+ // --- Thumbnail extraction ---
751
+ _extractThumbnails(playerResponses, videoId) {
752
+ const thumbnails = [];
753
+ const seenUrls = new Set();
754
+ for (const pr of playerResponses) {
755
+ const thumbs = (0, index_js_2.traverseObj)(pr, ['videoDetails', 'thumbnail', 'thumbnails']);
756
+ if (!Array.isArray(thumbs))
757
+ continue;
758
+ for (const t of thumbs) {
759
+ const url = (0, index_js_2.urlOrNone)(t.url);
760
+ if (!url || seenUrls.has(url))
761
+ continue;
762
+ seenUrls.add(url);
763
+ thumbnails.push({
764
+ url,
765
+ width: (0, index_js_2.intOrNone)(t.width) ?? undefined,
766
+ height: (0, index_js_2.intOrNone)(t.height) ?? undefined,
767
+ });
768
+ }
769
+ }
770
+ // Add standard YouTube thumbnail URLs
771
+ const standardThumbs = [
772
+ { id: 'default', url: `https://i.ytimg.com/vi/${videoId}/default.jpg`, width: 120, height: 90 },
773
+ { id: 'mqdefault', url: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
774
+ { id: 'hqdefault', url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
775
+ { id: 'sddefault', url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
776
+ { id: 'maxresdefault', url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
777
+ ];
778
+ for (const t of standardThumbs) {
779
+ if (!seenUrls.has(t.url)) {
780
+ thumbnails.push(t);
781
+ }
782
+ }
783
+ return thumbnails;
784
+ }
785
+ // --- Chapter extraction ---
786
+ _extractChapters(initialData, description) {
787
+ // Try from engagement panels (structured chapters)
788
+ const chapters = this._extractChaptersFromEngagementPanel(initialData);
789
+ if (chapters.length > 0)
790
+ return chapters;
791
+ // Fallback: parse from description
792
+ return this._extractChaptersFromDescription(description);
793
+ }
794
+ _extractChaptersFromEngagementPanel(initialData) {
795
+ const chapters = [];
796
+ const panels = (0, index_js_2.traverseObj)(initialData, ['engagementPanels']);
797
+ if (!Array.isArray(panels))
798
+ return chapters;
799
+ for (const panel of panels) {
800
+ const macroMarkers = (0, index_js_2.traverseObj)(panel, [
801
+ 'engagementPanelSectionListRenderer', 'content',
802
+ 'macroMarkersListRenderer', 'contents',
803
+ ]);
804
+ if (!Array.isArray(macroMarkers))
805
+ continue;
806
+ for (const marker of macroMarkers) {
807
+ const mr = marker.macroMarkersListItemRenderer;
808
+ if (!mr)
809
+ continue;
810
+ const title = this._getText(mr.title) || '';
811
+ const timeDesc = (0, index_js_2.strOrNone)((0, index_js_2.traverseObj)(mr, ['onTap', 'watchEndpoint', 'startTimeSeconds']));
812
+ const startTime = (0, index_js_2.intOrNone)(timeDesc);
813
+ if (startTime === null)
814
+ continue;
815
+ chapters.push({
816
+ start_time: startTime,
817
+ end_time: 0, // Will be filled in
818
+ title,
819
+ });
820
+ }
821
+ }
822
+ // Fill in end times
823
+ for (let i = 0; i < chapters.length - 1; i++) {
824
+ chapters[i].end_time = chapters[i + 1].start_time;
825
+ }
826
+ return chapters;
827
+ }
828
+ _extractChaptersFromDescription(description) {
829
+ const chapters = [];
830
+ // Match timestamps like 0:00, 1:23, 01:23:45
831
+ const re = /(?:^|\n)\s*(?:(?:\d{1,2}:)?\d{1,2}:\d{2})\s+(.+)/g;
832
+ const timeRe = /(\d{1,2}):(\d{2})(?::(\d{2}))?/;
833
+ let match;
834
+ while ((match = re.exec(description)) !== null) {
835
+ const fullLine = match[0].trim();
836
+ const timeMatch = fullLine.match(timeRe);
837
+ if (!timeMatch)
838
+ continue;
839
+ let seconds;
840
+ if (timeMatch[3]) {
841
+ seconds = parseInt(timeMatch[1]) * 3600 + parseInt(timeMatch[2]) * 60 + parseInt(timeMatch[3]);
842
+ }
843
+ else {
844
+ seconds = parseInt(timeMatch[1]) * 60 + parseInt(timeMatch[2]);
845
+ }
846
+ const title = match[1].trim();
847
+ chapters.push({
848
+ start_time: seconds,
849
+ end_time: 0,
850
+ title,
851
+ });
852
+ }
853
+ // Fill in end times
854
+ for (let i = 0; i < chapters.length - 1; i++) {
855
+ chapters[i].end_time = chapters[i + 1].start_time;
856
+ }
857
+ return chapters;
858
+ }
859
+ // --- Playability check ---
860
+ _checkPlayability(playerResponses, videoId) {
861
+ for (const pr of playerResponses) {
862
+ const ps = pr.playabilityStatus;
863
+ if (!ps)
864
+ continue;
865
+ const status = (0, index_js_2.strOrNone)(ps.status);
866
+ if (status === 'OK')
867
+ return { error: null };
868
+ if (status === 'LOGIN_REQUIRED') {
869
+ return { error: 'This video requires login' };
870
+ }
871
+ if (status === 'UNPLAYABLE') {
872
+ const reason = (0, index_js_2.strOrNone)(ps.reason) || 'Video is unplayable';
873
+ return { error: reason };
874
+ }
875
+ if (status === 'ERROR') {
876
+ const reason = (0, index_js_2.strOrNone)(ps.reason) || 'Video not available';
877
+ return { error: reason };
878
+ }
879
+ }
880
+ return { error: null };
881
+ }
882
+ // --- Live status ---
883
+ _extractLiveStatus(videoDetails, playerResponses) {
884
+ for (const pr of playerResponses) {
885
+ const vd = pr.videoDetails;
886
+ if (!vd)
887
+ continue;
888
+ if (vd.isLive)
889
+ return 'is_live';
890
+ if (vd.isUpcoming)
891
+ return 'is_upcoming';
892
+ if (vd.isLiveContent)
893
+ return 'was_live';
894
+ }
895
+ return 'not_live';
896
+ }
897
+ // --- Title extraction fallback ---
898
+ _extractTitle(webpage, initialData) {
899
+ if (webpage) {
900
+ const title = this._htmlExtractTitle(webpage);
901
+ if (title) {
902
+ return title.replace(/ - YouTube$/, '');
903
+ }
904
+ }
905
+ const title = (0, index_js_2.traverseObj)(initialData, [
906
+ 'contents', 'twoColumnWatchNextResults', 'results', 'results',
907
+ 'contents', 0, 'videoPrimaryInfoRenderer', 'title',
908
+ ]);
909
+ if (title)
910
+ return this._getText(title);
911
+ return 'Unknown';
912
+ }
913
+ // --- Helper methods ---
914
+ _getText(obj) {
915
+ if (!obj || typeof obj !== 'object')
916
+ return String(obj || '');
917
+ const o = obj;
918
+ if (typeof o.simpleText === 'string')
919
+ return o.simpleText;
920
+ if (Array.isArray(o.runs)) {
921
+ return o.runs
922
+ .map(r => String(r.text || ''))
923
+ .join('');
924
+ }
925
+ return '';
926
+ }
927
+ _parseCount(text) {
928
+ // Parse "1,234,567" or "1.2M" or "1.2K subscribers" etc.
929
+ const cleaned = text.replace(/[,\s]/g, '').replace(/subscribers?|likes?/gi, '').trim();
930
+ // Direct number
931
+ const directMatch = cleaned.match(/^(\d+)$/);
932
+ if (directMatch)
933
+ return parseInt(directMatch[1]);
934
+ // Abbreviated
935
+ const abbrMatch = cleaned.match(/^([\d.]+)([KMB])/i);
936
+ if (abbrMatch) {
937
+ const num = parseFloat(abbrMatch[1]);
938
+ const mult = { K: 1e3, M: 1e6, B: 1e9 };
939
+ return Math.round(num * (mult[abbrMatch[2].toUpperCase()] || 1));
940
+ }
941
+ return null;
942
+ }
943
+ _mergeSubtitles(target, source) {
944
+ for (const [lang, subs] of Object.entries(source)) {
945
+ if (!target[lang])
946
+ target[lang] = [];
947
+ target[lang].push(...subs);
948
+ }
949
+ }
950
+ }
951
+ exports.YoutubeIE = YoutubeIE;
952
+ // --- YouTube Playlist extractor ---
953
+ class YoutubePlaylistIE extends common_js_1.InfoExtractor {
954
+ IE_NAME = 'youtube:playlist';
955
+ _VALID_URL = /^(?:https?:\/\/)?(?:(?:www|m|music)\.)?youtube\.com\/(?:playlist\?list=|watch\?.*?&list=)([\w-]+)/;
956
+ async _realExtract(url, match) {
957
+ const playlistId = match[1];
958
+ this._log('Fetching playlist', playlistId);
959
+ const data = await this._callBrowseApi(playlistId);
960
+ const metadata = (0, index_js_2.traverseObj)(data, ['metadata', 'playlistMetadataRenderer']);
961
+ const title = metadata ? (0, index_js_2.strOrNone)(metadata.title) : null;
962
+ const entries = this._extractPlaylistEntries(data, playlistId);
963
+ return {
964
+ _type: 'playlist',
965
+ id: playlistId,
966
+ title: title || `Playlist ${playlistId}`,
967
+ entries: entries,
968
+ playlist_id: playlistId,
969
+ playlist_title: title || undefined,
970
+ };
971
+ }
972
+ async _callBrowseApi(playlistId) {
973
+ const context = {
974
+ client: {
975
+ clientName: 'WEB',
976
+ clientVersion: '2.20260114.08.00',
977
+ hl: 'en',
978
+ gl: 'US',
979
+ },
980
+ };
981
+ const resp = await (0, index_js_1.makeRequest)(`https://www.youtube.com/youtubei/v1/browse`, {
982
+ method: 'POST',
983
+ headers: {
984
+ 'Content-Type': 'application/json',
985
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
986
+ },
987
+ data: JSON.stringify({
988
+ context,
989
+ browseId: `VL${playlistId}`,
990
+ }),
991
+ query: { key: INNERTUBE_API_KEY, prettyPrint: 'false' },
992
+ });
993
+ return resp.json();
994
+ }
995
+ _extractPlaylistEntries(data, playlistId) {
996
+ const entries = [];
997
+ const contents = (0, index_js_2.traverseObj)(data, [
998
+ 'contents', 'twoColumnBrowseResultsRenderer', 'tabs', 0,
999
+ 'tabRenderer', 'content', 'sectionListRenderer', 'contents', 0,
1000
+ 'itemSectionRenderer', 'contents', 0,
1001
+ 'playlistVideoListRenderer', 'contents',
1002
+ ]);
1003
+ if (!Array.isArray(contents))
1004
+ return entries;
1005
+ for (const item of contents) {
1006
+ const renderer = item.playlistVideoRenderer;
1007
+ if (!renderer)
1008
+ continue;
1009
+ const videoId = (0, index_js_2.strOrNone)(renderer.videoId);
1010
+ if (!videoId)
1011
+ continue;
1012
+ entries.push({
1013
+ _type: 'url',
1014
+ id: videoId,
1015
+ title: this._getRendererText(renderer.title) || videoId,
1016
+ url: `https://www.youtube.com/watch?v=${videoId}`,
1017
+ duration: (0, index_js_2.intOrNone)(renderer.lengthSeconds) ?? undefined,
1018
+ });
1019
+ }
1020
+ return entries;
1021
+ }
1022
+ _getRendererText(obj) {
1023
+ if (!obj || typeof obj !== 'object')
1024
+ return '';
1025
+ const o = obj;
1026
+ if (typeof o.simpleText === 'string')
1027
+ return o.simpleText;
1028
+ if (Array.isArray(o.runs)) {
1029
+ return o.runs.map(r => String(r.text || '')).join('');
1030
+ }
1031
+ return '';
1032
+ }
1033
+ }
1034
+ exports.YoutubePlaylistIE = YoutubePlaylistIE;
1035
+ // --- YouTube Search extractor ---
1036
+ class YoutubeSearchIE extends common_js_1.InfoExtractor {
1037
+ IE_NAME = 'youtube:search';
1038
+ _VALID_URL = /^ytsearch(\d+)?:(.+)$/;
1039
+ async _realExtract(url, match) {
1040
+ const maxResults = parseInt(match[1] || '10', 10);
1041
+ const query = match[2];
1042
+ this._log(`Searching for "${query}"`, 'search');
1043
+ const context = {
1044
+ client: {
1045
+ clientName: 'WEB',
1046
+ clientVersion: '2.20260114.08.00',
1047
+ hl: 'en',
1048
+ gl: 'US',
1049
+ },
1050
+ };
1051
+ const resp = await (0, index_js_1.makeRequest)('https://www.youtube.com/youtubei/v1/search', {
1052
+ method: 'POST',
1053
+ headers: {
1054
+ 'Content-Type': 'application/json',
1055
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
1056
+ },
1057
+ data: JSON.stringify({ context, query }),
1058
+ query: { key: INNERTUBE_API_KEY, prettyPrint: 'false' },
1059
+ });
1060
+ const data = resp.json();
1061
+ const entries = [];
1062
+ const contents = (0, index_js_2.traverseObj)(data, [
1063
+ 'contents', 'twoColumnSearchResultsRenderer', 'primaryContents',
1064
+ 'sectionListRenderer', 'contents',
1065
+ ]);
1066
+ if (Array.isArray(contents)) {
1067
+ for (const section of contents) {
1068
+ const items = (0, index_js_2.traverseObj)(section, ['itemSectionRenderer', 'contents']);
1069
+ if (!Array.isArray(items))
1070
+ continue;
1071
+ for (const item of items) {
1072
+ const vr = item.videoRenderer;
1073
+ if (!vr)
1074
+ continue;
1075
+ const videoId = (0, index_js_2.strOrNone)(vr.videoId);
1076
+ if (!videoId)
1077
+ continue;
1078
+ if (entries.length >= maxResults)
1079
+ break;
1080
+ entries.push({
1081
+ _type: 'url',
1082
+ id: videoId,
1083
+ title: this._getRendererText(vr.title) || videoId,
1084
+ url: `https://www.youtube.com/watch?v=${videoId}`,
1085
+ });
1086
+ }
1087
+ if (entries.length >= maxResults)
1088
+ break;
1089
+ }
1090
+ }
1091
+ return {
1092
+ _type: 'playlist',
1093
+ id: `search:${query}`,
1094
+ title: `YouTube search: ${query}`,
1095
+ entries: entries,
1096
+ };
1097
+ }
1098
+ _getRendererText(obj) {
1099
+ if (!obj || typeof obj !== 'object')
1100
+ return '';
1101
+ const o = obj;
1102
+ if (typeof o.simpleText === 'string')
1103
+ return o.simpleText;
1104
+ if (Array.isArray(o.runs)) {
1105
+ return o.runs.map(r => String(r.text || '')).join('');
1106
+ }
1107
+ return '';
1108
+ }
1109
+ }
1110
+ exports.YoutubeSearchIE = YoutubeSearchIE;
1111
+ // Export all extractors
1112
+ exports.EXTRACTORS = [YoutubeIE, YoutubePlaylistIE, YoutubeSearchIE];
1113
+ //# sourceMappingURL=youtube.js.map