ani-auto 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/src/cli.js ADDED
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * cli.js — ani-auto command line interface
6
+ *
7
+ * Commands:
8
+ * setup Interactive first-time setup wizard
9
+ * check Run one download cycle immediately
10
+ * list Show all tracked anime
11
+ * add <title> Add anime to manual list
12
+ * remove <title> Remove anime from manual list
13
+ * enable <title> Re-enable a disabled entry
14
+ * disable <title> Pause downloads for an anime
15
+ * status Show recent run log + per-anime episode counts
16
+ * config View / edit config values
17
+ * daemon Start the background daemon
18
+ */
19
+
20
+ const { program } = require('commander');
21
+ const inquirer = require('inquirer');
22
+ const chalk = require('chalk');
23
+ const ora = require('ora');
24
+
25
+ const { loadConfig, updateConfig, CONFIG_FILE } = require('./config');
26
+ const db = require('./db');
27
+ const { runCycle } = require('./engine');
28
+ const { getViewer } = require('./anilist');
29
+ const { version } = require('../package.json');
30
+
31
+ // ── Banner ────────────────────────────────────────────────────────────────
32
+
33
+ function banner() {
34
+ console.log(chalk.bold.red(`
35
+ █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
36
+ ██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
37
+ ███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
38
+ ██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
39
+ ██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
40
+ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
41
+ A N I M E A U T O D O W N L O A D E R
42
+ `));
43
+ }
44
+ // ── setup ─────────────────────────────────────────────────────────────────
45
+
46
+ program
47
+ .command('setup')
48
+ .description('Interactive first-time configuration wizard')
49
+ .action(async () => {
50
+ banner();
51
+ const current = loadConfig();
52
+
53
+ console.log(chalk.cyan(' Let\'s configure your auto-downloader.\n'));
54
+
55
+ // Step 1: get username
56
+ const { anilistUsername } = await inquirer.prompt([{
57
+ type: 'input',
58
+ name: 'anilistUsername',
59
+ message: 'AniList username (leave blank to skip AniList integration):',
60
+ default: current.anilistUsername || '',
61
+ }]);
62
+
63
+ // Step 2: if username given, open browser THEN ask for token
64
+ let anilistToken = current.anilistToken || '';
65
+ let watchStatuses = current.watchStatuses || ['CURRENT'];
66
+
67
+ if (anilistUsername) {
68
+ const { openBrowser } = await inquirer.prompt([{
69
+ type: 'confirm',
70
+ name: 'openBrowser',
71
+ message: 'Open browser to get AniList token?',
72
+ default: true,
73
+ }]);
74
+
75
+ if (openBrowser) {
76
+ const url = 'https://anilist.co/api/v2/oauth/authorize?client_id=37183&response_type=token';
77
+ const { spawn } = require('child_process');
78
+ const openers = process.platform === 'win32' ? ['start']
79
+ : process.platform === 'darwin' ? ['open']
80
+ : ['xdg-open', 'firefox'];
81
+ let opened = false;
82
+ for (const opener of openers) {
83
+ try {
84
+ spawn(opener, [url], { stdio: 'ignore', detached: true, env: process.env }).unref();
85
+ opened = true;
86
+ break;
87
+ } catch (e) {}
88
+ }
89
+ console.log(chalk.cyan(`\n ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
90
+ console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).\n'));
91
+ await new Promise(r => setTimeout(r, 1500));
92
+ }
93
+
94
+ const tokenAnswer = await inquirer.prompt([{
95
+ type: 'password',
96
+ name: 'anilistToken',
97
+ message: 'Paste your AniList token here:',
98
+ default: current.anilistToken || '',
99
+ }]);
100
+ anilistToken = tokenAnswer.anilistToken;
101
+
102
+ const statusAnswer = await inquirer.prompt([{
103
+ type: 'list',
104
+ name: 'watchStatuses',
105
+ message: 'Which AniList status to download?',
106
+ choices: [
107
+ { name: 'CURRENT (Watching)', value: 'CURRENT' },
108
+ { name: 'PLANNING (Plan to Watch)', value: 'PLANNING' },
109
+ { name: 'PAUSED', value: 'PAUSED' },
110
+ { name: 'REPEATING', value: 'REPEATING' },
111
+ ],
112
+ default: (current.watchStatuses || ['CURRENT'])[0],
113
+ filter: v => [v],
114
+ }]);
115
+ watchStatuses = statusAnswer.watchStatuses;
116
+ }
117
+
118
+ // Step 3: rest of config
119
+ const answers = await inquirer.prompt([
120
+ {
121
+ type: 'list',
122
+ name: 'quality',
123
+ message: 'Default download quality:',
124
+ choices: ['1080p', '720p', '480p', '360p'],
125
+ default: current.quality || '1080p',
126
+ },
127
+ {
128
+ type: 'list',
129
+ name: 'language',
130
+ message: 'Default audio language:',
131
+ choices: ['sub', 'dub'],
132
+ default: current.language || 'sub',
133
+ },
134
+ {
135
+ type: 'number',
136
+ name: 'concurrency',
137
+ message: 'Parallel downloads per run (1-10):',
138
+ default: current.concurrency || 2,
139
+ validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 1–10',
140
+ },
141
+ {
142
+ type: 'input',
143
+ name: 'outputDir',
144
+ message: 'Output directory for downloads:',
145
+ default: current.outputDir,
146
+ },
147
+ {
148
+ type: 'number',
149
+ name: 'daemonIntervalMinutes',
150
+ message: 'Daemon polling interval (minutes):',
151
+ default: current.daemonIntervalMinutes || 30,
152
+ },
153
+ ]);
154
+ answers.anilistUsername = anilistUsername;
155
+ answers.anilistToken = anilistToken;
156
+ answers.watchStatuses = watchStatuses;
157
+
158
+ // Verify AniList token if provided
159
+ if (answers.anilistToken) {
160
+ const spinner = ora('Verifying AniList token...').start();
161
+ try {
162
+ const viewer = await getViewer(answers.anilistToken);
163
+ spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`);
164
+ } catch {
165
+ spinner.warn('Could not verify token — saved anyway, check it manually.');
166
+ }
167
+ }
168
+
169
+ updateConfig({
170
+ anilistUsername: answers.anilistUsername || null,
171
+ anilistToken: answers.anilistToken || null,
172
+ watchStatuses: answers.watchStatuses || current.watchStatuses,
173
+ quality: answers.quality,
174
+ language: answers.language,
175
+ concurrency: answers.concurrency,
176
+ outputDir: answers.outputDir,
177
+ daemonIntervalMinutes: answers.daemonIntervalMinutes,
178
+ });
179
+
180
+ console.log(chalk.green(`\n ✔ Config saved to ${CONFIG_FILE}\n`));
181
+ console.log(` Run ${chalk.bold('ani-auto check')} to do a manual download cycle.`);
182
+ console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.\n`);
183
+ });
184
+
185
+ // ── check ─────────────────────────────────────────────────────────────────
186
+
187
+ program
188
+ .command('check')
189
+ .description('Run one download cycle immediately')
190
+ .option('--auto', 'Skip prompts, auto-select all anime and best site match')
191
+ .action(async (opts) => {
192
+ banner();
193
+ try {
194
+ await runCycle({ auto: !!opts.auto });
195
+ } catch (e) {
196
+ console.error(chalk.red('[ERROR]'), e.message);
197
+ process.exit(1);
198
+ }
199
+ });
200
+
201
+ // ── list ──────────────────────────────────────────────────────────────────
202
+
203
+ program
204
+ .command('list')
205
+ .description('Show all tracked anime and their episode download status')
206
+ .action(async () => {
207
+ banner();
208
+ await db.initDb();
209
+ const rawDb = db.getDb();
210
+ const stmt = rawDb.prepare('SELECT * FROM anime');
211
+ const all = [];
212
+ while (stmt.step()) all.push(stmt.getAsObject());
213
+ stmt.free();
214
+
215
+ if (!all.length) {
216
+ console.log(chalk.yellow(' No anime tracked yet.'));
217
+ console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.\n`);
218
+ return;
219
+ }
220
+
221
+ console.log(chalk.bold(` ${'Title'.padEnd(40)} ${'Source'.padEnd(8)} ${'Enabled'.padEnd(8)} Done Failed`));
222
+ console.log(' ' + '─'.repeat(72));
223
+
224
+ for (const row of all) {
225
+ const stats = db.getEpisodeStats(row.id);
226
+ const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
227
+ const title = row.title.length > 38 ? row.title.slice(0, 37) + '…' : row.title;
228
+ console.log(
229
+ ` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ` +
230
+ `${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`
231
+ );
232
+ }
233
+ console.log('');
234
+ });
235
+
236
+ // ── add ───────────────────────────────────────────────────────────────────
237
+
238
+ program
239
+ .command('add <title>')
240
+ .description('Search AniList and add an anime to the manual download list')
241
+ .option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
242
+ .option('-l, --language <lang>', 'Language override (sub/dub)', null)
243
+ .action(async (title, opts) => {
244
+ banner();
245
+ await db.initDb();
246
+
247
+ const { searchAnime, pickTitle } = require('./anilist');
248
+ const ora = require('ora');
249
+
250
+ const spinner = ora(`Searching AniList for "${title}"...`).start();
251
+ let results;
252
+ try {
253
+ results = await searchAnime(title);
254
+ spinner.stop();
255
+ } catch (e) {
256
+ spinner.fail(`AniList search failed: ${e.message}`);
257
+ return;
258
+ }
259
+
260
+ if (!results.length) {
261
+ console.log(chalk.yellow(` No results found on AniList for ${title}.`));
262
+ return;
263
+ }
264
+
265
+ // Let user pick the right match
266
+ const { picked } = await inquirer.prompt([{
267
+ type: 'list',
268
+ name: 'picked',
269
+ message: 'Select the anime:',
270
+ choices: results.map(r => ({
271
+ name: `${pickTitle(r.titles)} (${r.year || '?'}) — ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
272
+ value: r,
273
+ })),
274
+ }]);
275
+
276
+ const resolvedTitle = pickTitle(picked.titles);
277
+ const config = loadConfig();
278
+ const manualList = config.manualList || [];
279
+
280
+ const exists = manualList.find(m => m.title.toLowerCase() === resolvedTitle.toLowerCase());
281
+ if (exists) {
282
+ console.log(chalk.yellow(` ${resolvedTitle} is already in your manual list.`));
283
+ return;
284
+ }
285
+
286
+ const entry = {
287
+ title: resolvedTitle,
288
+ anilistId: picked.anilistId,
289
+ enabled: true,
290
+ quality: opts.quality || null,
291
+ language: opts.language || null,
292
+ };
293
+
294
+ manualList.push(entry);
295
+ updateConfig({ manualList });
296
+ db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: entry.language });
297
+
298
+ console.log(chalk.green(`\n ✔ Added ${resolvedTitle} to manual list.`));
299
+ if (opts.quality) console.log(` Quality: ${opts.quality}`);
300
+ if (opts.language) console.log(` Language: ${opts.language}`);
301
+ console.log('');
302
+ });
303
+
304
+ // ── remove ────────────────────────────────────────────────────────────────
305
+
306
+ program
307
+ .command('remove [title]')
308
+ .description('Remove an anime from the manual list (interactive if no title given)')
309
+ .action(async (title) => {
310
+ banner();
311
+ await db.initDb();
312
+ const config = loadConfig();
313
+ const manualList = config.manualList || [];
314
+
315
+ if (!manualList.length) {
316
+ console.log(chalk.yellow(' Your manual list is empty.\n'));
317
+ return;
318
+ }
319
+
320
+ let target = title;
321
+
322
+ // If no title given, show interactive picker
323
+ if (!target) {
324
+ const { picked } = await inquirer.prompt([{
325
+ type: 'list',
326
+ name: 'picked',
327
+ message: 'Select anime to remove:',
328
+ choices: manualList.map(m => ({
329
+ name: `${m.title}${m.enabled ? '' : chalk.gray(' (disabled)')}`,
330
+ value: m.title,
331
+ })),
332
+ }]);
333
+ target = picked;
334
+ }
335
+
336
+ const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
337
+ if (!exists) {
338
+ console.log(chalk.yellow(` "${target}" is not in your manual list.\n`));
339
+ return;
340
+ }
341
+
342
+ const { confirm } = await inquirer.prompt([{
343
+ type: 'confirm',
344
+ name: 'confirm',
345
+ message: `Remove "${target}" and all its episode history?`,
346
+ default: false,
347
+ }]);
348
+ if (!confirm) return;
349
+
350
+ updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
351
+ db.removeAnime(target);
352
+ console.log(chalk.green(` ✔ Removed "${target}".\n`));
353
+ });
354
+
355
+ // ── enable / disable ──────────────────────────────────────────────────────
356
+
357
+ async function pickFromWatchList(message) {
358
+ const { resolveWatchList } = require('./engine');
359
+ const config = loadConfig();
360
+ const watchList = await resolveWatchList(config);
361
+
362
+ if (!watchList.length) {
363
+ console.log(chalk.yellow(' Watch list is empty.\n'));
364
+ return null;
365
+ }
366
+
367
+ console.log(chalk.bold('\n Watch list:'));
368
+ watchList.forEach((item, i) => {
369
+ console.log(` ${chalk.cyan(i + 1)}. ${item.title}`);
370
+ });
371
+ console.log('');
372
+
373
+ const { input } = await inquirer.prompt([{
374
+ type: 'input',
375
+ name: 'input',
376
+ message,
377
+ validate: (val) => {
378
+ const n = parseInt(val.trim());
379
+ if (isNaN(n) || n < 1 || n > watchList.length)
380
+ return `Enter a number between 1 and ${watchList.length}`;
381
+ return true;
382
+ }
383
+ }]);
384
+
385
+ return watchList[parseInt(input.trim()) - 1];
386
+ }
387
+
388
+ program
389
+ .command('enable')
390
+ .description('Re-enable downloads for an anime')
391
+ .action(async () => {
392
+ await db.initDb();
393
+ const item = await pickFromWatchList('Select anime to enable:');
394
+ if (!item) return;
395
+ db.setAnimeEnabled(item.title, true);
396
+ console.log(chalk.green(`\n ✔ Enabled "${item.title}".\n`));
397
+ });
398
+
399
+ program
400
+ .command('disable')
401
+ .description('Pause downloads for an anime (keeps history)')
402
+ .action(async () => {
403
+ await db.initDb();
404
+ const item = await pickFromWatchList('Select anime to disable:');
405
+ if (!item) return;
406
+ db.setAnimeEnabled(item.title, false);
407
+ console.log(chalk.yellow(`\n ✔ Disabled "${item.title}".\n`));
408
+ });
409
+
410
+ // ── status ────────────────────────────────────────────────────────────────
411
+
412
+ program
413
+ .command('status')
414
+ .description('Show recent run log')
415
+ .option('-n, --runs <n>', 'Number of recent runs to show', '10')
416
+ .action(async (opts) => {
417
+ banner();
418
+ await db.initDb();
419
+ const runs = db.getRecentRuns(parseInt(opts.runs, 10));
420
+ if (!runs.length) {
421
+ console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start.\n'));
422
+ return;
423
+ }
424
+
425
+ console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
426
+ console.log(' ' + '─'.repeat(55));
427
+ for (const run of runs) {
428
+ console.log(
429
+ ` ${run.ran_at.padEnd(20)} ` +
430
+ `${chalk.green(String(run.downloaded).padEnd(12))}` +
431
+ `${chalk.gray(String(run.skipped).padEnd(10))}` +
432
+ `${chalk.red(run.failed)}`
433
+ );
434
+ }
435
+ console.log('');
436
+ });
437
+
438
+ // ── config ────────────────────────────────────────────────────────────────
439
+
440
+ program
441
+ .command('config')
442
+ .description('View, update, or open config file')
443
+ .option('--open', 'Open config file in default editor')
444
+ .option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
445
+ .option('--set-language <lang>', 'Set default language (sub/dub)')
446
+ .option('--set-concurrency <n>', 'Set parallel download count')
447
+ .option('--set-output <dir>', 'Set output directory')
448
+ .option('--set-interval <minutes>', 'Set daemon polling interval')
449
+ .option('--set-cron <schedule>', 'Set cron schedule (e.g. "0 */2 * * *")')
450
+ .action((opts) => {
451
+ banner();
452
+
453
+ if (opts.open) {
454
+ const { execSync } = require('child_process');
455
+ const { getConfigPath } = require('./config');
456
+ const configPath = getConfigPath();
457
+ const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
458
+ console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
459
+ try {
460
+ execSync(`${editor} "${configPath}"`, { stdio: 'inherit' });
461
+ } catch (e) {
462
+ console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`));
463
+ }
464
+ return;
465
+ }
466
+
467
+ const updates = {};
468
+ if (opts.setQuality) updates.quality = opts.setQuality;
469
+ if (opts.setLanguage) updates.language = opts.setLanguage;
470
+ if (opts.setConcurrency)updates.concurrency = parseInt(opts.setConcurrency, 10);
471
+ if (opts.setOutput) updates.outputDir = opts.setOutput;
472
+ if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
473
+ if (opts.setCron) updates.cronSchedule = opts.setCron;
474
+
475
+ if (Object.keys(updates).length) {
476
+ const config = updateConfig(updates);
477
+ console.log(chalk.green(' ✔ Config updated:\n'));
478
+ for (const [k, v] of Object.entries(updates)) {
479
+ console.log(` ${chalk.bold(k)}: ${v}`);
480
+ }
481
+ } else {
482
+ // Print full config
483
+ const config = loadConfig();
484
+ console.log(chalk.bold(' Current configuration:\n'));
485
+ for (const [k, v] of Object.entries(config)) {
486
+ if (k === 'anilistToken' && v) {
487
+ console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
488
+ } else {
489
+ console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
490
+ }
491
+ }
492
+ }
493
+ console.log('');
494
+ });
495
+
496
+ // ── daemon ────────────────────────────────────────────────────────────────
497
+
498
+ program
499
+ .command('daemon')
500
+ .description('Start the background daemon')
501
+ .option('--auto', 'Skip prompts, auto-select all (default: true for daemon)')
502
+ .action(() => {
503
+ require('./daemon');
504
+ });
505
+
506
+ // ── Bootstrap ─────────────────────────────────────────────────────────────
507
+
508
+ program
509
+ .name('ani-auto')
510
+ .version(version)
511
+ .description('Automatic anime episode downloader — AniList-aware')
512
+ .parse(process.argv);
513
+
514
+ if (!process.argv.slice(2).length) {
515
+ banner();
516
+ program.help();
517
+ }
package/src/config.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+
10
+ const DEFAULTS = {
11
+ // AniList
12
+ anilistToken: null,
13
+ anilistUsername: null,
14
+
15
+ // Which AniList statuses to auto-download
16
+ watchStatuses: ['CURRENT'],
17
+
18
+ // Manual overrides: array of { title, quality, language, enabled }
19
+ manualList: [],
20
+
21
+ // Download settings
22
+ quality: '1080p',
23
+ language: 'sub',
24
+ concurrency: 2,
25
+ outputDir: path.join(os.homedir(), 'anime'),
26
+ maxRetries: 3,
27
+
28
+ // Scheduling
29
+ daemonIntervalMinutes: 30, // how often the daemon polls
30
+ cronSchedule: '0 */2 * * *', // every 2 hours (for cron mode)
31
+
32
+ // Behaviour
33
+ skipExisting: true, // skip episodes already in DB
34
+ maxEpisodesPerRun: 50, // safety cap per check cycle
35
+ };
36
+
37
+ function ensureConfigDir() {
38
+ if (!fs.existsSync(CONFIG_DIR)) {
39
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
40
+ }
41
+ }
42
+
43
+ function loadConfig() {
44
+ ensureConfigDir();
45
+ if (!fs.existsSync(CONFIG_FILE)) {
46
+ saveConfig(DEFAULTS);
47
+ return { ...DEFAULTS };
48
+ }
49
+ try {
50
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
51
+ return { ...DEFAULTS, ...JSON.parse(raw) };
52
+ } catch {
53
+ return { ...DEFAULTS };
54
+ }
55
+ }
56
+
57
+ function saveConfig(config) {
58
+ ensureConfigDir();
59
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
60
+ }
61
+
62
+ function updateConfig(partial) {
63
+ const current = loadConfig();
64
+ const updated = { ...current, ...partial };
65
+ saveConfig(updated);
66
+ return updated;
67
+ }
68
+
69
+ const getConfigPath = () => CONFIG_FILE;
70
+
71
+ module.exports = {
72
+ CONFIG_FILE,
73
+ CONFIG_DIR,
74
+ DEFAULTS,
75
+ loadConfig,
76
+ saveConfig,
77
+ updateConfig,
78
+ getConfigPath,
79
+ };
package/src/daemon.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * daemon.js
5
+ * Keeps running indefinitely, polling on a configurable interval.
6
+ * Also registers a cron schedule as a secondary trigger.
7
+ *
8
+ * Usage: node src/daemon.js
9
+ * npm start
10
+ */
11
+ const chalk = require('chalk');
12
+ const { CronJob } = require('cron');
13
+ const { loadConfig } = require('./config');
14
+ const { runCycle } = require('./engine');
15
+
16
+ let running = false; // prevents overlapping runs
17
+
18
+ async function safeRun() {
19
+ if (running) {
20
+ console.log(chalk.yellow(`[${ts()}] Previous run still in progress — skipping.`));
21
+ return;
22
+ }
23
+ running = true;
24
+ try {
25
+ await runCycle({ auto: true });
26
+ } catch (e) {
27
+ console.error(chalk.red(`[${ts()}] Unhandled error in run cycle:`), e.message);
28
+ } finally {
29
+ running = false;
30
+ }
31
+ }
32
+
33
+ function ts() { return new Date().toISOString().replace('T',' ').slice(0,19); }
34
+
35
+ async function startDaemon() {
36
+ const config = loadConfig();
37
+ const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
38
+ const cronSchedule = config.cronSchedule || '0 */2 * * *';
39
+
40
+ console.log(chalk.bold.red(`
41
+ █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
42
+ ██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
43
+ ███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
44
+ ██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
45
+ ██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
46
+ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
47
+ A N I M E A U T O D O W N L O A D E R D A E M O N
48
+ `));
49
+
50
+ console.log(chalk.cyan(` Interval: every ${config.daemonIntervalMinutes || 30} minutes`));
51
+ console.log(chalk.cyan(` Cron: ${cronSchedule}`));
52
+ console.log(chalk.cyan(` Output: ${config.outputDir}`));
53
+ console.log(chalk.gray(` Started: ${ts()}\n`));
54
+
55
+ // Run immediately on start
56
+ await safeRun();
57
+
58
+ // Interval-based polling
59
+ setInterval(safeRun, intervalMs);
60
+
61
+ // Cron-based trigger (secondary — e.g. aligned to midnight/air times)
62
+ const job = new CronJob(cronSchedule, safeRun, null, true, 'UTC');
63
+ job.start();
64
+
65
+ console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
66
+
67
+ // Graceful shutdown
68
+ process.on('SIGINT', () => { console.log(chalk.yellow('\n Shutting down daemon...')); process.exit(0); });
69
+ process.on('SIGTERM', () => { console.log(chalk.yellow('\n Daemon terminated.')); process.exit(0); });
70
+ }
71
+
72
+ startDaemon().catch(e => {
73
+ console.error(chalk.red('[FATAL]'), e.message);
74
+ process.exit(1);
75
+ });