frostpv 1.0.24 → 1.0.26
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 +360 -99
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -27,12 +27,13 @@ try { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); } catch (_) { }
|
|
|
27
27
|
|
|
28
28
|
const TEMP_DIR = path.join(os.tmpdir(), "downloader-dl-bot");
|
|
29
29
|
try { fs.mkdirSync(TEMP_DIR, { recursive: true }); } catch (_) { }
|
|
30
|
-
const OUTPUT_RETENTION_MIN = Number(process.env.OUTPUT_RETENTION_MIN || 5);
|
|
30
|
+
const OUTPUT_RETENTION_MIN = Math.max(5, Number(process.env.OUTPUT_RETENTION_MIN || 5));
|
|
31
31
|
|
|
32
|
-
const GOFILE_API = "
|
|
32
|
+
const GOFILE_API = "Cs05tkrbwIb0vNdXJWI5nee869RDJG37";
|
|
33
33
|
const GOFILE_UPLOAD_URL = "https://upload.gofile.io/uploadfile";
|
|
34
34
|
const SIZE_LIMIT_MB = 10;
|
|
35
35
|
const SIZE_LIMIT_BYTES = SIZE_LIMIT_MB * 1024 * 1024;
|
|
36
|
+
const ABSOLUTE_MAX_BYTES = 1024 * 1024 * 1024; // 1GB limit to prevent RAM exhaustion
|
|
36
37
|
|
|
37
38
|
const videoPlatforms = [
|
|
38
39
|
"https://www.instagram.com",
|
|
@@ -173,7 +174,7 @@ async function safeUnlinkWithRetry(filePath, maxRetries = 3) {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
// Function to upload to GoFile if needed
|
|
176
|
-
async function uploadToGoFileIfNeeded(filePath) {
|
|
177
|
+
async function uploadToGoFileIfNeeded(filePath, signal = null) {
|
|
177
178
|
try {
|
|
178
179
|
const stats = fs.statSync(filePath);
|
|
179
180
|
const fileSizeInBytes = stats.size;
|
|
@@ -194,10 +195,11 @@ async function uploadToGoFileIfNeeded(filePath) {
|
|
|
194
195
|
headers: {
|
|
195
196
|
...form.getHeaders(),
|
|
196
197
|
"Authorization": `Bearer ${GOFILE_API}`,
|
|
197
|
-
"Content-Length": form.getLengthSync()
|
|
198
|
+
"Content-Length": form.getLengthSync()
|
|
198
199
|
},
|
|
199
200
|
maxContentLength: Infinity,
|
|
200
|
-
maxBodyLength: Infinity
|
|
201
|
+
maxBodyLength: Infinity,
|
|
202
|
+
signal: signal
|
|
201
203
|
});
|
|
202
204
|
|
|
203
205
|
if (response.data && response.data.status === "ok") {
|
|
@@ -761,6 +763,9 @@ async function tryFallbackDownload(url, maxRetries = 3) {
|
|
|
761
763
|
const MediaDownloader = async (url, options = {}) => {
|
|
762
764
|
assertAuthorized();
|
|
763
765
|
const config = { ...defaultConfig, ...options };
|
|
766
|
+
const signal = config.signal;
|
|
767
|
+
|
|
768
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
764
769
|
|
|
765
770
|
if (!url || !url.includes("http")) {
|
|
766
771
|
throw new Error("Please specify a valid video URL.");
|
|
@@ -796,20 +801,40 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
796
801
|
throw new Error("This URL is not from a supported platform. Supported platforms: Instagram, X(Twitter), TikTok, Facebook");
|
|
797
802
|
}
|
|
798
803
|
|
|
799
|
-
|
|
804
|
+
// No longer cleanup at start to avoid race conditions during concurrent downloads
|
|
805
|
+
// await cleanupTempFiles(); // Clean up previous temp files
|
|
800
806
|
|
|
801
807
|
let downloadedFilePath = null;
|
|
808
|
+
let primaryError = null;
|
|
802
809
|
|
|
803
810
|
try {
|
|
804
|
-
// Try primary download method
|
|
811
|
+
// 1. Try primary download method
|
|
805
812
|
try {
|
|
806
|
-
downloadedFilePath = await downloadSmartVideo(url, config);
|
|
813
|
+
downloadedFilePath = await downloadSmartVideo(url, config, signal);
|
|
814
|
+
|
|
815
|
+
// Verify integrity of primary download
|
|
816
|
+
if (downloadedFilePath) {
|
|
817
|
+
try {
|
|
818
|
+
const stats = fs.statSync(downloadedFilePath);
|
|
819
|
+
if (stats.size < 500) {
|
|
820
|
+
throw new Error(`Downloaded file is too small, likely corrupted.`);
|
|
821
|
+
}
|
|
822
|
+
} catch (statError) {
|
|
823
|
+
throw new Error(`Failed to verify file integrity`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
807
826
|
} catch (error) {
|
|
808
|
-
|
|
827
|
+
if (signal?.aborted || error.message?.includes("AbortError")) throw error;
|
|
828
|
+
primaryError = error;
|
|
829
|
+
downloadedFilePath = null; // Ensure null to trigger fallback
|
|
830
|
+
}
|
|
809
831
|
|
|
832
|
+
// 2. If primary failed OR file was invalid, try fallback
|
|
833
|
+
if (!downloadedFilePath) {
|
|
834
|
+
const platform = getPlatformType(url);
|
|
810
835
|
// YouTube doesn't use fallback method - it has its own specific implementation
|
|
811
836
|
if (platform === "youtube") {
|
|
812
|
-
throw new Error(`YouTube download failed: ${error
|
|
837
|
+
throw new Error(`YouTube download failed: ${primaryError?.message || 'Unknown error'}`);
|
|
813
838
|
}
|
|
814
839
|
|
|
815
840
|
try {
|
|
@@ -821,8 +846,19 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
821
846
|
throw new Error("Fallback URL validation failed");
|
|
822
847
|
}
|
|
823
848
|
|
|
824
|
-
downloadedFilePath = await downloadDirectVideo(fallbackUrl, config);
|
|
849
|
+
downloadedFilePath = await downloadDirectVideo(fallbackUrl, config, signal);
|
|
850
|
+
|
|
851
|
+
// Verify integrity of fallback download
|
|
852
|
+
if (downloadedFilePath) {
|
|
853
|
+
const stats = fs.statSync(downloadedFilePath);
|
|
854
|
+
if (stats.size < 500) {
|
|
855
|
+
await safeUnlinkWithRetry(downloadedFilePath); // Clean up bad file
|
|
856
|
+
throw new Error(`Fallback downloaded file is too small, likely corrupted.`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
825
860
|
} catch (fallbackError) {
|
|
861
|
+
// If both failed, throw a combined error
|
|
826
862
|
throw new Error(`Failed to download video with both methods: ${fallbackError.message}`);
|
|
827
863
|
}
|
|
828
864
|
}
|
|
@@ -831,10 +867,10 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
831
867
|
if (config.noGofile) {
|
|
832
868
|
return { type: "local", value: downloadedFilePath };
|
|
833
869
|
}
|
|
834
|
-
const result = await uploadToGoFileIfNeeded(downloadedFilePath);
|
|
870
|
+
const result = await uploadToGoFileIfNeeded(downloadedFilePath, signal);
|
|
835
871
|
return result;
|
|
836
872
|
} else {
|
|
837
|
-
throw new Error("Failed to obtain downloaded file path.");
|
|
873
|
+
throw new Error("Failed to obtain downloaded file path (both methods failed).");
|
|
838
874
|
}
|
|
839
875
|
} catch (error) {
|
|
840
876
|
// Clean up any remaining temp files on error
|
|
@@ -844,23 +880,27 @@ const MediaDownloader = async (url, options = {}) => {
|
|
|
844
880
|
};
|
|
845
881
|
|
|
846
882
|
// Function to process downloaded file
|
|
847
|
-
async function processDownloadedFile(fileName, config, platform = null) {
|
|
883
|
+
async function processDownloadedFile(fileName, config, platform = null, signal = null) {
|
|
848
884
|
let processedFile = path.resolve(fileName);
|
|
849
885
|
if (config.rotation) {
|
|
850
|
-
|
|
886
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
887
|
+
processedFile = await rotateVideo(processedFile, config.rotation, signal);
|
|
851
888
|
}
|
|
852
889
|
if (config.autocrop) {
|
|
853
|
-
|
|
890
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
891
|
+
processedFile = await autoCrop(processedFile, signal);
|
|
854
892
|
}
|
|
855
|
-
|
|
893
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
894
|
+
processedFile = await checkAndCompressVideo(processedFile, config.limitSizeMB, platform, signal);
|
|
856
895
|
if (config.outputFormat) {
|
|
857
|
-
|
|
896
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
897
|
+
processedFile = await convertVideoFormat(processedFile, String(config.outputFormat).toLowerCase(), signal);
|
|
858
898
|
}
|
|
859
899
|
return processedFile;
|
|
860
900
|
}
|
|
861
901
|
|
|
862
902
|
// Function for platform-specific video download
|
|
863
|
-
async function downloadSmartVideo(url, config) {
|
|
903
|
+
async function downloadSmartVideo(url, config, signal = null) {
|
|
864
904
|
try {
|
|
865
905
|
let videoUrl = null;
|
|
866
906
|
let platform = getPlatformType(url);
|
|
@@ -1094,21 +1134,80 @@ async function downloadSmartVideo(url, config) {
|
|
|
1094
1134
|
method: "GET",
|
|
1095
1135
|
responseType: "stream",
|
|
1096
1136
|
timeout: 30000,
|
|
1137
|
+
signal: signal,
|
|
1097
1138
|
headers: {
|
|
1098
1139
|
'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'
|
|
1099
1140
|
}
|
|
1100
1141
|
});
|
|
1101
1142
|
|
|
1143
|
+
// Check content-length early if available
|
|
1144
|
+
const contentLength = parseInt(response.headers['content-length']);
|
|
1145
|
+
if (!isNaN(contentLength) && contentLength > ABSOLUTE_MAX_BYTES) {
|
|
1146
|
+
throw new Error(`Video is too large (${(contentLength / (1024 * 1024)).toFixed(2)}MB). Max limit is 1GB.`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (!isNaN(contentLength) && contentLength < 200) {
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1102
1152
|
// Create minimal unique file name in output dir
|
|
1103
|
-
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1153
|
+
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp4`);
|
|
1104
1154
|
|
|
1105
1155
|
const videoWriter = fs.createWriteStream(fileName);
|
|
1156
|
+
|
|
1157
|
+
// Byte counter to abort if stream exceeds limit
|
|
1158
|
+
let downloadedBytes = 0;
|
|
1159
|
+
response.data.on('data', (chunk) => {
|
|
1160
|
+
downloadedBytes += chunk.length;
|
|
1161
|
+
if (downloadedBytes > ABSOLUTE_MAX_BYTES) {
|
|
1162
|
+
response.data.destroy(); // Stop the download
|
|
1163
|
+
videoWriter.destroy();
|
|
1164
|
+
safeUnlink(fileName);
|
|
1165
|
+
// We can't easily throw from here to the outer scope, but the writer error/finish will handle it
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
// Catch errors on the incoming source stream
|
|
1170
|
+
response.data.on('error', (err) => {
|
|
1171
|
+
videoWriter.destroy();
|
|
1172
|
+
safeUnlink(fileName);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1106
1175
|
response.data.pipe(videoWriter);
|
|
1107
1176
|
|
|
1177
|
+
if (signal) {
|
|
1178
|
+
if (signal.aborted) {
|
|
1179
|
+
response.data.destroy();
|
|
1180
|
+
videoWriter.destroy();
|
|
1181
|
+
safeUnlink(fileName);
|
|
1182
|
+
throw new Error("AbortError");
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1108
1186
|
return new Promise((resolve, reject) => {
|
|
1187
|
+
// Handle abort inside the Promise so reject is in scope
|
|
1188
|
+
if (signal) {
|
|
1189
|
+
signal.addEventListener('abort', () => {
|
|
1190
|
+
response.data.destroy();
|
|
1191
|
+
videoWriter.destroy();
|
|
1192
|
+
safeUnlink(fileName);
|
|
1193
|
+
reject(new Error("AbortError"));
|
|
1194
|
+
}, { once: true });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1109
1197
|
videoWriter.on("finish", async () => {
|
|
1110
1198
|
try {
|
|
1111
|
-
|
|
1199
|
+
if (downloadedBytes > ABSOLUTE_MAX_BYTES) {
|
|
1200
|
+
throw new Error("Video exceeded 1GB limit.");
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Check if file is empty
|
|
1204
|
+
const stats = fs.statSync(fileName);
|
|
1205
|
+
if (stats.size < 200) {
|
|
1206
|
+
await safeUnlinkWithRetry(fileName);
|
|
1207
|
+
throw new Error("Downloaded file is too small or corrupted (likely download failed)");
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const finalFilePath = await processDownloadedFile(fileName, config, platform, signal);
|
|
1112
1211
|
triggerGC(); // Limpar memória após processamento pesado
|
|
1113
1212
|
resolve(finalFilePath);
|
|
1114
1213
|
|
|
@@ -1127,7 +1226,7 @@ async function downloadSmartVideo(url, config) {
|
|
|
1127
1226
|
|
|
1128
1227
|
|
|
1129
1228
|
// Function for direct video download
|
|
1130
|
-
async function downloadDirectVideo(url, config) {
|
|
1229
|
+
async function downloadDirectVideo(url, config, signal = null) {
|
|
1131
1230
|
try {
|
|
1132
1231
|
const response = await axios({
|
|
1133
1232
|
url: url,
|
|
@@ -1139,16 +1238,64 @@ async function downloadDirectVideo(url, config) {
|
|
|
1139
1238
|
}
|
|
1140
1239
|
});
|
|
1141
1240
|
|
|
1241
|
+
// Check content-length early if available
|
|
1242
|
+
const contentLength = parseInt(response.headers['content-length']);
|
|
1243
|
+
if (!isNaN(contentLength) && contentLength > ABSOLUTE_MAX_BYTES) {
|
|
1244
|
+
throw new Error(`Video is too large (${(contentLength / (1024 * 1024)).toFixed(2)}MB). Max limit is 1GB.`);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!isNaN(contentLength) && contentLength < 200) {
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1142
1250
|
// Create minimal unique file name in output dir
|
|
1143
|
-
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1251
|
+
let fileName = path.join(OUTPUT_DIR, `v_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp4`);
|
|
1144
1252
|
|
|
1145
1253
|
const videoWriter = fs.createWriteStream(fileName);
|
|
1254
|
+
|
|
1255
|
+
// Byte counter
|
|
1256
|
+
let downloadedBytes = 0;
|
|
1257
|
+
response.data.on('data', (chunk) => {
|
|
1258
|
+
downloadedBytes += chunk.length;
|
|
1259
|
+
if (downloadedBytes > ABSOLUTE_MAX_BYTES) {
|
|
1260
|
+
response.data.destroy();
|
|
1261
|
+
videoWriter.destroy();
|
|
1262
|
+
safeUnlink(fileName);
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// Catch errors on the incoming source stream
|
|
1267
|
+
response.data.on('error', (err) => {
|
|
1268
|
+
videoWriter.destroy();
|
|
1269
|
+
safeUnlink(fileName);
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1146
1272
|
response.data.pipe(videoWriter);
|
|
1147
1273
|
|
|
1148
1274
|
return new Promise((resolve, reject) => {
|
|
1275
|
+
// Handle abort inside the Promise so reject is in scope
|
|
1276
|
+
if (signal) {
|
|
1277
|
+
signal.addEventListener('abort', () => {
|
|
1278
|
+
response.data.destroy();
|
|
1279
|
+
videoWriter.destroy();
|
|
1280
|
+
safeUnlink(fileName);
|
|
1281
|
+
reject(new Error("AbortError"));
|
|
1282
|
+
}, { once: true });
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1149
1285
|
videoWriter.on("finish", async () => {
|
|
1150
1286
|
try {
|
|
1151
|
-
|
|
1287
|
+
if (downloadedBytes > ABSOLUTE_MAX_BYTES) {
|
|
1288
|
+
throw new Error("Video exceeded 1GB limit.");
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Check if file is empty
|
|
1292
|
+
const stats = fs.statSync(fileName);
|
|
1293
|
+
if (stats.size < 200) {
|
|
1294
|
+
await safeUnlinkWithRetry(fileName);
|
|
1295
|
+
throw new Error("Downloaded file is too small or corrupted (likely download failed)");
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const finalFilePath = await processDownloadedFile(fileName, config, "unknown", signal);
|
|
1152
1299
|
resolve(finalFilePath);
|
|
1153
1300
|
} catch (error) {
|
|
1154
1301
|
reject(new Error(`Error processing downloaded video: ${error.message}`));
|
|
@@ -1176,6 +1323,11 @@ async function downloadGenericFile(url, preferredExt = null) {
|
|
|
1176
1323
|
}
|
|
1177
1324
|
});
|
|
1178
1325
|
|
|
1326
|
+
// Check content-length early if available
|
|
1327
|
+
const contentLength = parseInt(response.headers['content-length']);
|
|
1328
|
+
if (!isNaN(contentLength) && contentLength < 200) {
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1179
1331
|
// Try to infer extension from content-type
|
|
1180
1332
|
const ct = (response.headers && response.headers['content-type']) || '';
|
|
1181
1333
|
let ext = preferredExt;
|
|
@@ -1189,7 +1341,7 @@ async function downloadGenericFile(url, preferredExt = null) {
|
|
|
1189
1341
|
else ext = 'bin';
|
|
1190
1342
|
}
|
|
1191
1343
|
|
|
1192
|
-
const fileName = path.join(OUTPUT_DIR, `v_${uuidv4().slice(0,
|
|
1344
|
+
const fileName = path.join(OUTPUT_DIR, `v_${uuidv4().replace(/-/g, '').slice(0, 12)}.${ext}`);
|
|
1193
1345
|
const writer = fs.createWriteStream(fileName);
|
|
1194
1346
|
response.data.pipe(writer);
|
|
1195
1347
|
|
|
@@ -1203,8 +1355,8 @@ async function downloadGenericFile(url, preferredExt = null) {
|
|
|
1203
1355
|
}
|
|
1204
1356
|
|
|
1205
1357
|
// Function to rotate video
|
|
1206
|
-
async function rotateVideo(fileName, rotation) {
|
|
1207
|
-
const outputPath = path.join(OUTPUT_DIR, `vr_${uuidv4().slice(0,
|
|
1358
|
+
async function rotateVideo(fileName, rotation, signal = null) {
|
|
1359
|
+
const outputPath = path.join(OUTPUT_DIR, `vr_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp4`);
|
|
1208
1360
|
let angle;
|
|
1209
1361
|
switch (rotation.toLowerCase()) {
|
|
1210
1362
|
case "left": angle = "transpose=2"; break;
|
|
@@ -1215,14 +1367,27 @@ async function rotateVideo(fileName, rotation) {
|
|
|
1215
1367
|
}
|
|
1216
1368
|
|
|
1217
1369
|
return new Promise((resolve, reject) => {
|
|
1218
|
-
ffmpeg(fileName)
|
|
1370
|
+
const ff = ffmpeg(fileName)
|
|
1219
1371
|
.videoFilters(angle)
|
|
1220
1372
|
.audioCodec('copy')
|
|
1221
|
-
.output(outputPath)
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1373
|
+
.output(outputPath);
|
|
1374
|
+
|
|
1375
|
+
if (signal) {
|
|
1376
|
+
if (signal.aborted) {
|
|
1377
|
+
reject(new Error("AbortError"));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
signal.addEventListener('abort', () => {
|
|
1381
|
+
ff.kill('SIGKILL');
|
|
1382
|
+
safeUnlink(outputPath);
|
|
1383
|
+
reject(new Error("AbortError"));
|
|
1384
|
+
}, { once: true });
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
ff.on('end', async () => {
|
|
1388
|
+
await safeUnlinkWithRetry(fileName);
|
|
1389
|
+
resolve(outputPath);
|
|
1390
|
+
})
|
|
1226
1391
|
.on('error', (err) => reject(new Error(`Error during video rotation: ${err.message}`)))
|
|
1227
1392
|
.run();
|
|
1228
1393
|
});
|
|
@@ -1253,26 +1418,55 @@ async function deleteTempVideos() {
|
|
|
1253
1418
|
|
|
1254
1419
|
// Enhanced cleanup function for all temporary files
|
|
1255
1420
|
async function cleanupTempFiles() {
|
|
1421
|
+
// Clean TEMP_DIR (_compressed files)
|
|
1256
1422
|
try {
|
|
1257
|
-
if (
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1423
|
+
if (fs.existsSync(TEMP_DIR)) {
|
|
1424
|
+
const files = fs.readdirSync(TEMP_DIR);
|
|
1425
|
+
const tempFiles = files.filter(file => /_compressed\.mp4$/.test(file));
|
|
1426
|
+
const now = Date.now();
|
|
1427
|
+
const GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
|
|
1428
|
+
|
|
1429
|
+
for (const file of tempFiles) {
|
|
1430
|
+
const full = path.join(TEMP_DIR, file);
|
|
1431
|
+
try {
|
|
1432
|
+
const st = fs.statSync(full);
|
|
1433
|
+
if (now - st.mtimeMs > GRACE_PERIOD_MS) {
|
|
1434
|
+
await safeUnlinkWithRetry(full);
|
|
1435
|
+
}
|
|
1436
|
+
} catch (_) { }
|
|
1437
|
+
}
|
|
1268
1438
|
}
|
|
1269
|
-
triggerGC();
|
|
1270
1439
|
} catch (error) {
|
|
1440
|
+
if (error && error.code !== 'ENOENT') {
|
|
1441
|
+
console.error(`Error during temp file cleanup (TEMP_DIR): ${error.message}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1271
1444
|
|
|
1445
|
+
// Also clean OUTPUT_DIR orphan files (v_, vr_, vc_, vx_, vf_, a_ prefixed)
|
|
1446
|
+
try {
|
|
1447
|
+
if (fs.existsSync(OUTPUT_DIR)) {
|
|
1448
|
+
const files = fs.readdirSync(OUTPUT_DIR);
|
|
1449
|
+
const now = Date.now();
|
|
1450
|
+
const GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
|
|
1451
|
+
|
|
1452
|
+
for (const file of files) {
|
|
1453
|
+
if (!/^(v_|vr_|vc_|vx_|vf_|a_)[0-9a-f]{12}/i.test(file)) continue;
|
|
1454
|
+
const full = path.join(OUTPUT_DIR, file);
|
|
1455
|
+
try {
|
|
1456
|
+
const st = fs.statSync(full);
|
|
1457
|
+
if (now - st.mtimeMs > GRACE_PERIOD_MS) {
|
|
1458
|
+
await safeUnlinkWithRetry(full);
|
|
1459
|
+
}
|
|
1460
|
+
} catch (_) { }
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
} catch (error) {
|
|
1272
1464
|
if (error && error.code !== 'ENOENT') {
|
|
1273
|
-
console.error(`Error during temp file cleanup: ${error.message}`);
|
|
1465
|
+
console.error(`Error during temp file cleanup (OUTPUT_DIR): ${error.message}`);
|
|
1274
1466
|
}
|
|
1275
1467
|
}
|
|
1468
|
+
|
|
1469
|
+
triggerGC();
|
|
1276
1470
|
}
|
|
1277
1471
|
|
|
1278
1472
|
// Cleanup files in output folder older than OUTPUT_RETENTION_MIN
|
|
@@ -1283,12 +1477,14 @@ async function cleanupOutputFiles() {
|
|
|
1283
1477
|
const now = Date.now();
|
|
1284
1478
|
const maxAgeMs = OUTPUT_RETENTION_MIN * 60 * 1000;
|
|
1285
1479
|
for (const file of files) {
|
|
1286
|
-
// Only consider our generated
|
|
1287
|
-
if (!/^(v_|vr_|vc_|vx_|vf_|a_)[0-9a-f]{
|
|
1480
|
+
// Only consider our generated names with exactly 12 char hex suffix
|
|
1481
|
+
if (!/^(v_|vr_|vc_|vx_|vf_|a_)[0-9a-f]{12}/i.test(file)) continue;
|
|
1288
1482
|
const full = path.join(OUTPUT_DIR, file);
|
|
1289
1483
|
try {
|
|
1290
1484
|
const st = fs.statSync(full);
|
|
1291
|
-
|
|
1485
|
+
const GRACE_PERIOD_MS = 10 * 60 * 1000; // 10 minutes
|
|
1486
|
+
// Only delete if matches retention AND is not extremely new (grace period)
|
|
1487
|
+
if (now - st.mtimeMs > maxAgeMs && now - st.mtimeMs > GRACE_PERIOD_MS) {
|
|
1292
1488
|
await safeUnlinkWithRetry(full);
|
|
1293
1489
|
}
|
|
1294
1490
|
} catch (_) { }
|
|
@@ -1298,52 +1494,74 @@ async function cleanupOutputFiles() {
|
|
|
1298
1494
|
}
|
|
1299
1495
|
|
|
1300
1496
|
|
|
1301
|
-
// Schedule periodic cleanup (every
|
|
1497
|
+
// Schedule periodic cleanup (every 5 minutes)
|
|
1302
1498
|
setInterval(() => {
|
|
1303
1499
|
cleanupTempFiles();
|
|
1304
1500
|
cleanupOutputFiles();
|
|
1305
|
-
}, 60 * 1000);
|
|
1501
|
+
}, 5 * 60 * 1000);
|
|
1306
1502
|
// Run once at startup
|
|
1307
1503
|
cleanupOutputFiles();
|
|
1308
1504
|
|
|
1309
1505
|
// Function to auto-crop video
|
|
1310
|
-
async function autoCrop(fileName) {
|
|
1506
|
+
async function autoCrop(fileName, signal = null) {
|
|
1311
1507
|
const inputPath = fileName;
|
|
1312
|
-
const outputPath = path.join(OUTPUT_DIR, `vc_${uuidv4().slice(0,
|
|
1508
|
+
const outputPath = path.join(OUTPUT_DIR, `vc_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp4`);
|
|
1313
1509
|
|
|
1314
1510
|
return new Promise((resolve, reject) => {
|
|
1315
1511
|
let cropValues = null;
|
|
1316
|
-
ffmpeg(inputPath)
|
|
1512
|
+
const ffDetect = ffmpeg(inputPath)
|
|
1317
1513
|
.outputOptions('-vf', 'cropdetect=24:16:0')
|
|
1318
1514
|
.outputFormat('null')
|
|
1319
|
-
.output('-')
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
}
|
|
1515
|
+
.output('-');
|
|
1516
|
+
|
|
1517
|
+
if (signal) {
|
|
1518
|
+
if (signal.aborted) {
|
|
1519
|
+
reject(new Error("AbortError"));
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
signal.addEventListener('abort', () => {
|
|
1523
|
+
ffDetect.kill('SIGKILL');
|
|
1524
|
+
reject(new Error("AbortError"));
|
|
1525
|
+
}, { once: true });
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
ffDetect.on('stderr', function (stderrLine) {
|
|
1529
|
+
const cropMatch = stderrLine.match(/crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)/);
|
|
1530
|
+
if (cropMatch) {
|
|
1531
|
+
cropValues = `crop=${cropMatch[1]}:${cropMatch[2]}:${cropMatch[3]}:${cropMatch[4]}`;
|
|
1532
|
+
}
|
|
1533
|
+
})
|
|
1326
1534
|
.on('end', function () {
|
|
1327
1535
|
if (!cropValues) {
|
|
1328
1536
|
resolve(inputPath);
|
|
1329
1537
|
return;
|
|
1330
1538
|
}
|
|
1331
|
-
ffmpeg(inputPath)
|
|
1539
|
+
const ffCrop = ffmpeg(inputPath)
|
|
1332
1540
|
.outputOptions('-vf', cropValues)
|
|
1333
1541
|
.audioCodec('copy')
|
|
1334
|
-
.output(outputPath)
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1542
|
+
.output(outputPath);
|
|
1543
|
+
|
|
1544
|
+
if (signal) {
|
|
1545
|
+
signal.addEventListener('abort', () => {
|
|
1546
|
+
ffCrop.kill('SIGKILL');
|
|
1547
|
+
safeUnlink(outputPath);
|
|
1548
|
+
reject(new Error("AbortError"));
|
|
1549
|
+
}, { once: true });
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
ffCrop.on('end', async () => {
|
|
1553
|
+
await safeUnlinkWithRetry(inputPath);
|
|
1554
|
+
resolve(outputPath);
|
|
1555
|
+
})
|
|
1339
1556
|
.on('error', (err) => reject(new Error(`Error during auto-cropping: ${err.message}`)))
|
|
1340
1557
|
.run();
|
|
1341
|
-
})
|
|
1558
|
+
})
|
|
1559
|
+
.run();
|
|
1342
1560
|
});
|
|
1343
1561
|
}
|
|
1344
1562
|
|
|
1345
1563
|
// Function to check and compress video
|
|
1346
|
-
async function checkAndCompressVideo(filePath, limitSizeMB, platform = null) {
|
|
1564
|
+
async function checkAndCompressVideo(filePath, limitSizeMB, platform = null, signal = null) {
|
|
1347
1565
|
// Não comprimir vídeos do YouTube
|
|
1348
1566
|
if (platform === "youtube") return filePath;
|
|
1349
1567
|
|
|
@@ -1357,21 +1575,34 @@ async function checkAndCompressVideo(filePath, limitSizeMB, platform = null) {
|
|
|
1357
1575
|
return filePath;
|
|
1358
1576
|
}
|
|
1359
1577
|
|
|
1360
|
-
const outputPath = path.join(OUTPUT_DIR, `vx_${uuidv4().slice(0,
|
|
1578
|
+
const outputPath = path.join(OUTPUT_DIR, `vx_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp4`);
|
|
1361
1579
|
|
|
1362
1580
|
return new Promise((resolve, reject) => {
|
|
1363
|
-
ffmpeg(filePath)
|
|
1581
|
+
const ff = ffmpeg(filePath)
|
|
1364
1582
|
.outputOptions(
|
|
1365
1583
|
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
|
1366
1584
|
'-crf', '28',
|
|
1367
1585
|
'-preset', 'slow'
|
|
1368
1586
|
)
|
|
1369
1587
|
.audioCodec('aac')
|
|
1370
|
-
.output(outputPath)
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1588
|
+
.output(outputPath);
|
|
1589
|
+
|
|
1590
|
+
if (signal) {
|
|
1591
|
+
if (signal.aborted) {
|
|
1592
|
+
reject(new Error("AbortError"));
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
signal.addEventListener('abort', () => {
|
|
1596
|
+
ff.kill('SIGKILL');
|
|
1597
|
+
safeUnlink(outputPath);
|
|
1598
|
+
reject(new Error("AbortError"));
|
|
1599
|
+
}, { once: true });
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
ff.on('end', async () => {
|
|
1603
|
+
await safeUnlinkWithRetry(filePath);
|
|
1604
|
+
resolve(outputPath);
|
|
1605
|
+
})
|
|
1375
1606
|
.on('error', (err) => {
|
|
1376
1607
|
resolve(filePath);
|
|
1377
1608
|
})
|
|
@@ -1380,12 +1611,12 @@ async function checkAndCompressVideo(filePath, limitSizeMB, platform = null) {
|
|
|
1380
1611
|
}
|
|
1381
1612
|
|
|
1382
1613
|
// Video format conversion
|
|
1383
|
-
async function convertVideoFormat(inputPath, targetFormat) {
|
|
1614
|
+
async function convertVideoFormat(inputPath, targetFormat, signal = null) {
|
|
1384
1615
|
try {
|
|
1385
1616
|
const fmt = String(targetFormat).toLowerCase();
|
|
1386
1617
|
|
|
1387
1618
|
if (fmt === "mp3") {
|
|
1388
|
-
const audioPath = await extractAudioMp3(inputPath);
|
|
1619
|
+
const audioPath = await extractAudioMp3(inputPath, signal);
|
|
1389
1620
|
await safeUnlinkWithRetry(inputPath);
|
|
1390
1621
|
return audioPath;
|
|
1391
1622
|
}
|
|
@@ -1393,9 +1624,17 @@ async function convertVideoFormat(inputPath, targetFormat) {
|
|
|
1393
1624
|
const supported = ["mp4", "mov", "webm", "mkv"];
|
|
1394
1625
|
if (!supported.includes(fmt)) return inputPath;
|
|
1395
1626
|
|
|
1396
|
-
const outputPath = path.join(OUTPUT_DIR, `vf_${uuidv4().slice(0,
|
|
1627
|
+
const outputPath = path.join(OUTPUT_DIR, `vf_${uuidv4().replace(/-/g, '').slice(0, 12)}.${fmt}`);
|
|
1397
1628
|
|
|
1398
1629
|
const ff = ffmpeg(inputPath);
|
|
1630
|
+
if (signal) {
|
|
1631
|
+
if (signal.aborted) throw new Error("AbortError");
|
|
1632
|
+
signal.addEventListener('abort', () => {
|
|
1633
|
+
ff.kill('SIGKILL');
|
|
1634
|
+
safeUnlink(outputPath);
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1399
1638
|
switch (fmt) {
|
|
1400
1639
|
case "mp4":
|
|
1401
1640
|
case "mov":
|
|
@@ -1484,6 +1723,9 @@ async function unshortenUrl(url) {
|
|
|
1484
1723
|
const AudioDownloader = async (url, options = {}) => {
|
|
1485
1724
|
assertAuthorized();
|
|
1486
1725
|
const config = { ...defaultConfig, ...options };
|
|
1726
|
+
const signal = config.signal;
|
|
1727
|
+
|
|
1728
|
+
if (signal?.aborted) throw new Error("AbortError");
|
|
1487
1729
|
|
|
1488
1730
|
if (!url || !url.includes("http")) {
|
|
1489
1731
|
throw new Error("Please specify a valid video URL.");
|
|
@@ -1501,7 +1743,8 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1501
1743
|
throw new Error("This URL is not from a supported platform. Supported platforms: Instagram, X(Twitter), TikTok, Facebook, and YouTube");
|
|
1502
1744
|
}
|
|
1503
1745
|
|
|
1504
|
-
|
|
1746
|
+
// No longer cleanup at start to avoid race conditions during concurrent downloads
|
|
1747
|
+
// await cleanupTempFiles(); // Clean up previous temp files
|
|
1505
1748
|
|
|
1506
1749
|
let downloadedFilePath = null;
|
|
1507
1750
|
let audioFilePath = null;
|
|
@@ -1518,16 +1761,16 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1518
1761
|
}
|
|
1519
1762
|
await downloadYoutubeAudio(url, fileName);
|
|
1520
1763
|
audioFilePath = fileName;
|
|
1521
|
-
const result = await uploadToGoFileIfNeeded(audioFilePath);
|
|
1764
|
+
const result = await uploadToGoFileIfNeeded(audioFilePath, signal);
|
|
1522
1765
|
return result;
|
|
1523
1766
|
}
|
|
1524
1767
|
// Baixar vídeo normalmente
|
|
1525
1768
|
try {
|
|
1526
|
-
downloadedFilePath = await downloadSmartVideo(url, config);
|
|
1769
|
+
downloadedFilePath = await downloadSmartVideo(url, config, signal);
|
|
1527
1770
|
} catch (error) {
|
|
1528
1771
|
try {
|
|
1529
1772
|
const fallbackUrl = await tryFallbackDownload(url);
|
|
1530
|
-
downloadedFilePath = await downloadDirectVideo(fallbackUrl, config);
|
|
1773
|
+
downloadedFilePath = await downloadDirectVideo(fallbackUrl, config, signal);
|
|
1531
1774
|
} catch (fallbackError) {
|
|
1532
1775
|
throw new Error(`Failed to download video with both methods: ${fallbackError.message}`);
|
|
1533
1776
|
}
|
|
@@ -1535,12 +1778,12 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1535
1778
|
|
|
1536
1779
|
if (downloadedFilePath) {
|
|
1537
1780
|
// Extrair áudio em mp3
|
|
1538
|
-
audioFilePath = await extractAudioMp3(downloadedFilePath);
|
|
1781
|
+
audioFilePath = await extractAudioMp3(downloadedFilePath, signal);
|
|
1539
1782
|
|
|
1540
1783
|
// Remove o arquivo de vídeo temporário após extrair o áudio
|
|
1541
1784
|
await safeUnlinkWithRetry(downloadedFilePath);
|
|
1542
1785
|
|
|
1543
|
-
const result = await uploadToGoFileIfNeeded(audioFilePath);
|
|
1786
|
+
const result = await uploadToGoFileIfNeeded(audioFilePath, signal);
|
|
1544
1787
|
triggerGC();
|
|
1545
1788
|
return result;
|
|
1546
1789
|
|
|
@@ -1554,24 +1797,42 @@ const AudioDownloader = async (url, options = {}) => {
|
|
|
1554
1797
|
}
|
|
1555
1798
|
};
|
|
1556
1799
|
|
|
1557
|
-
|
|
1558
|
-
async function extractAudioMp3(videoPath) {
|
|
1800
|
+
async function extractAudioMp3(videoPath, signal = null) {
|
|
1559
1801
|
return new Promise((resolve, reject) => {
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
.
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1802
|
+
const fileName = `a_${uuidv4().replace(/-/g, '').slice(0, 12)}.mp3`;
|
|
1803
|
+
const audioPath = path.join(OUTPUT_DIR, fileName);
|
|
1804
|
+
|
|
1805
|
+
// We remove the hardcoded 'libmp3lame' codec which can cause "Invalid argument"
|
|
1806
|
+
// on some statically compiled ffmpeg versions and let it auto-select based on format.
|
|
1807
|
+
const ff = ffmpeg(videoPath)
|
|
1808
|
+
.outputOptions([
|
|
1809
|
+
'-vn', // No video
|
|
1810
|
+
'-b:a', '192k' // Audio bitrate
|
|
1811
|
+
])
|
|
1812
|
+
.format('mp3');
|
|
1813
|
+
|
|
1814
|
+
if (signal) {
|
|
1815
|
+
if (signal.aborted) {
|
|
1816
|
+
reject(new Error("AbortError"));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
signal.addEventListener('abort', () => {
|
|
1820
|
+
ff.kill('SIGKILL');
|
|
1821
|
+
safeUnlink(audioPath);
|
|
1822
|
+
reject(new Error("AbortError"));
|
|
1823
|
+
}, { once: true });
|
|
1824
|
+
}
|
|
1571
1825
|
|
|
1826
|
+
// Attach events BEFORE calling run or save
|
|
1827
|
+
ff.on('end', () => {
|
|
1828
|
+
triggerGC();
|
|
1829
|
+
resolve(audioPath);
|
|
1830
|
+
})
|
|
1572
1831
|
.on('error', (err) => {
|
|
1573
1832
|
reject(new Error(`Error extracting audio: ${err.message}`));
|
|
1574
|
-
})
|
|
1833
|
+
})
|
|
1834
|
+
.output(audioPath)
|
|
1835
|
+
.run();
|
|
1575
1836
|
});
|
|
1576
1837
|
}
|
|
1577
1838
|
|