frostpv 1.0.10 → 1.0.11
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/index.js +117 -246
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require("path");
|
|
|
4
4
|
const os = require("os");
|
|
5
5
|
const FormData = require("form-data");
|
|
6
6
|
const crypto = require("crypto");
|
|
7
|
-
const { igdl, ttdl, fbdown, mediafire, capcut, gdrive, pinterest
|
|
7
|
+
const { igdl, ttdl, fbdown, mediafire, capcut, gdrive, pinterest } = require("btch-downloader");
|
|
8
8
|
const { TwitterDL } = require("twitter-downloader");
|
|
9
9
|
const btch = require("btch-downloader");
|
|
10
10
|
const btchOld = require("btch-downloader-old");
|
|
@@ -21,10 +21,10 @@ ffmpeg.setFfmpegPath(pathToFfmpeg.ffmpegPath);
|
|
|
21
21
|
|
|
22
22
|
// Output directory is the caller's working directory (keeps files next to the running app)
|
|
23
23
|
const OUTPUT_DIR = process.cwd();
|
|
24
|
-
try { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); } catch (_) {
|
|
24
|
+
try { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); } catch (_) {}
|
|
25
25
|
// Keep an OS temp directory for any future transient needs
|
|
26
26
|
const TEMP_DIR = path.join(os.tmpdir(), "downloader-dl-bot");
|
|
27
|
-
try { fs.mkdirSync(TEMP_DIR, { recursive: true }); } catch (_) {
|
|
27
|
+
try { fs.mkdirSync(TEMP_DIR, { recursive: true }); } catch (_) {}
|
|
28
28
|
// TTL (minutes) for files in OUTPUT_DIR (audio/video) before they are pruned
|
|
29
29
|
const OUTPUT_RETENTION_MIN = Number(process.env.OUTPUT_RETENTION_MIN || 5);
|
|
30
30
|
|
|
@@ -58,13 +58,7 @@ const videoPlatforms = [
|
|
|
58
58
|
"https://twitter.com",
|
|
59
59
|
"https://www.twitter.com",
|
|
60
60
|
"https://vm.tiktok.com/",
|
|
61
|
-
"https://vt.tiktok.com/"
|
|
62
|
-
"https://www.youtube.com",
|
|
63
|
-
"https://youtube.com",
|
|
64
|
-
"https://youtu.be",
|
|
65
|
-
"https://m.youtube.com",
|
|
66
|
-
"https://www.youtube.com/watch?"
|
|
67
|
-
|
|
61
|
+
"https://vt.tiktok.com/"
|
|
68
62
|
];
|
|
69
63
|
|
|
70
64
|
// Blacklist links
|
|
@@ -79,7 +73,7 @@ const isVideoLink = (link) => {
|
|
|
79
73
|
const u = new URL(link);
|
|
80
74
|
const host = (u.hostname || '').toLowerCase();
|
|
81
75
|
if (host.endsWith('pintere21313213st.com')) return true;
|
|
82
|
-
} catch (_) {
|
|
76
|
+
} catch (_) {}
|
|
83
77
|
return videoPlatforms.some(platform => link.startsWith(platform));
|
|
84
78
|
};
|
|
85
79
|
|
|
@@ -152,14 +146,14 @@ async function safeUnlinkWithRetry(filePath, maxRetries = 3) {
|
|
|
152
146
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
153
147
|
try {
|
|
154
148
|
const resolvedPath = path.resolve(filePath);
|
|
155
|
-
|
|
149
|
+
|
|
156
150
|
if (fs.existsSync(resolvedPath)) {
|
|
157
151
|
// Check if file is in use (Windows)
|
|
158
152
|
if (process.platform === 'win32' && isFileInUse(resolvedPath)) {
|
|
159
153
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
160
154
|
continue;
|
|
161
155
|
}
|
|
162
|
-
|
|
156
|
+
|
|
163
157
|
fs.rmSync(resolvedPath, { force: true });
|
|
164
158
|
return true;
|
|
165
159
|
} else {
|
|
@@ -231,7 +225,7 @@ async function validateVideoUrl(url, platform) {
|
|
|
231
225
|
return status >= 200 && status < 400; // Accept redirects
|
|
232
226
|
}
|
|
233
227
|
});
|
|
234
|
-
|
|
228
|
+
|
|
235
229
|
return true;
|
|
236
230
|
} catch (error) {
|
|
237
231
|
return false;
|
|
@@ -243,18 +237,18 @@ function validateTwitterUrl(url) {
|
|
|
243
237
|
try {
|
|
244
238
|
// Remove query parameters that might cause issues
|
|
245
239
|
const cleanUrl = url.split('?')[0];
|
|
246
|
-
|
|
240
|
+
|
|
247
241
|
// Ensure it's a valid Twitter/X URL format
|
|
248
242
|
if (!cleanUrl.includes('x.com') && !cleanUrl.includes('twitter.com')) {
|
|
249
243
|
throw new Error("Not a valid Twitter/X URL");
|
|
250
244
|
}
|
|
251
|
-
|
|
245
|
+
|
|
252
246
|
// Check if it has the required structure
|
|
253
247
|
const urlParts = cleanUrl.split('/');
|
|
254
248
|
if (urlParts.length < 5) {
|
|
255
249
|
throw new Error("Invalid Twitter/X URL structure");
|
|
256
250
|
}
|
|
257
|
-
|
|
251
|
+
|
|
258
252
|
return cleanUrl;
|
|
259
253
|
} catch (error) {
|
|
260
254
|
throw new Error(`Twitter URL validation failed: ${error.message}`);
|
|
@@ -293,10 +287,10 @@ async function downloadYoutubeAudio(url, outputPath) {
|
|
|
293
287
|
try {
|
|
294
288
|
const ytdlp = new YtDlp({ ffmpegPath });
|
|
295
289
|
const cookiesPath = getYoutubeCookiesPath();
|
|
296
|
-
|
|
290
|
+
|
|
297
291
|
// Garantir que o nome do arquivo tenha a extensão correta
|
|
298
292
|
const baseName = outputPath.replace(/\.[^/.]+$/, ""); // Remove extensão se existir
|
|
299
|
-
|
|
293
|
+
|
|
300
294
|
// Primeiro, tentar com a API de opções
|
|
301
295
|
try {
|
|
302
296
|
const options = {
|
|
@@ -307,11 +301,11 @@ async function downloadYoutubeAudio(url, outputPath) {
|
|
|
307
301
|
},
|
|
308
302
|
output: outputPath
|
|
309
303
|
};
|
|
310
|
-
|
|
304
|
+
|
|
311
305
|
if (cookiesPath) {
|
|
312
306
|
options.cookies = cookiesPath;
|
|
313
307
|
}
|
|
314
|
-
|
|
308
|
+
|
|
315
309
|
// Remover logs detalhados para download de áudio do YouTube
|
|
316
310
|
const result = await ytdlp.downloadAsync(url, options);
|
|
317
311
|
// Verificar se o arquivo foi criado
|
|
@@ -320,7 +314,7 @@ async function downloadYoutubeAudio(url, outputPath) {
|
|
|
320
314
|
resolve(actualFileName);
|
|
321
315
|
return;
|
|
322
316
|
}
|
|
323
|
-
|
|
317
|
+
|
|
324
318
|
// Tentar encontrar o arquivo com extensão diferente
|
|
325
319
|
const files = fs.readdirSync('./');
|
|
326
320
|
const downloadedFile = files.find(file => file.startsWith(baseName.split('/').pop()));
|
|
@@ -331,18 +325,18 @@ async function downloadYoutubeAudio(url, outputPath) {
|
|
|
331
325
|
} catch (apiError) {
|
|
332
326
|
console.log('API method failed, trying direct args method:', apiError.message);
|
|
333
327
|
}
|
|
334
|
-
|
|
328
|
+
|
|
335
329
|
// Fallback: usar argumentos diretos para máxima qualidade de áudio
|
|
336
330
|
const args = [
|
|
337
331
|
url,
|
|
338
332
|
'-f', 'bestaudio[ext=mp3]/bestaudio',
|
|
339
333
|
'-o', outputPath
|
|
340
334
|
];
|
|
341
|
-
|
|
335
|
+
|
|
342
336
|
if (cookiesPath) {
|
|
343
337
|
args.push('--cookies', cookiesPath);
|
|
344
338
|
}
|
|
345
|
-
|
|
339
|
+
|
|
346
340
|
// Remover logs detalhados para download de áudio do YouTube
|
|
347
341
|
const result = await ytdlp.execAsync(args);
|
|
348
342
|
// Verificar se o arquivo foi criado
|
|
@@ -369,53 +363,25 @@ async function downloadYoutubeAudio(url, outputPath) {
|
|
|
369
363
|
// Enhanced function to get platform type from URL
|
|
370
364
|
function getPlatformType(url) {
|
|
371
365
|
const lowerUrl = url.toLowerCase();
|
|
372
|
-
|
|
366
|
+
|
|
373
367
|
if (lowerUrl.includes("instagram.com") || lowerUrl.includes("instagr.am")) return "instagram";
|
|
374
368
|
if (lowerUrl.includes("tiktok.com") || lowerUrl.includes("vm.tiktok.com") || lowerUrl.includes("vt.tiktok.com")) return "tiktok";
|
|
375
369
|
if (lowerUrl.includes("facebook.com") || lowerUrl.includes("fb.watch") || lowerUrl.includes("fb.com")) return "facebook";
|
|
376
370
|
if (lowerUrl.includes("mediafire.com")) return "mediafire";
|
|
377
371
|
if (lowerUrl.includes("x.com") || lowerUrl.includes("twitter.com")) return "twitter";
|
|
378
|
-
if (lowerUrl.includes("youtube.com") || lowerUrl.includes("
|
|
379
|
-
|
|
372
|
+
if (lowerUrl.includes("youtube.com") || lowerUrl.includes("you112t12u.be") || lowerUrl.includes("m.y11outu314be.com")) return "youtu1354be";
|
|
373
|
+
|
|
380
374
|
return "unknown";
|
|
381
375
|
}
|
|
382
376
|
|
|
383
|
-
// Function to get direct video URL from Twitter using yt-dlp
|
|
384
|
-
async function downloadTwitterWithYtDlp(url) {
|
|
385
|
-
return new Promise(async (resolve, reject) => {
|
|
386
|
-
try {
|
|
387
|
-
const ytdlp = new YtDlp({ ffmpegPath });
|
|
388
|
-
// Use -g to get URL, -f "best[ext=mp4]/best" to get best quality single file if possible
|
|
389
|
-
const args = [
|
|
390
|
-
url,
|
|
391
|
-
'-g',
|
|
392
|
-
'-f', 'best[ext=mp4]/best'
|
|
393
|
-
];
|
|
394
|
-
|
|
395
|
-
const result = await ytdlp.execAsync(args);
|
|
396
|
-
// yt-dlp might return multiple lines if it finds separate audio/video streams
|
|
397
|
-
// We take the first one, but for Twitter it is usually a single mp4 file
|
|
398
|
-
const videoUrl = result.trim().split('\n')[0];
|
|
399
|
-
|
|
400
|
-
if (videoUrl && videoUrl.startsWith('http')) {
|
|
401
|
-
resolve(videoUrl);
|
|
402
|
-
} else {
|
|
403
|
-
reject(new Error('yt-dlp did not return a valid URL'));
|
|
404
|
-
}
|
|
405
|
-
} catch (error) {
|
|
406
|
-
reject(error);
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
377
|
// Enhanced fallback download function with platform-specific methods
|
|
412
378
|
async function tryFallbackDownload(url, maxRetries = 3) {
|
|
413
379
|
const platform = getPlatformType(url);
|
|
414
|
-
|
|
380
|
+
|
|
415
381
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
416
382
|
try {
|
|
417
383
|
let videoUrl = null;
|
|
418
|
-
|
|
384
|
+
|
|
419
385
|
// Platform-specific fallback methods
|
|
420
386
|
switch (platform) {
|
|
421
387
|
case "instagram": {
|
|
@@ -461,7 +427,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
461
427
|
}
|
|
462
428
|
}
|
|
463
429
|
];
|
|
464
|
-
|
|
430
|
+
|
|
465
431
|
for (const method of methods) {
|
|
466
432
|
try {
|
|
467
433
|
videoUrl = await method();
|
|
@@ -472,7 +438,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
472
438
|
}
|
|
473
439
|
break;
|
|
474
440
|
}
|
|
475
|
-
|
|
441
|
+
|
|
476
442
|
case "tiktok": {
|
|
477
443
|
// Try multiple TikTok methods
|
|
478
444
|
const methods = [
|
|
@@ -507,7 +473,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
507
473
|
throw new Error("No valid video in Tiktok.Downloader v2 response");
|
|
508
474
|
}
|
|
509
475
|
];
|
|
510
|
-
|
|
476
|
+
|
|
511
477
|
for (const method of methods) {
|
|
512
478
|
try {
|
|
513
479
|
videoUrl = await method();
|
|
@@ -518,7 +484,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
518
484
|
}
|
|
519
485
|
break;
|
|
520
486
|
}
|
|
521
|
-
|
|
487
|
+
|
|
522
488
|
case "facebook": {
|
|
523
489
|
// Try multiple Facebook methods
|
|
524
490
|
const methods = [
|
|
@@ -542,7 +508,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
542
508
|
throw new Error("No valid video in fbdown response with custom headers");
|
|
543
509
|
}
|
|
544
510
|
];
|
|
545
|
-
|
|
511
|
+
|
|
546
512
|
for (const method of methods) {
|
|
547
513
|
try {
|
|
548
514
|
videoUrl = await method();
|
|
@@ -553,46 +519,42 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
553
519
|
}
|
|
554
520
|
break;
|
|
555
521
|
}
|
|
556
|
-
|
|
522
|
+
|
|
557
523
|
case "twitter": {
|
|
558
|
-
// Try
|
|
524
|
+
// Try multiple Twitter methods with better response handling
|
|
559
525
|
const methods = [
|
|
560
|
-
async () => {
|
|
561
|
-
// Priority 1: yt-dlp
|
|
562
|
-
return await downloadTwitterWithYtDlp(url);
|
|
563
|
-
},
|
|
564
526
|
async () => {
|
|
565
527
|
const cleanUrl = validateTwitterUrl(url);
|
|
566
528
|
const data = await TwitterDL(cleanUrl, {});
|
|
567
|
-
|
|
529
|
+
|
|
568
530
|
// Check multiple possible response structures
|
|
569
531
|
if (data && data.result) {
|
|
570
532
|
// Structure 1: data.result.media[0].videos[0].url
|
|
571
|
-
if (data.result.media && data.result.media[0] &&
|
|
572
|
-
|
|
573
|
-
|
|
533
|
+
if (data.result.media && data.result.media[0] &&
|
|
534
|
+
data.result.media[0].videos && data.result.media[0].videos[0] &&
|
|
535
|
+
data.result.media[0].videos[0].url) {
|
|
574
536
|
return data.result.media[0].videos[0].url;
|
|
575
537
|
}
|
|
576
|
-
|
|
538
|
+
|
|
577
539
|
// Structure 2: data.result.video[0].url
|
|
578
540
|
if (data.result.video && data.result.video[0] && data.result.video[0].url) {
|
|
579
541
|
return data.result.video[0].url;
|
|
580
542
|
}
|
|
581
|
-
|
|
543
|
+
|
|
582
544
|
// Structure 3: data.result.url (direct URL)
|
|
583
545
|
if (data.result.url) {
|
|
584
546
|
return data.result.url;
|
|
585
547
|
}
|
|
586
|
-
|
|
548
|
+
|
|
587
549
|
// Structure 4: data.result.media[0].url
|
|
588
550
|
if (data.result.media && data.result.media[0] && data.result.media[0].url) {
|
|
589
551
|
return data.result.media[0].url;
|
|
590
552
|
}
|
|
591
|
-
|
|
553
|
+
|
|
592
554
|
// Structure 5: Check for any video URL in the entire response
|
|
593
555
|
const findVideoUrl = (obj) => {
|
|
594
|
-
if (typeof obj === 'string' && obj.includes('http') &&
|
|
595
|
-
|
|
556
|
+
if (typeof obj === 'string' && obj.includes('http') &&
|
|
557
|
+
(obj.includes('.mp4') || obj.includes('video') || obj.includes('media'))) {
|
|
596
558
|
return obj;
|
|
597
559
|
}
|
|
598
560
|
if (typeof obj === 'object' && obj !== null) {
|
|
@@ -603,13 +565,13 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
603
565
|
}
|
|
604
566
|
return null;
|
|
605
567
|
};
|
|
606
|
-
|
|
568
|
+
|
|
607
569
|
const foundUrl = findVideoUrl(data.result);
|
|
608
570
|
if (foundUrl) {
|
|
609
571
|
return foundUrl;
|
|
610
572
|
}
|
|
611
573
|
}
|
|
612
|
-
|
|
574
|
+
|
|
613
575
|
throw new Error("No valid video URL found in TwitterDL response structure");
|
|
614
576
|
},
|
|
615
577
|
async () => {
|
|
@@ -625,30 +587,30 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
625
587
|
'Upgrade-Insecure-Requests': '1'
|
|
626
588
|
}
|
|
627
589
|
});
|
|
628
|
-
|
|
590
|
+
|
|
629
591
|
// Same structure checking as above
|
|
630
592
|
if (data && data.result) {
|
|
631
|
-
if (data.result.media && data.result.media[0] &&
|
|
632
|
-
|
|
633
|
-
|
|
593
|
+
if (data.result.media && data.result.media[0] &&
|
|
594
|
+
data.result.media[0].videos && data.result.media[0].videos[0] &&
|
|
595
|
+
data.result.media[0].videos[0].url) {
|
|
634
596
|
return data.result.media[0].videos[0].url;
|
|
635
597
|
}
|
|
636
|
-
|
|
598
|
+
|
|
637
599
|
if (data.result.video && data.result.video[0] && data.result.video[0].url) {
|
|
638
600
|
return data.result.video[0].url;
|
|
639
601
|
}
|
|
640
|
-
|
|
602
|
+
|
|
641
603
|
if (data.result.url) {
|
|
642
604
|
return data.result.url;
|
|
643
605
|
}
|
|
644
|
-
|
|
606
|
+
|
|
645
607
|
if (data.result.media && data.result.media[0] && data.result.media[0].url) {
|
|
646
608
|
return data.result.media[0].url;
|
|
647
609
|
}
|
|
648
|
-
|
|
610
|
+
|
|
649
611
|
const findVideoUrl = (obj) => {
|
|
650
|
-
if (typeof obj === 'string' && obj.includes('http') &&
|
|
651
|
-
|
|
612
|
+
if (typeof obj === 'string' && obj.includes('http') &&
|
|
613
|
+
(obj.includes('.mp4') || obj.includes('video') || obj.includes('media'))) {
|
|
652
614
|
return obj;
|
|
653
615
|
}
|
|
654
616
|
if (typeof obj === 'object' && obj !== null) {
|
|
@@ -659,13 +621,13 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
659
621
|
}
|
|
660
622
|
return null;
|
|
661
623
|
};
|
|
662
|
-
|
|
624
|
+
|
|
663
625
|
const foundUrl = findVideoUrl(data.result);
|
|
664
626
|
if (foundUrl) {
|
|
665
627
|
return foundUrl;
|
|
666
628
|
}
|
|
667
629
|
}
|
|
668
|
-
|
|
630
|
+
|
|
669
631
|
throw new Error("No valid video URL found in TwitterDL response with custom headers");
|
|
670
632
|
},
|
|
671
633
|
async () => {
|
|
@@ -701,9 +663,9 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
701
663
|
const data = await btchOld[funcName](cleanUrl);
|
|
702
664
|
// Check multiple possible response structures
|
|
703
665
|
if (data && data.result) {
|
|
704
|
-
if (data.result.media && data.result.media[0] &&
|
|
705
|
-
|
|
706
|
-
|
|
666
|
+
if (data.result.media && data.result.media[0] &&
|
|
667
|
+
data.result.media[0].videos && data.result.media[0].videos[0] &&
|
|
668
|
+
data.result.media[0].videos[0].url) {
|
|
707
669
|
return data.result.media[0].videos[0].url;
|
|
708
670
|
}
|
|
709
671
|
if (data.result.video && data.result.video[0] && data.result.video[0].url) {
|
|
@@ -730,7 +692,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
730
692
|
}
|
|
731
693
|
}
|
|
732
694
|
];
|
|
733
|
-
|
|
695
|
+
|
|
734
696
|
for (const method of methods) {
|
|
735
697
|
try {
|
|
736
698
|
videoUrl = await method();
|
|
@@ -741,12 +703,12 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
741
703
|
}
|
|
742
704
|
break;
|
|
743
705
|
}
|
|
744
|
-
|
|
706
|
+
|
|
745
707
|
case "youtube": {
|
|
746
708
|
// YouTube doesn't need fallback - it has its own specific method
|
|
747
709
|
throw new Error("YouTube downloads should use the primary method, not fallback");
|
|
748
710
|
}
|
|
749
|
-
|
|
711
|
+
|
|
750
712
|
case "mediafire": {
|
|
751
713
|
// Try MediaFire method
|
|
752
714
|
try {
|
|
@@ -784,10 +746,10 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
784
746
|
};
|
|
785
747
|
mediaUrl = pickFrom(data);
|
|
786
748
|
if (mediaUrl) return mediaUrl;
|
|
787
|
-
} catch (_) {
|
|
749
|
+
} catch (_) {}
|
|
788
750
|
break;
|
|
789
751
|
}
|
|
790
|
-
|
|
752
|
+
|
|
791
753
|
default: {
|
|
792
754
|
// Generic fallback for unknown platforms
|
|
793
755
|
try {
|
|
@@ -803,18 +765,18 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
803
765
|
break;
|
|
804
766
|
}
|
|
805
767
|
}
|
|
806
|
-
|
|
768
|
+
|
|
807
769
|
// If we got a video URL, validate it
|
|
808
770
|
if (videoUrl && videoUrl.includes("http")) {
|
|
809
771
|
// Validate the URL is accessible
|
|
810
772
|
try {
|
|
811
|
-
const response = await axios.head(videoUrl, {
|
|
773
|
+
const response = await axios.head(videoUrl, {
|
|
812
774
|
timeout: 10000,
|
|
813
775
|
headers: {
|
|
814
776
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
815
777
|
}
|
|
816
778
|
});
|
|
817
|
-
|
|
779
|
+
|
|
818
780
|
if (response.status === 200) {
|
|
819
781
|
return videoUrl;
|
|
820
782
|
} else {
|
|
@@ -826,7 +788,7 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
826
788
|
} else {
|
|
827
789
|
throw new Error("No valid video URL obtained from any fallback method");
|
|
828
790
|
}
|
|
829
|
-
|
|
791
|
+
|
|
830
792
|
} catch (error) {
|
|
831
793
|
if (attempt === maxRetries) {
|
|
832
794
|
throw new Error(`Enhanced fallback method failed after ${maxRetries} attempts for ${platform}: ${error.message}`);
|
|
@@ -873,8 +835,9 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
873
835
|
|
|
874
836
|
// Verificar se é YouTube e lançar erro customizado
|
|
875
837
|
const platform = getPlatformType(url);
|
|
876
|
-
|
|
877
|
-
|
|
838
|
+
if (platform === "youtube") {
|
|
839
|
+
throw new Error("This URL is not from a supported platform. Supported platforms: Instagram, X(Twitter), TikTok, Facebook");
|
|
840
|
+
}
|
|
878
841
|
|
|
879
842
|
await cleanupTempFiles(); // Clean up previous temp files
|
|
880
843
|
|
|
@@ -886,21 +849,21 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
886
849
|
downloadedFilePath = await downloadSmartVideo(url, config);
|
|
887
850
|
} catch (error) {
|
|
888
851
|
const platform = getPlatformType(url);
|
|
889
|
-
|
|
890
|
-
// YouTube doesn't use fallback method - it has its own specific implementation
|
|
852
|
+
|
|
853
|
+
// YouTube doesn't use fallback method - it has its own specific implementation
|
|
891
854
|
if (platform === "youtube") {
|
|
892
855
|
throw new Error(`YouTube download failed: ${error.message}`);
|
|
893
856
|
}
|
|
894
|
-
|
|
857
|
+
|
|
895
858
|
try {
|
|
896
859
|
const fallbackUrl = await tryFallbackDownload(url);
|
|
897
|
-
|
|
860
|
+
|
|
898
861
|
// Validate fallback URL
|
|
899
862
|
const isValid = await validateVideoUrl(fallbackUrl, platform);
|
|
900
863
|
if (!isValid) {
|
|
901
864
|
throw new Error("Fallback URL validation failed");
|
|
902
865
|
}
|
|
903
|
-
|
|
866
|
+
|
|
904
867
|
downloadedFilePath = await downloadDirectVideo(fallbackUrl, config);
|
|
905
868
|
} catch (fallbackError) {
|
|
906
869
|
throw new Error(`Failed to download video with both methods: ${fallbackError.message}`);
|
|
@@ -1023,15 +986,15 @@ async function downloadSmartVideo(url, config) {
|
|
|
1023
986
|
throw new Error("No video URLs found in Facebook response");
|
|
1024
987
|
}
|
|
1025
988
|
videoUrl = data.Normal_video || data.HD;
|
|
1026
|
-
|
|
989
|
+
|
|
1027
990
|
// Validate the URL is accessible
|
|
1028
|
-
const response = await axios.head(videoUrl, {
|
|
991
|
+
const response = await axios.head(videoUrl, {
|
|
1029
992
|
timeout: 10000,
|
|
1030
993
|
headers: {
|
|
1031
994
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
1032
995
|
}
|
|
1033
996
|
}).catch(() => null);
|
|
1034
|
-
|
|
997
|
+
|
|
1035
998
|
if (!response || response.status !== 200) {
|
|
1036
999
|
throw new Error("Facebook video URL is not accessible");
|
|
1037
1000
|
}
|
|
@@ -1103,12 +1066,12 @@ async function downloadSmartVideo(url, config) {
|
|
|
1103
1066
|
// Validate and clean the Twitter URL
|
|
1104
1067
|
const cleanUrl = validateTwitterUrl(url);
|
|
1105
1068
|
const data = await TwitterDL(cleanUrl, {});
|
|
1106
|
-
|
|
1069
|
+
|
|
1107
1070
|
if (data && data.result) {
|
|
1108
1071
|
// Try multiple possible response structures
|
|
1109
|
-
if (data.result.media && data.result.media[0] &&
|
|
1110
|
-
|
|
1111
|
-
|
|
1072
|
+
if (data.result.media && data.result.media[0] &&
|
|
1073
|
+
data.result.media[0].videos && data.result.media[0].videos[0] &&
|
|
1074
|
+
data.result.media[0].videos[0].url) {
|
|
1112
1075
|
videoUrl = data.result.media[0].videos[0].url;
|
|
1113
1076
|
} else if (data.result.video && data.result.video[0] && data.result.video[0].url) {
|
|
1114
1077
|
videoUrl = data.result.video[0].url;
|
|
@@ -1119,8 +1082,8 @@ async function downloadSmartVideo(url, config) {
|
|
|
1119
1082
|
} else {
|
|
1120
1083
|
// Search for any video URL in the response
|
|
1121
1084
|
const findVideoUrl = (obj) => {
|
|
1122
|
-
if (typeof obj === 'string' && obj.includes('http') &&
|
|
1123
|
-
|
|
1085
|
+
if (typeof obj === 'string' && obj.includes('http') &&
|
|
1086
|
+
(obj.includes('.mp4') || obj.includes('video') || obj.includes('media'))) {
|
|
1124
1087
|
return obj;
|
|
1125
1088
|
}
|
|
1126
1089
|
if (typeof obj === 'object' && obj !== null) {
|
|
@@ -1131,7 +1094,7 @@ async function downloadSmartVideo(url, config) {
|
|
|
1131
1094
|
}
|
|
1132
1095
|
return null;
|
|
1133
1096
|
};
|
|
1134
|
-
|
|
1097
|
+
|
|
1135
1098
|
const foundUrl = findVideoUrl(data.result);
|
|
1136
1099
|
if (foundUrl) {
|
|
1137
1100
|
videoUrl = foundUrl;
|
|
@@ -1147,98 +1110,6 @@ async function downloadSmartVideo(url, config) {
|
|
|
1147
1110
|
}
|
|
1148
1111
|
break;
|
|
1149
1112
|
}
|
|
1150
|
-
case "youtube": {
|
|
1151
|
-
try {
|
|
1152
|
-
const data = await youtube(url);
|
|
1153
|
-
// Try to find the video URL in the response
|
|
1154
|
-
|
|
1155
|
-
if (!data) throw new Error("No data returned from youtube downloader");
|
|
1156
|
-
|
|
1157
|
-
// Helper to check if a value looks like a video URL
|
|
1158
|
-
const isVideoUrl = (val) => typeof val === 'string' && val.startsWith('http') && !val.includes('.mp3');
|
|
1159
|
-
|
|
1160
|
-
let bestUrl = null;
|
|
1161
|
-
let bestScore = -1;
|
|
1162
|
-
|
|
1163
|
-
// Function to score keys based on quality
|
|
1164
|
-
const getScore = (key) => {
|
|
1165
|
-
const k = key.toLowerCase();
|
|
1166
|
-
if (k.includes('1080')) return 100;
|
|
1167
|
-
if (k.includes('720')) return 80;
|
|
1168
|
-
if (k.includes('480')) return 60;
|
|
1169
|
-
if (k.includes('hd')) return 90;
|
|
1170
|
-
if (k.includes('sd')) return 50;
|
|
1171
|
-
if (k.includes('360')) return 40;
|
|
1172
|
-
if (k.includes('mp4')) return 30;
|
|
1173
|
-
if (k.includes('video')) return 20;
|
|
1174
|
-
return 10;
|
|
1175
|
-
};
|
|
1176
|
-
|
|
1177
|
-
// Recursive function to gather all candidates
|
|
1178
|
-
const gatherCandidates = (obj) => {
|
|
1179
|
-
if (!obj) return;
|
|
1180
|
-
|
|
1181
|
-
if (typeof obj === 'object') {
|
|
1182
|
-
for (const key in obj) {
|
|
1183
|
-
const val = obj[key];
|
|
1184
|
-
if (isVideoUrl(val)) {
|
|
1185
|
-
const score = getScore(key);
|
|
1186
|
-
if (score > bestScore) {
|
|
1187
|
-
bestScore = score;
|
|
1188
|
-
bestUrl = val;
|
|
1189
|
-
}
|
|
1190
|
-
} else if (typeof val === 'object') {
|
|
1191
|
-
// Check if this object represents a format (e.g. { quality: '720p', url: '...' })
|
|
1192
|
-
if (val.url && isVideoUrl(val.url)) {
|
|
1193
|
-
let score = getScore(key); // Score from key name
|
|
1194
|
-
if (val.quality || val.resolution) {
|
|
1195
|
-
score = Math.max(score, getScore(String(val.quality || val.resolution)));
|
|
1196
|
-
}
|
|
1197
|
-
if (score > bestScore) {
|
|
1198
|
-
bestScore = score;
|
|
1199
|
-
bestUrl = val.url;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
// Recurse
|
|
1203
|
-
gatherCandidates(val);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
};
|
|
1208
|
-
|
|
1209
|
-
gatherCandidates(data);
|
|
1210
|
-
|
|
1211
|
-
// If no scored candidate found, try direct simple extraction with priority keys
|
|
1212
|
-
if (!bestUrl) {
|
|
1213
|
-
const priorities = ['mp4', 'url', 'link', 'download', 'video'];
|
|
1214
|
-
const findSimple = (obj) => {
|
|
1215
|
-
if (!obj) return null;
|
|
1216
|
-
for (const key of priorities) {
|
|
1217
|
-
if (obj[key] && isVideoUrl(obj[key])) return obj[key];
|
|
1218
|
-
}
|
|
1219
|
-
for (const key in obj) {
|
|
1220
|
-
if (typeof obj[key] === 'object') {
|
|
1221
|
-
const found = findSimple(obj[key]);
|
|
1222
|
-
if (found) return found;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
return null;
|
|
1226
|
-
};
|
|
1227
|
-
bestUrl = findSimple(data);
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
videoUrl = bestUrl;
|
|
1231
|
-
|
|
1232
|
-
if (!videoUrl) {
|
|
1233
|
-
console.log('YouTube data dump:', JSON.stringify(data, null, 2)); // Debug log since we can't test
|
|
1234
|
-
throw new Error("Could not extract video URL from YouTube response");
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
} catch (error) {
|
|
1238
|
-
throw new Error(`YouTube download failed: ${error.message}`);
|
|
1239
|
-
}
|
|
1240
|
-
break;
|
|
1241
|
-
}
|
|
1242
1113
|
default:
|
|
1243
1114
|
throw new Error("Platform not supported or invalid link.");
|
|
1244
1115
|
}
|
|
@@ -1246,21 +1117,21 @@ async function downloadSmartVideo(url, config) {
|
|
|
1246
1117
|
if (!videoUrl || !videoUrl.includes("http")) {
|
|
1247
1118
|
throw new Error("Returned video URL is invalid or unavailable.");
|
|
1248
1119
|
}
|
|
1249
|
-
|
|
1120
|
+
|
|
1250
1121
|
// Download the video with better error handling
|
|
1251
|
-
const response = await axios({
|
|
1252
|
-
url: videoUrl,
|
|
1253
|
-
method: "GET",
|
|
1122
|
+
const response = await axios({
|
|
1123
|
+
url: videoUrl,
|
|
1124
|
+
method: "GET",
|
|
1254
1125
|
responseType: "stream",
|
|
1255
1126
|
timeout: 30000,
|
|
1256
1127
|
headers: {
|
|
1257
1128
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
1258
1129
|
}
|
|
1259
1130
|
});
|
|
1260
|
-
|
|
1131
|
+
|
|
1261
1132
|
// Create minimal unique file name in output dir
|
|
1262
|
-
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1263
|
-
|
|
1133
|
+
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,4)}.mp4`);
|
|
1134
|
+
|
|
1264
1135
|
const videoWriter = fs.createWriteStream(fileName);
|
|
1265
1136
|
response.data.pipe(videoWriter);
|
|
1266
1137
|
|
|
@@ -1285,19 +1156,19 @@ async function downloadSmartVideo(url, config) {
|
|
|
1285
1156
|
// Function for direct video download
|
|
1286
1157
|
async function downloadDirectVideo(url, config) {
|
|
1287
1158
|
try {
|
|
1288
|
-
const response = await axios({
|
|
1289
|
-
url: url,
|
|
1290
|
-
method: "GET",
|
|
1159
|
+
const response = await axios({
|
|
1160
|
+
url: url,
|
|
1161
|
+
method: "GET",
|
|
1291
1162
|
responseType: "stream",
|
|
1292
1163
|
timeout: 30000,
|
|
1293
1164
|
headers: {
|
|
1294
1165
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
1295
1166
|
}
|
|
1296
1167
|
});
|
|
1297
|
-
|
|
1168
|
+
|
|
1298
1169
|
// Create minimal unique file name in output dir
|
|
1299
|
-
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1300
|
-
|
|
1170
|
+
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,4)}.mp4`);
|
|
1171
|
+
|
|
1301
1172
|
const videoWriter = fs.createWriteStream(fileName);
|
|
1302
1173
|
response.data.pipe(videoWriter);
|
|
1303
1174
|
|
|
@@ -1345,7 +1216,7 @@ async function downloadGenericFile(url, preferredExt = null) {
|
|
|
1345
1216
|
else ext = 'bin';
|
|
1346
1217
|
}
|
|
1347
1218
|
|
|
1348
|
-
const fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1219
|
+
const fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,4)}.${ext}`);
|
|
1349
1220
|
const writer = fs.createWriteStream(fileName);
|
|
1350
1221
|
response.data.pipe(writer);
|
|
1351
1222
|
|
|
@@ -1360,7 +1231,7 @@ async function downloadGenericFile(url, preferredExt = null) {
|
|
|
1360
1231
|
|
|
1361
1232
|
// Function to rotate video
|
|
1362
1233
|
async function rotateVideo(fileName, rotation) {
|
|
1363
|
-
const outputPath = path.join(OUTPUT_DIR, `vr_${uuidv4().slice(0,
|
|
1234
|
+
const outputPath = path.join(OUTPUT_DIR, `vr_${uuidv4().slice(0,4)}.mp4`);
|
|
1364
1235
|
let angle;
|
|
1365
1236
|
switch (rotation.toLowerCase()) {
|
|
1366
1237
|
case "left": angle = "transpose=2"; break;
|
|
@@ -1411,8 +1282,8 @@ async function cleanupTempFiles() {
|
|
|
1411
1282
|
try {
|
|
1412
1283
|
if (!fs.existsSync(TEMP_DIR)) return;
|
|
1413
1284
|
const files = fs.readdirSync(TEMP_DIR);
|
|
1414
|
-
const tempFiles = files.filter(file =>
|
|
1415
|
-
/^temp_video.*\.mp4$/.test(file) ||
|
|
1285
|
+
const tempFiles = files.filter(file =>
|
|
1286
|
+
/^temp_video.*\.mp4$/.test(file) ||
|
|
1416
1287
|
/_audio\.mp3$/.test(file) ||
|
|
1417
1288
|
/_rotated\.mp4$/.test(file) ||
|
|
1418
1289
|
/_cropped\.mp4$/.test(file) ||
|
|
@@ -1444,9 +1315,9 @@ async function cleanupOutputFiles() {
|
|
|
1444
1315
|
if (now - st.mtimeMs > maxAgeMs) {
|
|
1445
1316
|
await safeUnlinkWithRetry(full);
|
|
1446
1317
|
}
|
|
1447
|
-
} catch (_) {
|
|
1318
|
+
} catch (_) {}
|
|
1448
1319
|
}
|
|
1449
|
-
} catch (_) {
|
|
1320
|
+
} catch (_) {}
|
|
1450
1321
|
}
|
|
1451
1322
|
|
|
1452
1323
|
// Schedule periodic cleanup (every 10 minutes)
|
|
@@ -1460,7 +1331,7 @@ cleanupOutputFiles();
|
|
|
1460
1331
|
// Function to auto-crop video
|
|
1461
1332
|
async function autoCrop(fileName) {
|
|
1462
1333
|
const inputPath = fileName;
|
|
1463
|
-
const outputPath = path.join(OUTPUT_DIR, `vc_${uuidv4().slice(0,
|
|
1334
|
+
const outputPath = path.join(OUTPUT_DIR, `vc_${uuidv4().slice(0,4)}.mp4`);
|
|
1464
1335
|
|
|
1465
1336
|
return new Promise((resolve, reject) => {
|
|
1466
1337
|
let cropValues = null;
|
|
@@ -1468,13 +1339,13 @@ async function autoCrop(fileName) {
|
|
|
1468
1339
|
.outputOptions('-vf', 'cropdetect=24:16:0')
|
|
1469
1340
|
.outputFormat('null')
|
|
1470
1341
|
.output('-')
|
|
1471
|
-
.on('stderr', function
|
|
1342
|
+
.on('stderr', function(stderrLine) {
|
|
1472
1343
|
const cropMatch = stderrLine.match(/crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)/);
|
|
1473
1344
|
if (cropMatch) {
|
|
1474
1345
|
cropValues = `crop=${cropMatch[1]}:${cropMatch[2]}:${cropMatch[3]}:${cropMatch[4]}`;
|
|
1475
1346
|
}
|
|
1476
1347
|
})
|
|
1477
|
-
.on('end', function
|
|
1348
|
+
.on('end', function() {
|
|
1478
1349
|
if (!cropValues) {
|
|
1479
1350
|
resolve(inputPath);
|
|
1480
1351
|
return;
|
|
@@ -1507,7 +1378,7 @@ async function checkAndCompressVideo(filePath, limitSizeMB, platform = null) {
|
|
|
1507
1378
|
return filePath;
|
|
1508
1379
|
}
|
|
1509
1380
|
|
|
1510
|
-
const outputPath = path.join(OUTPUT_DIR, `vx_${uuidv4().slice(0,
|
|
1381
|
+
const outputPath = path.join(OUTPUT_DIR, `vx_${uuidv4().slice(0,4)}.mp4`);
|
|
1511
1382
|
|
|
1512
1383
|
return new Promise((resolve, reject) => {
|
|
1513
1384
|
ffmpeg(filePath)
|
|
@@ -1542,17 +1413,17 @@ async function convertVideoFormat(inputPath, targetFormat) {
|
|
|
1542
1413
|
const supported = ["mp4", "mov", "webm", "mkv"];
|
|
1543
1414
|
if (!supported.includes(fmt)) return inputPath;
|
|
1544
1415
|
|
|
1545
|
-
const outputPath = path.join(OUTPUT_DIR, `vf_${uuidv4().slice(0,
|
|
1416
|
+
const outputPath = path.join(OUTPUT_DIR, `vf_${uuidv4().slice(0,4)}.${fmt}`);
|
|
1546
1417
|
|
|
1547
1418
|
const ff = ffmpeg(inputPath);
|
|
1548
1419
|
switch (fmt) {
|
|
1549
1420
|
case "mp4":
|
|
1550
1421
|
case "mov":
|
|
1551
1422
|
case "mkv":
|
|
1552
|
-
ff.outputOptions("-c:v",
|
|
1423
|
+
ff.outputOptions("-c:v","libx264","-pix_fmt","yuv420p","-c:a","aac","-b:a","192k");
|
|
1553
1424
|
break;
|
|
1554
1425
|
case "webm":
|
|
1555
|
-
ff.outputOptions("-c:v",
|
|
1426
|
+
ff.outputOptions("-c:v","libvpx-vp9","-b:v","0","-crf","30","-c:a","libopus","-b:a","128k");
|
|
1556
1427
|
break;
|
|
1557
1428
|
default:
|
|
1558
1429
|
return inputPath;
|
|
@@ -1609,14 +1480,14 @@ async function unshortenUrl(url) {
|
|
|
1609
1480
|
return status >= 200 && status < 400; // Accept redirects
|
|
1610
1481
|
}
|
|
1611
1482
|
});
|
|
1612
|
-
|
|
1483
|
+
|
|
1613
1484
|
// Get the final URL after all redirects
|
|
1614
1485
|
const finalUrl = response.request.res.responseUrl || response.config.url;
|
|
1615
1486
|
return finalUrl;
|
|
1616
1487
|
}
|
|
1617
|
-
|
|
1488
|
+
|
|
1618
1489
|
// For other URLs, use the original method
|
|
1619
|
-
const response = await axios.head(url, {
|
|
1490
|
+
const response = await axios.head(url, {
|
|
1620
1491
|
maxRedirects: 10,
|
|
1621
1492
|
timeout: 10000,
|
|
1622
1493
|
headers: {
|
|
@@ -1685,10 +1556,10 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1685
1556
|
if (downloadedFilePath) {
|
|
1686
1557
|
// Extrair áudio em mp3
|
|
1687
1558
|
audioFilePath = await extractAudioMp3(downloadedFilePath);
|
|
1688
|
-
|
|
1559
|
+
|
|
1689
1560
|
// Remove o arquivo de vídeo temporário após extrair o áudio
|
|
1690
1561
|
await safeUnlinkWithRetry(downloadedFilePath);
|
|
1691
|
-
|
|
1562
|
+
|
|
1692
1563
|
const result = await uploadToGoFileIfNeeded(audioFilePath);
|
|
1693
1564
|
return result;
|
|
1694
1565
|
} else {
|
|
@@ -1704,7 +1575,7 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1704
1575
|
// Função para extrair áudio em mp3 usando ffmpeg
|
|
1705
1576
|
async function extractAudioMp3(videoPath) {
|
|
1706
1577
|
return new Promise((resolve, reject) => {
|
|
1707
|
-
const audioPath = path.join(OUTPUT_DIR, `a_${uuidv4().slice(0,
|
|
1578
|
+
const audioPath = path.join(OUTPUT_DIR, `a_${uuidv4().slice(0,4)}.mp3`);
|
|
1708
1579
|
ffmpeg(videoPath)
|
|
1709
1580
|
.noVideo()
|
|
1710
1581
|
.audioCodec('libmp3lame')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frostpv",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "downloads",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"license": "ISC",
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"axios": "1.12.0",
|
|
16
|
-
"btch-downloader": "6.0.
|
|
16
|
+
"btch-downloader": "6.0.25",
|
|
17
17
|
"btch-downloader-old": "npm:btch-downloader@4.0.15",
|
|
18
18
|
"express": "^5.1.0",
|
|
19
19
|
"ffmpeg-ffprobe-static": "^6.1.1-rc.5",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"path": "^0.12.7",
|
|
23
23
|
"twitter-downloader": "^1.1.8",
|
|
24
24
|
"uuid": "^11.1.0",
|
|
25
|
-
"@tobyg74/tiktok-api-dl": "^1.3.
|
|
25
|
+
"@tobyg74/tiktok-api-dl": "^1.3.7",
|
|
26
26
|
"ytdlp-nodejs": "2.3.4"
|
|
27
27
|
}
|
|
28
28
|
}
|