1
2
3
4
5
6
7
8
9import { execSync } from 'node:child_process';
10import { randomBytes } from 'node:crypto';
11import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
12import { tmpdir } from 'node:os';
13import { extname, join } from 'node:path';
14import { setTimeout as sleep } from 'node:timers/promises';
15
16import { Actor } from 'apify';
17
18import { formatBytes, formatDuration, mimeType, safeFilename } from './utils.js';
19
20
21
22
23
24
25
26
27async function withHeartbeat(promiseOrFn, msg, { intervalMs = 15_000, abortSignal } = {}) {
28 let done = false;
29
30 let value;
31 try {
32 value = typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn;
33 } catch (e) {
34 return Promise.reject(e);
35 }
36
37 const promise = Promise.resolve(value);
38 const result = promise.then(
39 (v) => {
40 done = true;
41 return v;
42 },
43 (e) => {
44 done = true;
45 throw e;
46 },
47 );
48
49 const hb = (async () => {
50 let count = 0;
51 while (!done) {
52 await sleep(intervalMs);
53 if (done) break;
54 count++;
55 const elapsed = count * (intervalMs / 1000);
56 console.log(` ${msg} (still working... ${elapsed}s elapsed)`);
57 }
58 })();
59
60 try {
61 return await result;
62 } finally {
63 done = true;
64
65 await sleep(0);
66 }
67}
68
69
70
71const USER_AGENTS = [
72 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
73 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
74 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
75 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
76];
77
78function randomUserAgent() {
79 return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
80}
81
82function parseCookiesTxt(text) {
83 if (!text || !text.trim()) return null;
84 const dir = mkdtempSync(join(tmpdir(), 'cookies-'));
85 const path = join(dir, 'cookies.txt');
86 writeFileSync(path, text);
87 return path;
88}
89
90
91let ytDlpPath = null;
92
93async function ensureYtDlp() {
94 if (ytDlpPath) return;
95 const dir = mkdtempSync(join(tmpdir(), 'ytdlp-bin-'));
96 ytDlpPath = join(dir, 'yt-dlp');
97 console.log(' Downloading latest yt-dlp...');
98 const resp = await fetch('https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_musllinux');
99 if (!resp.ok) throw new Error(`Failed to download yt-dlp: HTTP ${resp.status}`);
100 const buffer = Buffer.from(await resp.arrayBuffer());
101 writeFileSync(ytDlpPath, buffer, { mode: 0o755 });
102 const ver = execSync(`${ytDlpPath} --version`, { encoding: 'utf-8' }).trim();
103 console.log(` yt-dlp ${ver} ready (${formatBytes(buffer.length)})`);
104}
105
106
107
108
109function ytdlp(args, { maxBuffer = 10 * 1024 * 1024, timeout = 120_000 } = {}) {
110 const quoted = args.map((a) => `"${a.replace(/"/g, '\\"')}"`).join(' ');
111 return execSync(`"${ytDlpPath}" ${quoted}`, {
112 maxBuffer,
113 timeout,
114 encoding: 'utf-8',
115 shell: true,
116 }).trim();
117}
118
119
120
121
122
123
124function extractInfo(url, { proxyUrl, cookiesPath, playerClients = null, useExtractorArgs = false }) {
125 const tmpDir = mkdtempSync(join(tmpdir(), 'ytdlp-'));
126 try {
127 const args = [
128 url,
129 '--no-playlist',
130 '--quiet',
131 '--no-warnings',
132 '--print-json',
133 '--skip-download',
134 '--ignore-no-formats-error',
135 '--user-agent',
136 randomUserAgent(),
137 '--js-runtimes',
138 'node',
139 '--force-ipv4',
140 '--socket-timeout',
141 '30',
142 '--retries',
143 '0',
144 '--fragment-retries',
145 '0',
146 '--extractor-retries',
147 '0',
148 '--sleep-requests',
149 '1',
150 '--xff',
151 'default',
152 '--output',
153 join(tmpDir, '%(title)s.%(ext)s'),
154 ];
155 if (useExtractorArgs && playerClients) args.push('--extractor-args', `youtube:player_client=${playerClients}`);
156 if (proxyUrl) args.push('--proxy', proxyUrl);
157 if (cookiesPath) args.push('--cookies', cookiesPath);
158
159 const stdout = ytdlp(args, { maxBuffer: 10 * 1024 * 1024, timeout: 120_000 });
160
161 const lines = stdout.split('\n').filter(Boolean);
162 const info = JSON.parse(lines[lines.length - 1]);
163 const title = info.title || 'Unknown';
164 const dur = info.duration || 0;
165 console.log(` Found: "${title}" — ${formatDuration(dur)}`);
166 return info;
167 } finally {
168 try {
169 rmSync(tmpDir, { recursive: true, force: true });
170 } catch {}
171 }
172}
173
174function assertDownloadedFile(filePath, label) {
175 let size = 0;
176 try {
177 size = statSync(filePath).size;
178 } catch {}
179 if (size <= 0) throw new Error(`${label} produced an empty file`);
180 return size;
181}
182
183function bestByBitrate(formats) {
184 return formats.filter(Boolean).sort((a, b) => (b.abr || b.tbr || 0) - (a.abr || a.tbr || 0))[0] || null;
185}
186
187function isDirectAudioFormat(format) {
188 return (
189 format?.url &&
190 format.vcodec === 'none' &&
191 format.acodec &&
192 format.acodec !== 'none' &&
193 ['http', 'https'].includes(format.protocol || 'https')
194 );
195}
196
197function hasDirectAudioFormats(info) {
198 return (info.formats || []).some(isDirectAudioFormat) || isDirectAudioFormat(info);
199}
200
201function isTransientProxyError(err) {
202 const msg = err?.message || '';
203 return /UPSTREAM\d+|Tunnel connection failed|ProxyError|HTTP Error 403|HTTP Error 429|HTTP Error 503|not a bot|confirm you.?re not a bot|Failed to extract any player response|timed out|timeout/i.test(msg);
204}
205
206async function extractInfoWithProxyRetries(url, options, { freshProxyUrl, label, attempts = 3 }) {
207 let lastErr;
208 let currentProxyUrl = options.proxyUrl;
209 for (let attempt = 1; attempt <= attempts; attempt++) {
210 try {
211 return await withHeartbeat(
212 () => extractInfo(url, { ...options, proxyUrl: currentProxyUrl }),
213 attempt === 1 ? label : `${label} (attempt ${attempt}/${attempts})`,
214 );
215 } catch (err) {
216 lastErr = err;
217 if (attempt >= attempts || !isTransientProxyError(err)) throw err;
218 console.log(` Metadata attempt ${attempt}/${attempts} failed with proxy/network error (${err.message}); retrying with fresh proxy session...`);
219 currentProxyUrl = await freshProxyUrl();
220 await sleep(1_000 * attempt);
221 }
222 }
223 throw lastErr;
224}
225
226function selectAudioFormat(info, requestedFormat) {
227 const audioFormats = (info.formats || []).filter(isDirectAudioFormat);
228 if (!audioFormats.length && isDirectAudioFormat(info)) return info;
229 if (!audioFormats.length) throw new Error('No direct audio-only formats found');
230
231 if (requestedFormat === 'm4a') {
232 return bestByBitrate(audioFormats.filter((f) => f.ext === 'm4a')) || bestByBitrate(audioFormats);
233 }
234
235 if (requestedFormat === 'opus' || requestedFormat === 'webm') {
236 return (
237 bestByBitrate(audioFormats.filter((f) => f.ext === 'webm' || f.acodec?.includes('opus'))) ||
238 bestByBitrate(audioFormats)
239 );
240 }
241
242 return bestByBitrate(audioFormats);
243}
244
245function ffmpegAudioArgs(audioFormat, audioBitrate) {
246 if (audioFormat === 'mp3') return `-c:a libmp3lame -b:a ${audioBitrate}k`;
247 if (audioFormat === 'm4a') return `-c:a aac -b:a ${audioBitrate}k`;
248 if (audioFormat === 'flac') return '-c:a flac';
249 if (audioFormat === 'wav') return '-c:a pcm_s16le';
250 return '-c:a copy';
251}
252
253function ytdlpAudioFormat(audioFormat) {
254 if (audioFormat === 'webm') return 'opus';
255 if (audioFormat === 'best') return 'best';
256 return audioFormat;
257}
258
259function downloadAudioWithYtDlp(url, { tmpDir, proxyUrl, cookiesPath, audioFormat, audioBitrate }) {
260 const outputBase = `proxy-audio-${randomBytes(4).toString('hex')}`;
261 const outputTemplate = join(tmpDir, `${outputBase}.%(ext)s`);
262 const args = [
263 url,
264 '--no-playlist',
265 '--quiet',
266 '--no-warnings',
267 '--format',
268 'bestaudio/best',
269 '--extract-audio',
270 '--audio-format',
271 ytdlpAudioFormat(audioFormat),
272 '--audio-quality',
273 `${audioBitrate}K`,
274 '--user-agent',
275 randomUserAgent(),
276 '--js-runtimes',
277 'node',
278 '--force-ipv4',
279 '--socket-timeout',
280 '30',
281 '--force-overwrites',
282 '--no-part',
283 '--output',
284 outputTemplate,
285 ];
286 if (proxyUrl) args.push('--proxy', proxyUrl);
287 if (cookiesPath) args.push('--cookies', cookiesPath);
288 ytdlp(args, { maxBuffer: 50 * 1024 * 1024, timeout: 600_000 });
289
290 const downloaded = readdirSync(tmpDir)
291 .filter((name) => name.startsWith(`${outputBase}.`))
292 .map((name) => join(tmpDir, name))
293 .sort((a, b) => statSync(b).size - statSync(a).size)[0];
294 if (!downloaded) throw new Error('yt-dlp proxy media fallback did not produce an output file');
295 assertDownloadedFile(downloaded, 'yt-dlp proxy media fallback');
296 return downloaded;
297}
298
299async function ytdlpWithProxyRetries(buildArgs, { freshProxyUrl, cookiesPath, label, attempts = 4 }) {
300 let lastErr;
301 for (let attempt = 1; attempt <= attempts; attempt++) {
302 const proxyUrl = await freshProxyUrl();
303 const args = buildArgs(proxyUrl);
304 if (cookiesPath) args.push('--cookies', cookiesPath);
305 try {
306 return await withHeartbeat(
307 () => ytdlp(args, { maxBuffer: 50 * 1024 * 1024, timeout: 600_000 }),
308 attempt === 1 ? label : `${label} (attempt ${attempt}/${attempts})`,
309 );
310 } catch (err) {
311 lastErr = err;
312 if (attempt >= attempts || !isTransientProxyError(err)) throw err;
313 console.log(` ${label} attempt ${attempt}/${attempts} failed (${err.message}); retrying with fresh proxy session...`);
314 await sleep(1_500 * attempt);
315 }
316 }
317 throw lastErr;
318}
319
320
321
322await Actor.init();
323
324Actor.on('aborting', async () => {
325 await sleep(1000);
326 await Actor.exit();
327});
328
329await ensureYtDlp();
330
331const input = (await Actor.getInput()) || {};
332const videoUrls = input.videoUrls || [];
333const audioFormat = input.audioFormat || 'm4a';
334const audioBitrate = input.audioBitrate || '192';
335const embedMetadata = input.embedMetadata !== false;
336const sleepMax = input.sleepBetweenUrls || 12;
337const cookiesText = input.cookies || '';
338const proxyInput = input.proxyConfiguration || {};
339const useApifyProxy = proxyInput.useApifyProxy !== false;
340const proxyCountry = proxyInput.apifyProxyCountry || 'US';
341const allowProxyMediaDownload = input.allowProxyMediaDownload !== false;
342const continueOnError = input.continueOnError === true;
343
344if (!videoUrls.length) {
345 console.log('No video URLs provided.');
346 await Actor.exit();
347 process.exit(0);
348}
349
350console.log(
351 `Processing ${videoUrls.length} video(s), format=${audioFormat}, bitrate=${audioBitrate}k, embed=${embedMetadata}`,
352);
353
354
355let cookiesPath = null;
356if (cookiesText) {
357 cookiesPath = parseCookiesTxt(cookiesText);
358 console.log('Cookies loaded from input');
359}
360
361const conversionFormats = ['mp3', 'm4a', 'flac', 'wav'];
362const kvStoreId = process.env.APIFY_DEFAULT_KEY_VALUE_STORE_ID;
363const kvBaseUrl = kvStoreId ? `https://api.apify.com/v2/key-value-stores/${kvStoreId}/records` : null;
364
365let failedCount = 0;
366let downloadedCount = 0;
367
368for (let i = 0; i < videoUrls.length; i++) {
369 const url = videoUrls[i];
370 console.log(`[${i + 1}/${videoUrls.length}] Processing: ${url}`);
371
372 const tmpDir = mkdtempSync(join(tmpdir(), 'ytdlp-'));
373
374 try {
375
376 let proxyUrl = null;
377 let proxyTier = 'none';
378 let proxyConfig = null;
379
380 if (useApifyProxy) {
381 console.log(' Setting up residential proxy...');
382 proxyConfig = await Actor.createProxyConfiguration({
383 groups: ['RESIDENTIAL'],
384 countryCode: proxyCountry,
385 });
386 if (proxyConfig) {
387 proxyUrl = await proxyConfig.newUrl(`session_${randomBytes(4).toString('hex')}`);
388 proxyTier = 'residential';
389 }
390 }
391 const freshProxyUrl = async () => {
392 if (!proxyConfig) return null;
393 return proxyConfig.newUrl(`session_${randomBytes(4).toString('hex')}`);
394 };
395
396
397
398
399
400
401 console.log(' Fetching video metadata via proxy...');
402 let info = await extractInfoWithProxyRetries(
403 url,
404 { proxyUrl, cookiesPath },
405 { freshProxyUrl, label: 'Fetching video metadata' },
406 );
407 if (!hasDirectAudioFormats(info)) {
408 console.log(
409 ' No audio-only formats from yt-dlp default clients; retrying with android_vr/mweb and fresh proxy session...',
410 );
411 try {
412 proxyUrl = await freshProxyUrl();
413 info = await extractInfoWithProxyRetries(
414 url,
415 { proxyUrl, cookiesPath, playerClients: 'android_vr,mweb', useExtractorArgs: true },
416 { freshProxyUrl, label: 'Retrying video metadata with android_vr/mweb' },
417 );
418 } catch (retryErr) {
419 console.log(` android_vr/mweb metadata retry failed (${retryErr.message})`);
420 }
421 }
422 if (!hasDirectAudioFormats(info)) {
423 console.log(' Still no audio-only formats; retrying with web_embedded/default and fresh proxy session...');
424 try {
425 proxyUrl = await freshProxyUrl();
426 info = await extractInfoWithProxyRetries(
427 url,
428 { proxyUrl, cookiesPath, playerClients: 'web_embedded,default', useExtractorArgs: true },
429 { freshProxyUrl, label: 'Retrying video metadata with web_embedded/default' },
430 );
431 } catch (retryErr) {
432 console.log(` web_embedded/default metadata retry failed (${retryErr.message})`);
433 }
434 }
435 if (!hasDirectAudioFormats(info)) {
436 console.log(' Still no audio-only formats; retrying metadata with TV clients and fresh proxy session...');
437 proxyUrl = await freshProxyUrl();
438 info = await extractInfoWithProxyRetries(
439 url,
440 {
441 proxyUrl,
442 cookiesPath,
443 playerClients: 'tv_simply,tv_downgraded,tv,tv_embedded',
444 useExtractorArgs: true,
445 },
446 { freshProxyUrl, label: 'Retrying video metadata with TV clients' },
447 );
448 }
449 const title = info.title || 'Unknown';
450 const videoId = info.id || 'unknown';
451 const duration = info.duration || 0;
452 const channel = info.channel || info.uploader || '';
453 let selectedFormat = null;
454 let sourceExt = audioFormat === 'opus' ? 'webm' : audioFormat;
455 let sourceCodec = 'unknown';
456 let sourceBitrate = null;
457 let needsConversion = false;
458 let rawPath;
459 let proxyMediaUsed = false;
460
461 if (!allowProxyMediaDownload) {
462 throw new Error('Media downloads now require proxy media download because direct CDN downloading is disabled. Set allowProxyMediaDownload=true or omit it.');
463 }
464
465 if (hasDirectAudioFormats(info)) {
466 selectedFormat = selectAudioFormat(info, audioFormat);
467 sourceExt = selectedFormat.ext || info.ext || 'm4a';
468 sourceCodec = selectedFormat.acodec || 'unknown';
469 sourceBitrate = selectedFormat.abr || selectedFormat.tbr || null;
470 const requestedDirectFormat =
471 audioFormat === 'best' ||
472 (audioFormat === 'opus' && (sourceExt === 'webm' || sourceCodec.includes('opus'))) ||
473 audioFormat === sourceExt;
474 needsConversion = conversionFormats.includes(audioFormat) && !requestedDirectFormat;
475
476 console.log(
477 ` Selected audio-only format ${selectedFormat.format_id || 'unknown'} (${sourceExt}, ${sourceCodec}, ${sourceBitrate || '?'} kbps)`,
478 );
479
480 rawPath = join(tmpDir, `${safeFilename(title)}.raw.${sourceExt}`);
481 console.log(' Downloading selected audio format with yt-dlp via residential proxy...');
482 proxyMediaUsed = true;
483 await ytdlpWithProxyRetries(
484 (mediaProxyUrl) => {
485 const args = [
486 url,
487 '--no-playlist',
488 '--quiet',
489 '--no-warnings',
490 '--format',
491 selectedFormat.format_id || 'bestaudio/best',
492 '--user-agent',
493 randomUserAgent(),
494 '--js-runtimes',
495 'node',
496 '--force-ipv4',
497 '--socket-timeout',
498 '30',
499 '--retries',
500 '2',
501 '--fragment-retries',
502 '2',
503 '--force-overwrites',
504 '--no-part',
505 '--output',
506 rawPath,
507 ];
508 if (mediaProxyUrl) args.push('--proxy', mediaProxyUrl);
509 return args;
510 },
511 {
512 freshProxyUrl,
513 cookiesPath,
514 label: 'Downloading selected format with yt-dlp via proxy',
515 },
516 );
517 console.log(` Downloaded ${formatBytes(assertDownloadedFile(rawPath, 'Proxy media download'))}`);
518 } else {
519 console.log(' No direct audio-only formats found; using yt-dlp audio extraction via residential proxy...');
520 proxyMediaUsed = true;
521 const outputBase = `proxy-audio-${randomBytes(4).toString('hex')}`;
522 const outputTemplate = join(tmpDir, `${outputBase}.%(ext)s`);
523 await ytdlpWithProxyRetries(
524 (mediaProxyUrl) => {
525 const args = [
526 url,
527 '--no-playlist',
528 '--quiet',
529 '--no-warnings',
530 '--format',
531 'bestaudio/best',
532 '--extract-audio',
533 '--audio-format',
534 ytdlpAudioFormat(audioFormat),
535 '--audio-quality',
536 `${audioBitrate}K`,
537 '--user-agent',
538 randomUserAgent(),
539 '--js-runtimes',
540 'node',
541 '--force-ipv4',
542 '--socket-timeout',
543 '30',
544 '--retries',
545 '2',
546 '--fragment-retries',
547 '2',
548 '--force-overwrites',
549 '--no-part',
550 '--output',
551 outputTemplate,
552 ];
553 if (mediaProxyUrl) args.push('--proxy', mediaProxyUrl);
554 return args;
555 },
556 {
557 freshProxyUrl,
558 cookiesPath,
559 label: 'Downloading with yt-dlp audio extraction via proxy',
560 },
561 );
562 rawPath = readdirSync(tmpDir)
563 .filter((name) => name.startsWith(`${outputBase}.`))
564 .map((name) => join(tmpDir, name))
565 .sort((a, b) => statSync(b).size - statSync(a).size)[0];
566 if (!rawPath) throw new Error('yt-dlp audio extraction did not produce an output file');
567 sourceExt = extname(rawPath).replace(/^\./, '') || sourceExt;
568 sourceCodec = 'unknown';
569 sourceBitrate = null;
570 needsConversion = false;
571 console.log(` Downloaded ${formatBytes(assertDownloadedFile(rawPath, 'Proxy media download'))}`);
572 }
573 assertDownloadedFile(rawPath, 'Audio download');
574
575
576 let audioPath;
577 let wasConverted = false;
578 if (needsConversion) {
579 audioPath = join(tmpDir, `${safeFilename(title)}.${audioFormat}`);
580 console.log(` Converting to ${audioFormat} with ffmpeg...`);
581 execSync(`ffmpeg -y -i "${rawPath}" -vn ${ffmpegAudioArgs(audioFormat, audioBitrate)} "${audioPath}"`, {
582 stdio: ['ignore', 'pipe', 'pipe'],
583 timeout: 300_000,
584 });
585 wasConverted = true;
586 console.log(` Converted: ${formatBytes(statSync(audioPath).size)}`);
587 } else {
588 audioPath = join(tmpDir, `${safeFilename(title)}.${audioFormat === 'opus' ? 'webm' : sourceExt}`);
589 execSync(`mv "${rawPath}" "${audioPath}"`);
590 }
591
592 if (embedMetadata && audioFormat !== 'wav') {
593 console.log(' Embedding metadata...');
594 try {
595 const safeTitle = (info.title || '').replace(/"/g, '\\"');
596 const safeArtist = (channel || '').replace(/"/g, '\\"');
597 const tmpAudio = `${audioPath}.tmp.${extname(audioPath).replace(/^\./, '')}`;
598 execSync(
599 `ffmpeg -y -i "${audioPath}" -c copy ` +
600 `-metadata title="${safeTitle}" ` +
601 `-metadata artist="${safeArtist}" ` +
602 `-id3v2_version 3 "${tmpAudio}" && mv "${tmpAudio}" "${audioPath}"`,
603 { stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 },
604 );
605 } catch (metaErr) {
606 console.log(` Metadata embedding skipped (${metaErr.message})`);
607 }
608 }
609
610
611 const audioFile = readFileSync(audioPath);
612 const fileExt = extname(audioPath).replace(/^\./, '');
613 const kvKey = `audio-${videoId}`;
614 const contentType = mimeType(fileExt);
615
616 console.log(` Uploading ${formatBytes(audioFile.length)} to key-value store...`);
617 await Actor.setValue(kvKey, audioFile, { contentType });
618 console.log(` ✓ Done: "${title}" (${videoId}), ${formatDuration(duration)}, ${formatBytes(audioFile.length)}`);
619
620 const item = {
621 video_id: videoId,
622 video_url: url,
623 video_title: title,
624 channel,
625 duration,
626 audio_format: fileExt,
627 file_size_bytes: audioFile.length,
628 source_format_id: selectedFormat.format_id || null,
629 source_ext: sourceExt,
630 source_codec: sourceCodec,
631 source_bitrate: sourceBitrate,
632 was_converted: wasConverted,
633 proxy_media_used: proxyMediaUsed,
634 kv_store_key: kvKey,
635 proxy_tier_used: proxyTier,
636 status: 'downloaded',
637 };
638 if (kvBaseUrl) item.audio_url = `${kvBaseUrl}/${kvKey}`;
639 await Actor.pushData(item);
640 downloadedCount++;
641 } catch (err) {
642 failedCount++;
643 console.error(` ✗ Failed: ${url} — ${err.message}`);
644 await Actor.pushData({
645 video_url: url,
646 status: 'error',
647 error: err.message.slice(0, 500),
648 });
649 } finally {
650 try {
651 rmSync(tmpDir, { recursive: true, force: true });
652 } catch {}
653 }
654
655
656 if (i < videoUrls.length - 1) {
657 const delaySec = 5 + Math.random() * (sleepMax - 5);
658 console.log(` Sleeping ${delaySec.toFixed(1)}s before next URL...`);
659 await sleep(Math.round(delaySec * 1000));
660 }
661}
662
663console.log(`Finished processing all URLs: ${downloadedCount} downloaded, ${failedCount} failed`);
664if (failedCount > 0 && !continueOnError) {
665 throw new Error(`${failedCount} of ${videoUrls.length} video(s) failed. Set continueOnError=true to keep the Actor run successful despite per-video errors.`);
666}
667await Actor.exit();