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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/bin/ytgrab.js +194 -0
- package/dist/downloader/common.d.ts +22 -0
- package/dist/downloader/common.d.ts.map +1 -0
- package/dist/downloader/common.js +125 -0
- package/dist/downloader/common.js.map +1 -0
- package/dist/downloader/hls.d.ts +11 -0
- package/dist/downloader/hls.d.ts.map +1 -0
- package/dist/downloader/hls.js +134 -0
- package/dist/downloader/hls.js.map +1 -0
- package/dist/downloader/http.d.ts +10 -0
- package/dist/downloader/http.d.ts.map +1 -0
- package/dist/downloader/http.js +132 -0
- package/dist/downloader/http.js.map +1 -0
- package/dist/downloader/index.d.ts +10 -0
- package/dist/downloader/index.d.ts.map +1 -0
- package/dist/downloader/index.js +24 -0
- package/dist/downloader/index.js.map +1 -0
- package/dist/extractor/common.d.ts +48 -0
- package/dist/extractor/common.d.ts.map +1 -0
- package/dist/extractor/common.js +324 -0
- package/dist/extractor/common.js.map +1 -0
- package/dist/extractor/nsig.d.ts +17 -0
- package/dist/extractor/nsig.d.ts.map +1 -0
- package/dist/extractor/nsig.js +200 -0
- package/dist/extractor/nsig.js.map +1 -0
- package/dist/extractor/youtube.d.ts +51 -0
- package/dist/extractor/youtube.d.ts.map +1 -0
- package/dist/extractor/youtube.js +1113 -0
- package/dist/extractor/youtube.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/networking/index.d.ts +33 -0
- package/dist/networking/index.d.ts.map +1 -0
- package/dist/networking/index.js +171 -0
- package/dist/networking/index.js.map +1 -0
- package/dist/postprocessor/common.d.ts +21 -0
- package/dist/postprocessor/common.d.ts.map +1 -0
- package/dist/postprocessor/common.js +42 -0
- package/dist/postprocessor/common.js.map +1 -0
- package/dist/postprocessor/ffmpeg.d.ts +44 -0
- package/dist/postprocessor/ffmpeg.d.ts.map +1 -0
- package/dist/postprocessor/ffmpeg.js +286 -0
- package/dist/postprocessor/ffmpeg.js.map +1 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/index.d.ts +57 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +403 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/traversal.d.ts +22 -0
- package/dist/utils/traversal.d.ts.map +1 -0
- package/dist/utils/traversal.js +112 -0
- package/dist/utils/traversal.js.map +1 -0
- package/dist/ytgrab.d.ts +48 -0
- package/dist/ytgrab.d.ts.map +1 -0
- package/dist/ytgrab.js +450 -0
- package/dist/ytgrab.js.map +1 -0
- 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
|