yt-direct 1.0.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 +269 -0
- package/dist/.integrity +1 -0
- package/dist/index.js +1304 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/*! yt-direct v1.0.0 */
|
|
2
|
+
// index.js
|
|
3
|
+
// core/InnerTube.js
|
|
4
|
+
// core/Client.js
|
|
5
|
+
const https = require('node:https');
|
|
6
|
+
const zlib = require('node:zlib');
|
|
7
|
+
const { URL } = require('node:url');
|
|
8
|
+
|
|
9
|
+
const UA = 'com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip';
|
|
10
|
+
const API_KEY = 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w';
|
|
11
|
+
const HOST = 'www.youtube.com';
|
|
12
|
+
const PATH = '/youtubei/v1/player';
|
|
13
|
+
const TIMEOUT = 20000;
|
|
14
|
+
|
|
15
|
+
function buildOptions(videoId) {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
context: {
|
|
18
|
+
client: {
|
|
19
|
+
clientName: 'ANDROID',
|
|
20
|
+
clientVersion: '20.10.38',
|
|
21
|
+
androidSdkVersion: 30,
|
|
22
|
+
osName: 'Android',
|
|
23
|
+
osVersion: '11',
|
|
24
|
+
userAgent: UA,
|
|
25
|
+
hl: 'en',
|
|
26
|
+
gl: 'US',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
videoId,
|
|
30
|
+
contentCheckOk: true,
|
|
31
|
+
racyCheckOk: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function request(payload) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const body = typeof payload === 'string' ? payload : buildOptions(payload);
|
|
38
|
+
const opts = {
|
|
39
|
+
hostname: HOST,
|
|
40
|
+
path: `${PATH}?key=${API_KEY}`,
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
'User-Agent': UA,
|
|
45
|
+
'Content-Length': Buffer.byteLength(body),
|
|
46
|
+
'Accept-Encoding': 'gzip',
|
|
47
|
+
'Origin': `https://${HOST}`,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const req = https.request(opts, (res) => {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
res.on('data', (c) => chunks.push(c));
|
|
53
|
+
res.on('end', () => {
|
|
54
|
+
let buf = Buffer.concat(chunks);
|
|
55
|
+
if (res.headers['content-encoding'] === 'gzip') {
|
|
56
|
+
try { buf = zlib.gunzipSync(buf); } catch {}
|
|
57
|
+
}
|
|
58
|
+
if (res.statusCode !== 200) {
|
|
59
|
+
return reject(new Error(`YouTube API returned HTTP ${res.statusCode}`));
|
|
60
|
+
}
|
|
61
|
+
resolve(JSON.parse(buf.toString('utf8')));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
req.setTimeout(TIMEOUT, () => { req.destroy(new Error('YouTube API request timed out')); });
|
|
66
|
+
req.write(body);
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function head(url) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let u;
|
|
74
|
+
try { u = new URL(url); } catch { resolve(false); return; }
|
|
75
|
+
const req = https.get({
|
|
76
|
+
hostname: u.hostname,
|
|
77
|
+
path: u.pathname + u.search,
|
|
78
|
+
headers: {
|
|
79
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
80
|
+
'Referer': 'https://www.youtube.com/',
|
|
81
|
+
'Range': 'bytes=0-0',
|
|
82
|
+
},
|
|
83
|
+
}, (res) => {
|
|
84
|
+
resolve(res.statusCode === 206 || res.statusCode === 200);
|
|
85
|
+
res.resume();
|
|
86
|
+
});
|
|
87
|
+
req.on('error', () => resolve(false));
|
|
88
|
+
req.setTimeout(8000, () => { req.destroy(); resolve(false); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stream(url) {
|
|
93
|
+
const u = new URL(url);
|
|
94
|
+
return https.get({
|
|
95
|
+
hostname: u.hostname,
|
|
96
|
+
path: u.pathname + u.search,
|
|
97
|
+
headers: {
|
|
98
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
99
|
+
'Referer': 'https://www.youtube.com/',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { request, head, stream, buildOptions, UA, API_KEY, HOST, PATH };
|
|
105
|
+
|
|
106
|
+
// core/Errors.js
|
|
107
|
+
class YouTubeError extends Error {
|
|
108
|
+
constructor(message, code = 'YOUTUBE_ERROR', details = null) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'YouTubeError';
|
|
111
|
+
this.code = code;
|
|
112
|
+
this.details = details;
|
|
113
|
+
Error.captureStackTrace(this, this.constructor);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class FormatError extends YouTubeError {
|
|
118
|
+
constructor(message, details = null) {
|
|
119
|
+
super(message, 'FORMAT_ERROR', details);
|
|
120
|
+
this.name = 'FormatError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
class QualityError extends YouTubeError {
|
|
125
|
+
constructor(message, details = null) {
|
|
126
|
+
super(message, 'QUALITY_ERROR', details);
|
|
127
|
+
this.name = 'QualityError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class MergeError extends YouTubeError {
|
|
132
|
+
constructor(message, details = null) {
|
|
133
|
+
super(message, 'MERGE_ERROR', details);
|
|
134
|
+
this.name = 'MergeError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class NetworkError extends YouTubeError {
|
|
139
|
+
constructor(message, details = null) {
|
|
140
|
+
super(message, 'NETWORK_ERROR', details);
|
|
141
|
+
this.name = 'NetworkError';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
class ValidationError extends YouTubeError {
|
|
146
|
+
constructor(message, details = null) {
|
|
147
|
+
super(message, 'VALIDATION_ERROR', details);
|
|
148
|
+
this.name = 'ValidationError';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
YouTubeError,
|
|
154
|
+
FormatError,
|
|
155
|
+
QualityError,
|
|
156
|
+
MergeError,
|
|
157
|
+
NetworkError,
|
|
158
|
+
ValidationError,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const { request } = require('./Client');
|
|
162
|
+
const { YouTubeError } = require('./Errors');
|
|
163
|
+
|
|
164
|
+
const CLIENT_PROFILES = [
|
|
165
|
+
{
|
|
166
|
+
name: 'ANDROID',
|
|
167
|
+
version: '20.10.38',
|
|
168
|
+
sdk: 30,
|
|
169
|
+
os: 'Android',
|
|
170
|
+
osVer: '11',
|
|
171
|
+
ua: 'com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip',
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'ANDROID_VR',
|
|
175
|
+
version: '1.71.26',
|
|
176
|
+
sdk: 32,
|
|
177
|
+
os: 'Android',
|
|
178
|
+
osVer: '12L',
|
|
179
|
+
ua: 'com.google.android.apps.youtube.vr.oculus/1.71.26 (Linux; U; Android 12L) gzip',
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
function buildContext(client) {
|
|
184
|
+
return {
|
|
185
|
+
client: {
|
|
186
|
+
clientName: client.name,
|
|
187
|
+
clientVersion: client.version,
|
|
188
|
+
androidSdkVersion: client.sdk,
|
|
189
|
+
osName: client.os,
|
|
190
|
+
osVersion: client.osVer,
|
|
191
|
+
userAgent: client.ua,
|
|
192
|
+
hl: 'en',
|
|
193
|
+
gl: 'US',
|
|
194
|
+
timeZone: 'UTC',
|
|
195
|
+
utcOffsetMinutes: 0,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function playerPayload(videoId, client) {
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
context: buildContext(client),
|
|
203
|
+
videoId,
|
|
204
|
+
contentCheckOk: true,
|
|
205
|
+
racyCheckOk: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseStreamingData(raw) {
|
|
210
|
+
if (!raw || raw.error) {
|
|
211
|
+
const msg = raw?.error?.message || 'Unknown API error';
|
|
212
|
+
throw new YouTubeError(msg, 'API_ERROR', raw?.error);
|
|
213
|
+
}
|
|
214
|
+
const sd = raw.streamingData;
|
|
215
|
+
if (!sd) {
|
|
216
|
+
throw new YouTubeError('No streaming data in response', 'NO_STREAMING_DATA');
|
|
217
|
+
}
|
|
218
|
+
return sd;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractFormats(streamingData) {
|
|
222
|
+
return {
|
|
223
|
+
combined: (streamingData.formats || []).filter((f) => f.url),
|
|
224
|
+
adaptive: (streamingData.adaptiveFormats || []).filter((f) => f.url),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function fetch(videoId) {
|
|
229
|
+
let lastError = null;
|
|
230
|
+
|
|
231
|
+
for (const client of CLIENT_PROFILES) {
|
|
232
|
+
try {
|
|
233
|
+
const payload = playerPayload(videoId, client);
|
|
234
|
+
const raw = await request(payload);
|
|
235
|
+
const sd = parseStreamingData(raw);
|
|
236
|
+
const { combined, adaptive } = extractFormats(sd);
|
|
237
|
+
|
|
238
|
+
if (combined.length > 0 || adaptive.length > 0) {
|
|
239
|
+
return {
|
|
240
|
+
raw,
|
|
241
|
+
videoDetails: raw.videoDetails || {},
|
|
242
|
+
streamingData: sd,
|
|
243
|
+
combined,
|
|
244
|
+
adaptive,
|
|
245
|
+
clientUsed: client.name,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
lastError = err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw lastError || new YouTubeError('All InnerTube clients failed', 'ALL_CLIENTS_FAILED');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { fetch, CLIENT_PROFILES };
|
|
257
|
+
|
|
258
|
+
// formats/Selector.js
|
|
259
|
+
// formats/Format.js
|
|
260
|
+
// formats/Registry.js
|
|
261
|
+
const { FormatError } = require('../core/Errors');
|
|
262
|
+
|
|
263
|
+
const ITAG_REGISTRY = {
|
|
264
|
+
'18': { quality: '360p', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
265
|
+
'22': { quality: '720p', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
266
|
+
'37': { quality: '1080p', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
267
|
+
'38': { quality: '4K', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
268
|
+
'59': { quality: '480p', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
269
|
+
'78': { quality: '480p', container: 'mp4', type: 'combined', codec: 'H.264 + AAC' },
|
|
270
|
+
'133': { quality: '240p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
271
|
+
'134': { quality: '360p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
272
|
+
'135': { quality: '480p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
273
|
+
'136': { quality: '720p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
274
|
+
'137': { quality: '1080p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
275
|
+
'138': { quality: '2160p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
276
|
+
'139': { quality: '48kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
277
|
+
'140': { quality: '128kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
278
|
+
'141': { quality: '256kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
279
|
+
'160': { quality: '144p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
280
|
+
'242': { quality: '240p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
281
|
+
'243': { quality: '360p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
282
|
+
'244': { quality: '480p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
283
|
+
'247': { quality: '720p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
284
|
+
'248': { quality: '1080p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
285
|
+
'249': { quality: '50kbps',container: 'webm', type: 'audio', codec: 'Opus' },
|
|
286
|
+
'250': { quality: '70kbps',container: 'webm', type: 'audio', codec: 'Opus' },
|
|
287
|
+
'251': { quality: '160kbps',container: 'webm',type: 'audio', codec: 'Opus' },
|
|
288
|
+
'271': { quality: '1440p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
289
|
+
'272': { quality: '2160p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
290
|
+
'278': { quality: '144p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
291
|
+
'298': { quality: '720p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
292
|
+
'299': { quality: '1080p', container: 'mp4', type: 'video', codec: 'H.264' },
|
|
293
|
+
'302': { quality: '720p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
294
|
+
'303': { quality: '1080p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
295
|
+
'308': { quality: '1440p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
296
|
+
'313': { quality: '2160p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
297
|
+
'315': { quality: '2160p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
298
|
+
'394': { quality: '144p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
299
|
+
'395': { quality: '240p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
300
|
+
'396': { quality: '360p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
301
|
+
'397': { quality: '480p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
302
|
+
'398': { quality: '720p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
303
|
+
'399': { quality: '1080p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
304
|
+
'400': { quality: '1440p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
305
|
+
'401': { quality: '2160p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
306
|
+
'402': { quality: '4320p', container: 'mp4', type: 'video', codec: 'AV1' },
|
|
307
|
+
'571': { quality: '48kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
308
|
+
'597': { quality: '48kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
309
|
+
'598': { quality: '144p', container: 'webm', type: 'video', codec: 'VP9' },
|
|
310
|
+
'599': { quality: '32kbps',container: 'mp4', type: 'audio', codec: 'AAC' },
|
|
311
|
+
'600': { quality: '32kbps',container: 'webm', type: 'audio', codec: 'Opus' },
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const CONTAINER_MAP = {
|
|
315
|
+
mp4: { mime: 'video/mp4', extension: '.mp4', default: true },
|
|
316
|
+
webm: { mime: 'video/webm', extension: '.webm', default: false },
|
|
317
|
+
mkv: { mime: 'video/x-matroska', extension: '.mkv', default: false, requiresMerge: true },
|
|
318
|
+
avi: { mime: 'video/x-msvideo', extension: '.avi', default: false, requiresMerge: true },
|
|
319
|
+
mov: { mime: 'video/quicktime', extension: '.mov', default: false, requiresMerge: true },
|
|
320
|
+
m4a: { mime: 'audio/mp4', extension: '.m4a', default: false },
|
|
321
|
+
aac: { mime: 'audio/aac', extension: '.aac', default: false, requiresMerge: true },
|
|
322
|
+
flac: { mime: 'audio/flac', extension: '.flac', default: false, requiresMerge: true },
|
|
323
|
+
ogg: { mime: 'audio/ogg', extension: '.ogg', default: false, requiresMerge: true },
|
|
324
|
+
mp3: { mime: 'audio/mpeg', extension: '.mp3', default: false, requiresMerge: true },
|
|
325
|
+
wav: { mime: 'audio/wav', extension: '.wav', default: false, requiresMerge: true },
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const QUALITY_TIERS = ['4320p', '2160p', '1440p', '1080p', '720p', '480p', '360p', '240p', '144p'];
|
|
329
|
+
|
|
330
|
+
function getItagMeta(itag) {
|
|
331
|
+
return ITAG_REGISTRY[String(itag)] || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolveContainer(name) {
|
|
335
|
+
const key = String(name).toLowerCase().replace(/^\./, '');
|
|
336
|
+
return CONTAINER_MAP[key] || null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requiresConversion(format, targetContainer) {
|
|
340
|
+
const container = resolveContainer(targetContainer);
|
|
341
|
+
if (!container) return false;
|
|
342
|
+
if (container.requiresMerge) return true;
|
|
343
|
+
const fmtContainer = resolveContainer(format.container || 'mp4');
|
|
344
|
+
if (!fmtContainer) return true;
|
|
345
|
+
return fmtContainer.extension !== container.extension;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function qualityIndex(label) {
|
|
349
|
+
const idx = QUALITY_TIERS.indexOf(label);
|
|
350
|
+
if (idx !== -1) return idx;
|
|
351
|
+
if (/^\d+p$/.test(label)) return QUALITY_TIERS.indexOf(label) !== -1 ? QUALITY_TIERS.indexOf(label) : -1;
|
|
352
|
+
return -1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isQualitySupported(label) {
|
|
356
|
+
return QUALITY_TIERS.includes(label) || ['audio', 'best', 'auto'].includes(label);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isContainerSupported(name) {
|
|
360
|
+
return !!resolveContainer(name);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = {
|
|
364
|
+
ITAG_REGISTRY,
|
|
365
|
+
CONTAINER_MAP,
|
|
366
|
+
QUALITY_TIERS,
|
|
367
|
+
getItagMeta,
|
|
368
|
+
resolveContainer,
|
|
369
|
+
requiresConversion,
|
|
370
|
+
qualityIndex,
|
|
371
|
+
isQualitySupported,
|
|
372
|
+
isContainerSupported,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const { getItagMeta, resolveContainer } = require('./Registry');
|
|
376
|
+
|
|
377
|
+
class Format {
|
|
378
|
+
#raw;
|
|
379
|
+
#meta;
|
|
380
|
+
|
|
381
|
+
constructor(rawFormat) {
|
|
382
|
+
this.#raw = rawFormat;
|
|
383
|
+
this.#meta = getItagMeta(rawFormat.itag) || {};
|
|
384
|
+
|
|
385
|
+
this.itag = rawFormat.itag;
|
|
386
|
+
this.url = rawFormat.url || null;
|
|
387
|
+
this.mimeType = rawFormat.mimeType || '';
|
|
388
|
+
this.contentLength = rawFormat.contentLength ? Number(rawFormat.contentLength) : 0;
|
|
389
|
+
this.bitrate = rawFormat.bitrate || 0;
|
|
390
|
+
this.width = rawFormat.width || 0;
|
|
391
|
+
this.height = rawFormat.height || 0;
|
|
392
|
+
this.fps = rawFormat.fps || 0;
|
|
393
|
+
this.qualityLabel = rawFormat.qualityLabel || this.#meta.quality || null;
|
|
394
|
+
this.container = rawFormat.container || this.#meta.container || 'mp4';
|
|
395
|
+
this.codec = rawFormat.codecs || this.#meta.codec || 'unknown';
|
|
396
|
+
this.isAudio = this.mimeType.includes('audio');
|
|
397
|
+
this.isVideo = this.mimeType.includes('video');
|
|
398
|
+
this.isCombined = this.#meta.type === 'combined' || (this.isVideo && this.mimeType.includes('mp4a'));
|
|
399
|
+
this.source = rawFormat._source || 'adaptive';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
get hasUrl() {
|
|
403
|
+
return !!this.url;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
get sizeMB() {
|
|
407
|
+
return this.contentLength > 0 ? (this.contentLength / 1024 / 1024).toFixed(1) : '?';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
get qualityRank() {
|
|
411
|
+
if (this.isAudio) return this.bitrate;
|
|
412
|
+
return (this.height || 0) * (this.width || 0);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
toJSON() {
|
|
416
|
+
return {
|
|
417
|
+
itag: this.itag,
|
|
418
|
+
quality: this.qualityLabel,
|
|
419
|
+
container: this.container,
|
|
420
|
+
codec: this.codec,
|
|
421
|
+
size: this.sizeMB,
|
|
422
|
+
width: this.width,
|
|
423
|
+
height: this.height,
|
|
424
|
+
fps: this.fps,
|
|
425
|
+
bitrate: this.bitrate,
|
|
426
|
+
type: this.isCombined ? 'combined' : this.isAudio ? 'audio' : 'video',
|
|
427
|
+
hasUrl: this.hasUrl,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
inspect() {
|
|
432
|
+
return `Format(${this.itag} | ${this.qualityLabel} | ${this.container} | ${this.codec})`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
436
|
+
return this.inspect();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
module.exports = { Format };
|
|
441
|
+
|
|
442
|
+
// formats/Qualities.js
|
|
443
|
+
const { QualityError } = require('../core/Errors');
|
|
444
|
+
|
|
445
|
+
const QUALITY_MAP = {
|
|
446
|
+
'4320p': 4320,
|
|
447
|
+
'2160p': 2160,
|
|
448
|
+
'1440p': 1440,
|
|
449
|
+
'1080p': 1080,
|
|
450
|
+
'720p': 720,
|
|
451
|
+
'480p': 480,
|
|
452
|
+
'360p': 360,
|
|
453
|
+
'240p': 240,
|
|
454
|
+
'144p': 144,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const QUALITY_TIERS = Object.keys(QUALITY_MAP);
|
|
458
|
+
|
|
459
|
+
function toHeight(label) {
|
|
460
|
+
if (!label) return 0;
|
|
461
|
+
const cleaned = String(label).toLowerCase().replace(/[^0-9]/g, '');
|
|
462
|
+
const num = parseInt(cleaned, 10);
|
|
463
|
+
return isNaN(num) ? 0 : num;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function matchQualityRank(format, targetHeight, tolerance = 72) {
|
|
467
|
+
if (!targetHeight) return true;
|
|
468
|
+
const h = format.height || toHeight(format.qualityLabel);
|
|
469
|
+
if (!h) return false;
|
|
470
|
+
return Math.abs(h - targetHeight) <= tolerance;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function getFallbackChain(requested) {
|
|
474
|
+
const t = String(requested || '').toLowerCase();
|
|
475
|
+
if (t === 'auto' || t === 'best') return [...QUALITY_TIERS];
|
|
476
|
+
if (t === 'audio') return ['audio'];
|
|
477
|
+
const idx = QUALITY_TIERS.indexOf(t);
|
|
478
|
+
if (idx !== -1) return QUALITY_TIERS.slice(idx);
|
|
479
|
+
return [...QUALITY_TIERS];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function validateQuality(requested) {
|
|
483
|
+
if (!requested) return 'auto';
|
|
484
|
+
const t = String(requested).toLowerCase();
|
|
485
|
+
if (t === 'auto' || t === 'best' || t === 'audio') return t;
|
|
486
|
+
if (QUALITY_TIERS.includes(t)) return t;
|
|
487
|
+
throw new QualityError(
|
|
488
|
+
`Unsupported quality "${requested}". Available: ${QUALITY_TIERS.join(', ')}, auto, best, audio`,
|
|
489
|
+
{ requested, supported: [...QUALITY_TIERS, 'auto', 'best', 'audio'] }
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
module.exports = {
|
|
494
|
+
QUALITY_MAP,
|
|
495
|
+
QUALITY_TIERS,
|
|
496
|
+
toHeight,
|
|
497
|
+
matchQualityRank,
|
|
498
|
+
getFallbackChain,
|
|
499
|
+
validateQuality,
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// formats/mime.js
|
|
503
|
+
const { resolveContainer, requiresConversion } = require('./Registry');
|
|
504
|
+
|
|
505
|
+
function mimeTypeToContainer(mimeType) {
|
|
506
|
+
if (!mimeType) return 'mp4';
|
|
507
|
+
const parts = mimeType.split('/');
|
|
508
|
+
if (parts.length < 2) return 'mp4';
|
|
509
|
+
const sub = parts[1].split(';')[0].trim().toLowerCase();
|
|
510
|
+
const map = {
|
|
511
|
+
mp4: 'mp4',
|
|
512
|
+
webm: 'webm',
|
|
513
|
+
'x-matroska': 'mkv',
|
|
514
|
+
'x-msvideo': 'avi',
|
|
515
|
+
quicktime: 'mov',
|
|
516
|
+
aac: 'aac',
|
|
517
|
+
mpeg: 'mp3',
|
|
518
|
+
wav: 'wav',
|
|
519
|
+
ogg: 'ogg',
|
|
520
|
+
flac: 'flac',
|
|
521
|
+
'3gpp': '3gp',
|
|
522
|
+
};
|
|
523
|
+
return map[sub] || sub;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function checkContainer(format, targetContainer) {
|
|
527
|
+
const current = format.container || mimeTypeToContainer(format.mimeType);
|
|
528
|
+
if (!targetContainer) return { compatible: true, current, needsConversion: false };
|
|
529
|
+
const tc = resolveContainer(targetContainer);
|
|
530
|
+
if (!tc) return { compatible: false, current, target: targetContainer, needsConversion: false };
|
|
531
|
+
if (current === targetContainer) return { compatible: true, current, target: targetContainer, needsConversion: false };
|
|
532
|
+
return {
|
|
533
|
+
compatible: false,
|
|
534
|
+
current,
|
|
535
|
+
target: targetContainer,
|
|
536
|
+
needsConversion: true,
|
|
537
|
+
requiresTool: requiresConversion(format, targetContainer),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
module.exports = { mimeTypeToContainer, checkContainer };
|
|
542
|
+
|
|
543
|
+
const { Format } = require('./Format');
|
|
544
|
+
const { getFallbackChain, matchQualityRank, toHeight, validateQuality } = require('./Qualities');
|
|
545
|
+
const { checkContainer, mimeTypeToContainer } = require('./mime');
|
|
546
|
+
const { requiresConversion } = require('./Registry');
|
|
547
|
+
const { FormatError, QualityError } = require('../core/Errors');
|
|
548
|
+
|
|
549
|
+
class FormatSelector {
|
|
550
|
+
#combined;
|
|
551
|
+
#adaptive;
|
|
552
|
+
#all;
|
|
553
|
+
|
|
554
|
+
constructor(streamingData) {
|
|
555
|
+
this.#combined = (streamingData.formats || []).map((f) => new Format({ ...f, _source: 'combined' }));
|
|
556
|
+
this.#adaptive = (streamingData.adaptiveFormats || []).map((f) => new Format({ ...f, _source: 'adaptive' }));
|
|
557
|
+
this.#all = [...this.#combined, ...this.#adaptive];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
get formats() {
|
|
561
|
+
return [...this.#all];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
get combined() {
|
|
565
|
+
return [...this.#combined];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
get adaptive() {
|
|
569
|
+
return [...this.#adaptive];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
list(options = {}) {
|
|
573
|
+
let list = [...this.#all];
|
|
574
|
+
|
|
575
|
+
if (options.type === 'video') list = list.filter((f) => f.isVideo);
|
|
576
|
+
else if (options.type === 'audio') list = list.filter((f) => f.isAudio);
|
|
577
|
+
else if (options.type === 'combined') list = list.filter((f) => f.isCombined);
|
|
578
|
+
|
|
579
|
+
if (options.minHeight) list = list.filter((f) => f.height >= options.minHeight);
|
|
580
|
+
if (options.minBitrate) list = list.filter((f) => f.bitrate >= options.minBitrate);
|
|
581
|
+
if (options.container) list = list.filter((f) => f.container === options.container);
|
|
582
|
+
if (options.codec) list = list.filter((f) => f.codec.toLowerCase().includes(options.codec.toLowerCase()));
|
|
583
|
+
|
|
584
|
+
return list.map((f) => f.toJSON());
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
select(options = {}) {
|
|
588
|
+
const quality = validateQuality(options.quality || 'auto');
|
|
589
|
+
const container = options.format || options.container || null;
|
|
590
|
+
|
|
591
|
+
if (quality === 'audio') {
|
|
592
|
+
return this.#selectAudio(options);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const chain = getFallbackChain(quality);
|
|
596
|
+
|
|
597
|
+
for (const q of chain) {
|
|
598
|
+
const targetH = toHeight(q);
|
|
599
|
+
const result = this.#tryQuality(targetH, container, options);
|
|
600
|
+
if (result) return result;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return this.#lastResort(container, options);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#tryQuality(targetH, container, options) {
|
|
607
|
+
const preferMp4 = options.preferMp4 !== false;
|
|
608
|
+
|
|
609
|
+
let candidates = this.#combined
|
|
610
|
+
.filter((f) => f.hasUrl && matchQualityRank(f, targetH))
|
|
611
|
+
.sort(this.#sortFn(preferMp4));
|
|
612
|
+
|
|
613
|
+
if (candidates.length) {
|
|
614
|
+
const picked = candidates[0];
|
|
615
|
+
const cc = container ? checkContainer(picked, container) : null;
|
|
616
|
+
|
|
617
|
+
if (container && cc && cc.needsConversion && !options.merge) {
|
|
618
|
+
throw new FormatError(
|
|
619
|
+
`Format "${container}" requires a merge/convert tool for itag ${picked.itag} (${picked.qualityLabel}, ${picked.container}). ` +
|
|
620
|
+
`Available source: ${picked.container}. Use { merge: { tool: 'ffmpeg', output: 'file.${container}' } } to convert.`,
|
|
621
|
+
{
|
|
622
|
+
itag: picked.itag,
|
|
623
|
+
sourceContainer: picked.container,
|
|
624
|
+
targetContainer: container,
|
|
625
|
+
requiresConversion: true,
|
|
626
|
+
suggestedTool: 'ffmpeg',
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return { format: picked, type: 'combined' };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const videos = this.#adaptive
|
|
635
|
+
.filter((f) => f.isVideo && f.hasUrl && matchQualityRank(f, targetH))
|
|
636
|
+
.sort(this.#sortFn(preferMp4));
|
|
637
|
+
|
|
638
|
+
if (videos.length) {
|
|
639
|
+
const video = videos[0];
|
|
640
|
+
const audios = this.#adaptive
|
|
641
|
+
.filter((f) => f.isAudio && f.hasUrl)
|
|
642
|
+
.sort((a, b) => b.bitrate - a.bitrate);
|
|
643
|
+
|
|
644
|
+
const cc = container ? checkContainer(video, container) : null;
|
|
645
|
+
|
|
646
|
+
if (container && cc && cc.needsConversion && !options.merge) {
|
|
647
|
+
throw new FormatError(
|
|
648
|
+
`Format "${container}" requires a merge/convert tool. ` +
|
|
649
|
+
`Source: ${video.container}. Use { merge: { tool: 'ffmpeg', output: 'file.${container}' } }.`,
|
|
650
|
+
{
|
|
651
|
+
itag: video.itag,
|
|
652
|
+
sourceContainer: video.container,
|
|
653
|
+
targetContainer: container,
|
|
654
|
+
requiresConversion: true,
|
|
655
|
+
suggestedTool: 'ffmpeg',
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const result = { format: video, type: 'video-only' };
|
|
661
|
+
|
|
662
|
+
if (audios.length) {
|
|
663
|
+
result.audio = audios[0];
|
|
664
|
+
result.type = 'separate';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
#selectAudio(options) {
|
|
674
|
+
const container = options.format || options.container || null;
|
|
675
|
+
const audios = this.#all
|
|
676
|
+
.filter((f) => f.isAudio && f.hasUrl)
|
|
677
|
+
.sort((a, b) => b.bitrate - a.bitrate);
|
|
678
|
+
|
|
679
|
+
if (!audios.length) throw new FormatError('No audio formats available');
|
|
680
|
+
|
|
681
|
+
if (container) {
|
|
682
|
+
const match = audios.find((f) => f.container === container);
|
|
683
|
+
if (match) return { format: match, type: 'audio', audio: null };
|
|
684
|
+
|
|
685
|
+
const best = audios[0];
|
|
686
|
+
throw new FormatError(
|
|
687
|
+
`No audio format in "${container}" available. Best available: ${best.container} (${best.qualityLabel}). ` +
|
|
688
|
+
`Use { merge: { tool: 'ffmpeg', output: 'file.${container}' } } to convert.`,
|
|
689
|
+
{
|
|
690
|
+
sourceContainer: best.container,
|
|
691
|
+
targetContainer: container,
|
|
692
|
+
requiresConversion: true,
|
|
693
|
+
suggestedTool: 'ffmpeg',
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return { format: audios[0], type: 'audio', audio: null };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
#lastResort(container, options) {
|
|
702
|
+
if (this.#combined.length) {
|
|
703
|
+
return { format: this.#combined[0], type: 'combined' };
|
|
704
|
+
}
|
|
705
|
+
const video = this.#adaptive.find((f) => f.isVideo && f.hasUrl);
|
|
706
|
+
if (video) {
|
|
707
|
+
const audio = this.#adaptive.find((f) => f.isAudio && f.hasUrl);
|
|
708
|
+
return { format: video, type: audio ? 'separate' : 'video-only', audio };
|
|
709
|
+
}
|
|
710
|
+
throw new FormatError('No compatible formats found for this video');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#sortFn(preferMp4) {
|
|
714
|
+
return (a, b) => {
|
|
715
|
+
if (preferMp4) {
|
|
716
|
+
const aMp4 = a.container === 'mp4' ? 1 : 0;
|
|
717
|
+
const bMp4 = b.container === 'mp4' ? 1 : 0;
|
|
718
|
+
if (aMp4 !== bMp4) return bMp4 - aMp4;
|
|
719
|
+
}
|
|
720
|
+
return b.qualityRank - a.qualityRank;
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
module.exports = { FormatSelector };
|
|
726
|
+
|
|
727
|
+
// utils/validators.js
|
|
728
|
+
const { ValidationError } = require('../core/Errors');
|
|
729
|
+
|
|
730
|
+
const VALID_QUALITIES = ['4320p', '2160p', '1440p', '1080p', '720p', '480p', '360p', '240p', '144p', 'auto', 'best', 'audio'];
|
|
731
|
+
const VALID_CONTAINERS = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'm4a', 'aac', 'flac', 'ogg', 'mp3', 'wav'];
|
|
732
|
+
|
|
733
|
+
function validateOptions(options = {}) {
|
|
734
|
+
const errors = [];
|
|
735
|
+
|
|
736
|
+
if (options.quality && !VALID_QUALITIES.includes(String(options.quality).toLowerCase())) {
|
|
737
|
+
errors.push(`Invalid quality "${options.quality}". Valid: ${VALID_QUALITIES.join(', ')}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (options.format && !VALID_CONTAINERS.includes(String(options.format).toLowerCase())) {
|
|
741
|
+
errors.push(`Invalid format "${options.format}". Valid: ${VALID_CONTAINERS.join(', ')}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (options.filter && !['audioandvideo', 'videoonly', 'audioonly'].includes(options.filter)) {
|
|
745
|
+
errors.push('filter must be "audioandvideo", "videoonly", or "audioonly"');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (options.merge) {
|
|
749
|
+
if (typeof options.merge !== 'object' || Array.isArray(options.merge)) {
|
|
750
|
+
errors.push('merge must be an object with optional { tool, path, output }');
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (errors.length) {
|
|
755
|
+
throw new ValidationError(`Invalid options:\n - ${errors.join('\n - ')}`, { errors });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
quality: String(options.quality || 'auto').toLowerCase(),
|
|
760
|
+
format: options.format ? String(options.format).toLowerCase() : null,
|
|
761
|
+
filter: options.filter || 'audioandvideo',
|
|
762
|
+
preferMp4: options.preferMp4 !== false,
|
|
763
|
+
merge: options.merge || null,
|
|
764
|
+
concurrency: Math.min(Math.max(parseInt(options.concurrency) || 6, 1), 12),
|
|
765
|
+
onProgress: typeof options.onProgress === 'function' ? options.onProgress : null,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
module.exports = { validateOptions, VALID_QUALITIES, VALID_CONTAINERS };
|
|
770
|
+
|
|
771
|
+
// utils/url.js
|
|
772
|
+
const { URL } = require('node:url');
|
|
773
|
+
|
|
774
|
+
function extractVideoId(input) {
|
|
775
|
+
if (!input) return null;
|
|
776
|
+
|
|
777
|
+
const trimmed = String(input).trim();
|
|
778
|
+
|
|
779
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) {
|
|
780
|
+
return trimmed;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
const u = new URL(trimmed);
|
|
785
|
+
const host = u.hostname.replace('www.', '').replace('m.', '');
|
|
786
|
+
|
|
787
|
+
if (host === 'youtu.be') {
|
|
788
|
+
return u.pathname.replace(/^\//, '').split(/[?&#]/)[0] || null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (host === 'youtube.com') {
|
|
792
|
+
if (u.pathname.startsWith('/embed/') || u.pathname.startsWith('/shorts/') || u.pathname.startsWith('/live/')) {
|
|
793
|
+
return u.pathname.split('/')[2]?.split(/[?&#]/)[0] || null;
|
|
794
|
+
}
|
|
795
|
+
return u.searchParams.get('v') || null;
|
|
796
|
+
}
|
|
797
|
+
} catch {}
|
|
798
|
+
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function isValidVideoId(id) {
|
|
803
|
+
return /^[a-zA-Z0-9_-]{11}$/.test(id);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
module.exports = { extractVideoId, isValidVideoId };
|
|
807
|
+
|
|
808
|
+
// download/Merge.js
|
|
809
|
+
const fs = require('node:fs');
|
|
810
|
+
const { spawn } = require('node:child_process');
|
|
811
|
+
const { MergeError } = require('../core/Errors');
|
|
812
|
+
|
|
813
|
+
const TOOLS = {
|
|
814
|
+
ffmpeg: {
|
|
815
|
+
cmd: 'ffmpeg',
|
|
816
|
+
label: 'FFmpeg',
|
|
817
|
+
check: () => {
|
|
818
|
+
return new Promise((resolve) => {
|
|
819
|
+
const proc = spawn('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
820
|
+
proc.on('error', () => resolve(false));
|
|
821
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
avconv: {
|
|
826
|
+
cmd: 'avconv',
|
|
827
|
+
label: 'avconv (Libav)',
|
|
828
|
+
check: () => {
|
|
829
|
+
return new Promise((resolve) => {
|
|
830
|
+
const proc = spawn('avconv', ['-version'], { stdio: 'ignore' });
|
|
831
|
+
proc.on('error', () => resolve(false));
|
|
832
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
833
|
+
});
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
async function detectTool(name) {
|
|
839
|
+
if (name) {
|
|
840
|
+
const tool = TOOLS[name.toLowerCase()];
|
|
841
|
+
if (!tool) throw new MergeError(`Unknown merge tool "${name}". Supported: ${Object.keys(TOOLS).join(', ')}`);
|
|
842
|
+
const available = await tool.check();
|
|
843
|
+
if (!available) throw new MergeError(`"${tool.label}" not found in PATH. Install it or provide a custom path.`);
|
|
844
|
+
return tool;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
for (const [key, tool] of Object.entries(TOOLS)) {
|
|
848
|
+
if (await tool.check()) return tool;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function buildArgs(tool, videoPath, audioPath, outputPath) {
|
|
855
|
+
const cmd = tool.cmd || 'ffmpeg';
|
|
856
|
+
return {
|
|
857
|
+
cmd,
|
|
858
|
+
args: [
|
|
859
|
+
'-i', videoPath,
|
|
860
|
+
'-i', audioPath,
|
|
861
|
+
'-c:v', 'copy',
|
|
862
|
+
'-c:a', 'aac',
|
|
863
|
+
'-shortest',
|
|
864
|
+
'-y',
|
|
865
|
+
outputPath,
|
|
866
|
+
],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function merge(videoPath, audioPath, outputPath, options = {}) {
|
|
871
|
+
const toolName = options.tool || null;
|
|
872
|
+
const customPath = options.path || null;
|
|
873
|
+
|
|
874
|
+
let tool;
|
|
875
|
+
if (customPath) {
|
|
876
|
+
tool = { cmd: customPath, label: customPath };
|
|
877
|
+
} else {
|
|
878
|
+
tool = await detectTool(toolName);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (!tool) {
|
|
882
|
+
throw new MergeError(
|
|
883
|
+
'No merge/conversion tool detected. Install FFmpeg (https://ffmpeg.org) or avconv. ' +
|
|
884
|
+
'Alternatively, use { merge: { path: "/path/to/ffmpeg" } } to specify a custom binary.'
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const { cmd, args } = buildArgs(tool, videoPath, audioPath, outputPath);
|
|
889
|
+
|
|
890
|
+
return new Promise((resolve, reject) => {
|
|
891
|
+
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
892
|
+
let stderr = '';
|
|
893
|
+
|
|
894
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
895
|
+
proc.on('close', (code) => {
|
|
896
|
+
if (code === 0) resolve(outputPath);
|
|
897
|
+
else reject(new MergeError(`Merge failed (exit ${code}): ${stderr.slice(-300)}`));
|
|
898
|
+
});
|
|
899
|
+
proc.on('error', (err) => reject(new MergeError(`Failed to start ${cmd}: ${err.message}`)));
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function checkAvailable(toolName) {
|
|
904
|
+
const tool = await detectTool(toolName);
|
|
905
|
+
return !!tool;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
module.exports = {
|
|
909
|
+
merge,
|
|
910
|
+
detectTool,
|
|
911
|
+
checkAvailable,
|
|
912
|
+
TOOLS,
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// download/Downloader.js
|
|
916
|
+
const fs = require('node:fs');
|
|
917
|
+
const https = require('node:https');
|
|
918
|
+
const { URL } = require('node:url');
|
|
919
|
+
const { head, stream } = require('../core/Client');
|
|
920
|
+
const { NetworkError } = require('../core/Errors');
|
|
921
|
+
|
|
922
|
+
const CHUNK_SIZE = 10 * 1024 * 1024;
|
|
923
|
+
const MAX_CONCURRENCY = 6;
|
|
924
|
+
|
|
925
|
+
async function verify(url) {
|
|
926
|
+
return head(url);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function createReadStream(url) {
|
|
930
|
+
return stream(url);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function downloadToFile(url, filePath, options = {}) {
|
|
934
|
+
const concurrency = options.concurrency || MAX_CONCURRENCY;
|
|
935
|
+
const onProgress = options.onProgress || null;
|
|
936
|
+
const chunkSize = options.chunkSize || CHUNK_SIZE;
|
|
937
|
+
|
|
938
|
+
const size = await getContentLength(url);
|
|
939
|
+
|
|
940
|
+
if (!size || size < chunkSize) {
|
|
941
|
+
return simpleDownload(url, filePath, onProgress);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return parallelDownload(url, filePath, size, concurrency, chunkSize, onProgress);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function getContentLength(url) {
|
|
948
|
+
return new Promise((resolve) => {
|
|
949
|
+
const u = new URL(url);
|
|
950
|
+
const req = https.get({
|
|
951
|
+
hostname: u.hostname,
|
|
952
|
+
path: u.pathname + u.search,
|
|
953
|
+
method: 'GET',
|
|
954
|
+
headers: {
|
|
955
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
956
|
+
'Referer': 'https://www.youtube.com/',
|
|
957
|
+
'Range': 'bytes=0-0',
|
|
958
|
+
},
|
|
959
|
+
}, (res) => {
|
|
960
|
+
const cr = res.headers['content-range'];
|
|
961
|
+
if (cr) {
|
|
962
|
+
const match = cr.match(/\/(\d+)/);
|
|
963
|
+
if (match) resolve(parseInt(match[1], 10));
|
|
964
|
+
}
|
|
965
|
+
resolve(parseInt(res.headers['content-length'] || '0', 10));
|
|
966
|
+
res.resume();
|
|
967
|
+
});
|
|
968
|
+
req.on('error', () => resolve(0));
|
|
969
|
+
req.setTimeout(10000, () => { req.destroy(); resolve(0); });
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function simpleDownload(url, filePath, onProgress, redirects = 0) {
|
|
974
|
+
return new Promise((resolve, reject) => {
|
|
975
|
+
if (redirects > 5) return reject(new NetworkError('Too many redirects'));
|
|
976
|
+
const u = new URL(url);
|
|
977
|
+
const req = https.get({
|
|
978
|
+
hostname: u.hostname,
|
|
979
|
+
path: u.pathname + u.search,
|
|
980
|
+
headers: {
|
|
981
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
982
|
+
'Referer': 'https://www.youtube.com/',
|
|
983
|
+
},
|
|
984
|
+
}, (res) => {
|
|
985
|
+
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
|
|
986
|
+
res.resume();
|
|
987
|
+
return resolve(simpleDownload(res.headers.location, filePath, onProgress, redirects + 1));
|
|
988
|
+
}
|
|
989
|
+
if (res.statusCode !== 200) {
|
|
990
|
+
return reject(new NetworkError(`Download failed with HTTP ${res.statusCode}`));
|
|
991
|
+
}
|
|
992
|
+
const total = parseInt(res.headers['content-length'] || '0', 10);
|
|
993
|
+
let downloaded = 0;
|
|
994
|
+
const out = fs.createWriteStream(filePath);
|
|
995
|
+
|
|
996
|
+
res.on('data', (chunk) => {
|
|
997
|
+
downloaded += chunk.length;
|
|
998
|
+
if (onProgress && total) onProgress(downloaded, total);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
res.pipe(out);
|
|
1002
|
+
out.on('finish', () => resolve(filePath));
|
|
1003
|
+
out.on('error', reject);
|
|
1004
|
+
});
|
|
1005
|
+
req.on('error', reject);
|
|
1006
|
+
req.setTimeout(300000, () => { req.destroy(new Error('Download timed out')); });
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function parallelDownload(url, filePath, totalSize, concurrency, chunkSize, onProgress) {
|
|
1011
|
+
const actualConcurrency = Math.min(concurrency, MAX_CONCURRENCY);
|
|
1012
|
+
const count = Math.min(actualConcurrency, Math.ceil(totalSize / chunkSize));
|
|
1013
|
+
const actualChunkSize = Math.ceil(totalSize / count);
|
|
1014
|
+
|
|
1015
|
+
const ranges = Array.from({ length: count }, (_, i) => ({
|
|
1016
|
+
start: i * actualChunkSize,
|
|
1017
|
+
end: i === count - 1 ? totalSize - 1 : (i + 1) * actualChunkSize - 1,
|
|
1018
|
+
}));
|
|
1019
|
+
|
|
1020
|
+
return new Promise(async (resolve, reject) => {
|
|
1021
|
+
try {
|
|
1022
|
+
const buffers = await Promise.all(
|
|
1023
|
+
ranges.map((r, i) => downloadChunk(url, r.start, r.end, i + 1, count))
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
if (onProgress) onProgress(totalSize, totalSize);
|
|
1027
|
+
|
|
1028
|
+
const full = Buffer.concat(buffers);
|
|
1029
|
+
fs.writeFileSync(filePath, full);
|
|
1030
|
+
resolve(filePath);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
reject(err);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function downloadChunk(url, start, end, index, total, redirects = 0) {
|
|
1038
|
+
return new Promise((resolve, reject) => {
|
|
1039
|
+
if (redirects > 5) return reject(new NetworkError('Too many redirects'));
|
|
1040
|
+
const u = new URL(url);
|
|
1041
|
+
const req = https.get({
|
|
1042
|
+
hostname: u.hostname,
|
|
1043
|
+
path: u.pathname + u.search,
|
|
1044
|
+
headers: {
|
|
1045
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
1046
|
+
'Referer': 'https://www.youtube.com/',
|
|
1047
|
+
'Range': `bytes=${start}-${end}`,
|
|
1048
|
+
},
|
|
1049
|
+
}, (res) => {
|
|
1050
|
+
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
|
|
1051
|
+
res.resume();
|
|
1052
|
+
return resolve(downloadChunk(res.headers.location, start, end, index, total, redirects + 1));
|
|
1053
|
+
}
|
|
1054
|
+
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
|
1055
|
+
return reject(new NetworkError(`Chunk HTTP ${res.statusCode}`));
|
|
1056
|
+
}
|
|
1057
|
+
const chunks = [];
|
|
1058
|
+
res.on('data', (c) => chunks.push(c));
|
|
1059
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
1060
|
+
});
|
|
1061
|
+
req.on('error', reject);
|
|
1062
|
+
req.setTimeout(120000, () => { req.destroy(new Error(`Chunk ${index} timed out`)); });
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
module.exports = {
|
|
1067
|
+
verify,
|
|
1068
|
+
createReadStream,
|
|
1069
|
+
downloadToFile,
|
|
1070
|
+
getContentLength,
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
// download/Stream.js
|
|
1074
|
+
const { Transform } = require('node:stream');
|
|
1075
|
+
const { createReadStream } = require('./Downloader');
|
|
1076
|
+
|
|
1077
|
+
class DownloadStream extends Transform {
|
|
1078
|
+
#url;
|
|
1079
|
+
#bytesRead;
|
|
1080
|
+
#startTime;
|
|
1081
|
+
#onProgress;
|
|
1082
|
+
|
|
1083
|
+
constructor(url, options = {}) {
|
|
1084
|
+
super({ highWaterMark: 1024 * 1024 });
|
|
1085
|
+
this.#url = url;
|
|
1086
|
+
this.#bytesRead = 0;
|
|
1087
|
+
this.#startTime = Date.now();
|
|
1088
|
+
this.#onProgress = options.onProgress || null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
_transform(chunk, encoding, callback) {
|
|
1092
|
+
this.#bytesRead += chunk.length;
|
|
1093
|
+
if (this.#onProgress) {
|
|
1094
|
+
this.#onProgress({
|
|
1095
|
+
bytes: this.#bytesRead,
|
|
1096
|
+
elapsed: Date.now() - this.#startTime,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
this.push(chunk);
|
|
1100
|
+
callback();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
_flush(callback) {
|
|
1104
|
+
callback();
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
get bytesRead() {
|
|
1108
|
+
return this.#bytesRead;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
get elapsed() {
|
|
1112
|
+
return Date.now() - this.#startTime;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function createStream(url, options = {}) {
|
|
1117
|
+
const transform = new DownloadStream(url, options);
|
|
1118
|
+
const source = createReadStream(url);
|
|
1119
|
+
|
|
1120
|
+
source.on('error', (err) => transform.destroy(err));
|
|
1121
|
+
source.on('response', () => {});
|
|
1122
|
+
source.pipe(transform);
|
|
1123
|
+
|
|
1124
|
+
return transform;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
module.exports = { DownloadStream, createStream };
|
|
1128
|
+
|
|
1129
|
+
// utils/constants.js
|
|
1130
|
+
// ../package.json
|
|
1131
|
+
{
|
|
1132
|
+
"name": "yt-direct",
|
|
1133
|
+
"version": "1.0.0",
|
|
1134
|
+
"description": "Hello, I present to you a module to download YouTube videos directly",
|
|
1135
|
+
"main": "dist/index.js",
|
|
1136
|
+
"types": "dist/index.d.ts",
|
|
1137
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
1138
|
+
"scripts": {
|
|
1139
|
+
"build": "node build/build.js",
|
|
1140
|
+
"prepublishOnly": "npm run build",
|
|
1141
|
+
"test": "node test/basic.js",
|
|
1142
|
+
"example": "node examples/basic.js"
|
|
1143
|
+
},
|
|
1144
|
+
"keywords": [
|
|
1145
|
+
"youtube",
|
|
1146
|
+
"download",
|
|
1147
|
+
"video",
|
|
1148
|
+
"yt-dlp",
|
|
1149
|
+
"innertube",
|
|
1150
|
+
"downloader",
|
|
1151
|
+
"ytdl",
|
|
1152
|
+
"mp4",
|
|
1153
|
+
"stream",
|
|
1154
|
+
"no-dependencies"
|
|
1155
|
+
],
|
|
1156
|
+
"license": "MIT",
|
|
1157
|
+
"engines": {
|
|
1158
|
+
"node": ">=18.0.0"
|
|
1159
|
+
},
|
|
1160
|
+
"repository": {
|
|
1161
|
+
"type": "git",
|
|
1162
|
+
"url": "https://github.com/SoyMaycol/yt-direct.git"
|
|
1163
|
+
},
|
|
1164
|
+
"bugs": {
|
|
1165
|
+
"url": "https://github.com/SoyMaycol/yt-direct/issues"
|
|
1166
|
+
},
|
|
1167
|
+
"homepage": "https://github.com/SoyMaycol/yt-direct#readme",
|
|
1168
|
+
"author": "SoyMaycol"
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const pkg = require('../../package.json');
|
|
1172
|
+
|
|
1173
|
+
module.exports = {
|
|
1174
|
+
VERSION: pkg.version,
|
|
1175
|
+
NAME: pkg.name,
|
|
1176
|
+
HOMEPAGE: pkg.homepage,
|
|
1177
|
+
MAX_CONCURRENCY: 6,
|
|
1178
|
+
DEFAULT_TIMEOUT: 20000,
|
|
1179
|
+
DOWNLOAD_TIMEOUT: 300000,
|
|
1180
|
+
CHUNK_SIZE: 10 * 1024 * 1024,
|
|
1181
|
+
YOUTUBE_HOST: 'www.youtube.com',
|
|
1182
|
+
INNERTUBE_KEY: 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
|
|
1183
|
+
SUPPORTED_PROTOCOLS: ['http:', 'https:'],
|
|
1184
|
+
INTEGRITY_SEED: 'yt-direct-v1',
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
const { fetch } = require('./core/InnerTube');
|
|
1188
|
+
const { head } = require('./core/Client');
|
|
1189
|
+
const { YouTubeError, FormatError, ValidationError } = require('./core/Errors');
|
|
1190
|
+
const { FormatSelector } = require('./formats/Selector');
|
|
1191
|
+
const { Format } = require('./formats/Format');
|
|
1192
|
+
const { resolveContainer, requiresConversion, QUALITY_TIERS, CONTAINER_MAP } = require('./formats/Registry');
|
|
1193
|
+
const { validateOptions } = require('./utils/validators');
|
|
1194
|
+
const { extractVideoId } = require('./utils/url');
|
|
1195
|
+
const { merge } = require('./download/Merge');
|
|
1196
|
+
const { downloadToFile, createReadStream, verify } = require('./download/Downloader');
|
|
1197
|
+
const { createStream } = require('./download/Stream');
|
|
1198
|
+
const { VERSION } = require('./utils/constants');
|
|
1199
|
+
|
|
1200
|
+
function ytdl(input, options = {}) {
|
|
1201
|
+
const videoId = extractVideoId(input);
|
|
1202
|
+
if (!videoId) {
|
|
1203
|
+
return Promise.reject(new ValidationError(`Invalid YouTube URL or video ID: "${input}"`));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const normalized = validateOptions(options);
|
|
1207
|
+
|
|
1208
|
+
return (async () => {
|
|
1209
|
+
const info = await getInfo(videoId);
|
|
1210
|
+
const selector = new FormatSelector(info.streamingData);
|
|
1211
|
+
const selected = selector.select(normalized);
|
|
1212
|
+
|
|
1213
|
+
const format = selected.format;
|
|
1214
|
+
const audio = selected.audio || null;
|
|
1215
|
+
|
|
1216
|
+
const response = {
|
|
1217
|
+
videoId,
|
|
1218
|
+
title: info.title || 'video',
|
|
1219
|
+
url: format.url,
|
|
1220
|
+
format,
|
|
1221
|
+
audio,
|
|
1222
|
+
type: selected.type,
|
|
1223
|
+
stream: () => createStream(format.url),
|
|
1224
|
+
pipe: (writable) => createStream(format.url).pipe(writable),
|
|
1225
|
+
download: async (filePath) => {
|
|
1226
|
+
if (!filePath) {
|
|
1227
|
+
const ext = normalized.format || format.container || 'mp4';
|
|
1228
|
+
filePath = `${sanitize(info.title || 'video')}.${ext}`;
|
|
1229
|
+
}
|
|
1230
|
+
return downloadToFile(format.url, filePath, {
|
|
1231
|
+
concurrency: normalized.concurrency,
|
|
1232
|
+
onProgress: normalized.onProgress,
|
|
1233
|
+
});
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
if (normalized.merge && audio) {
|
|
1238
|
+
response.merge = async (outputPath) => {
|
|
1239
|
+
if (!outputPath) {
|
|
1240
|
+
const ext = normalized.format || 'mkv';
|
|
1241
|
+
outputPath = `${sanitize(info.title || 'video')}.${ext}`;
|
|
1242
|
+
}
|
|
1243
|
+
const tmpVideo = `/tmp/yt-direct-${format.itag}-video`;
|
|
1244
|
+
const tmpAudio = `/tmp/yt-direct-${audio.itag}-audio`;
|
|
1245
|
+
try {
|
|
1246
|
+
await Promise.all([
|
|
1247
|
+
downloadToFile(format.url, tmpVideo),
|
|
1248
|
+
downloadToFile(audio.url, tmpAudio),
|
|
1249
|
+
]);
|
|
1250
|
+
await merge(tmpVideo, tmpAudio, outputPath, normalized.merge);
|
|
1251
|
+
return outputPath;
|
|
1252
|
+
} finally {
|
|
1253
|
+
try { require('node:fs').unlinkSync(tmpVideo); } catch {}
|
|
1254
|
+
try { require('node:fs').unlinkSync(tmpAudio); } catch {}
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return response;
|
|
1260
|
+
})();
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function getInfo(input) {
|
|
1264
|
+
const videoId = extractVideoId(input);
|
|
1265
|
+
if (!videoId) throw new ValidationError(`Invalid YouTube URL or video ID: "${input}"`);
|
|
1266
|
+
|
|
1267
|
+
const result = await fetch(videoId);
|
|
1268
|
+
const selector = new FormatSelector(result.streamingData);
|
|
1269
|
+
|
|
1270
|
+
return {
|
|
1271
|
+
id: videoId,
|
|
1272
|
+
title: result.videoDetails.title || 'Unknown',
|
|
1273
|
+
author: result.videoDetails.author || result.videoDetails?.channelId || null,
|
|
1274
|
+
duration: parseInt(result.videoDetails.lengthSeconds || '0', 10),
|
|
1275
|
+
thumbnails: result.videoDetails.thumbnail?.thumbnails || [],
|
|
1276
|
+
description: result.videoDetails.shortDescription || '',
|
|
1277
|
+
viewCount: parseInt(result.videoDetails.viewCount || '0', 10),
|
|
1278
|
+
isLive: result.videoDetails.isLive === true,
|
|
1279
|
+
streamingData: result.streamingData,
|
|
1280
|
+
formats: selector.list(),
|
|
1281
|
+
combined: selector.combined.map((f) => f.toJSON()),
|
|
1282
|
+
adaptive: selector.adaptive.map((f) => f.toJSON()),
|
|
1283
|
+
clientUsed: result.clientUsed,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function sanitize(name) {
|
|
1288
|
+
return String(name || 'video').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, ' ').trim() || 'video';
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
ytdl.getInfo = getInfo;
|
|
1292
|
+
ytdl.getFormats = (input) => getInfo(input).then((i) => i.formats);
|
|
1293
|
+
ytdl.verifyURL = verify;
|
|
1294
|
+
ytdl.createStream = createStream;
|
|
1295
|
+
ytdl.version = VERSION;
|
|
1296
|
+
|
|
1297
|
+
ytdl.FORMATS = Object.keys(CONTAINER_MAP);
|
|
1298
|
+
ytdl.QUALITIES = [...QUALITY_TIERS, 'auto', 'best', 'audio'];
|
|
1299
|
+
ytdl.FormatError = FormatError;
|
|
1300
|
+
ytdl.YouTubeError = YouTubeError;
|
|
1301
|
+
ytdl.ValidationError = ValidationError;
|
|
1302
|
+
|
|
1303
|
+
module.exports = ytdl;
|
|
1304
|
+
module.exports.default = ytdl;
|