ani-auto 1.3.0 → 1.3.1

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 CHANGED
@@ -8,10 +8,10 @@ const chalk = require('chalk');
8
8
  const ora = require('ora');
9
9
 
10
10
  const { loadConfig, updateConfig, CONFIG_FILE } = require('./config');
11
- const db = require('./db');
11
+ const db = require('./db');
12
12
  const { runCycle } = require('./engine');
13
13
  const { getViewer } = require('./anilist');
14
- const { version } = require('../package.json');
14
+ const { version } = require('../package.json');
15
15
 
16
16
  function banner() {
17
17
  console.log(chalk.bold.red(`
@@ -25,20 +25,23 @@ function banner() {
25
25
  `));
26
26
  }
27
27
 
28
+ // ── setup ─────────────────────────────────────────────────────────────────
29
+
28
30
  program
29
31
  .command('setup')
30
32
  .description('Interactive first-time configuration wizard')
31
33
  .action(async () => {
32
34
  banner();
33
35
  const current = loadConfig();
34
- console.log(chalk.cyan(' Let\'s configure your auto-downloader.'));
36
+ console.log(chalk.cyan(' Let\'s configure your auto-downloader.\n'));
37
+
35
38
  const { anilistUsername } = await inquirer.prompt([{
36
39
  type: 'input', name: 'anilistUsername',
37
40
  message: 'AniList username (leave blank to skip AniList integration):',
38
41
  default: current.anilistUsername || '',
39
42
  }]);
40
43
 
41
- let anilistToken = current.anilistToken || '';
44
+ let anilistToken = current.anilistToken || '';
42
45
  let watchStatuses = current.watchStatuses || ['CURRENT'];
43
46
 
44
47
  if (anilistUsername) {
@@ -57,8 +60,8 @@ program
57
60
  for (const opener of openers) {
58
61
  try { spawn(opener, [url], { stdio: 'ignore', detached: true, env: process.env }).unref(); opened = true; break; } catch (e) {}
59
62
  }
60
- console.log(chalk.cyan(` ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
61
- console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).'));
63
+ console.log(chalk.cyan(`\n ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
64
+ console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).\n'));
62
65
  await new Promise(r => setTimeout(r, 1500));
63
66
  }
64
67
 
@@ -86,10 +89,10 @@ program
86
89
 
87
90
  const answers = await inquirer.prompt([
88
91
  { type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '360p'], default: current.quality || '1080p' },
89
- { type: 'number', name: 'concurrency', message: 'Parallel downloads per run (1-10):', default: current.concurrency || 2, validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 110' },
92
+ { type: 'number', name: 'concurrency', message: 'Parallel downloads per run (1-10):', default: current.concurrency || 2, validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 1-10' },
90
93
  { type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
91
- { type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon polling interval (minutes):', default: current.daemonIntervalMinutes || 30 },
92
- { type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip): Message @userinfobot on Telegram to get your ID:', default: current.telegramChatId || '' },
94
+ { type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon fallback interval (minutes):', default: current.daemonIntervalMinutes || 120 },
95
+ { type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip):\n -> Message @userinfobot on Telegram to get your ID:', default: current.telegramChatId || '' },
93
96
  ]);
94
97
  answers.anilistUsername = anilistUsername;
95
98
  answers.anilistToken = anilistToken;
@@ -102,101 +105,102 @@ program
102
105
  }
103
106
 
104
107
  updateConfig({
105
- anilistUsername: answers.anilistUsername || null,
106
- anilistToken: answers.anilistToken || null,
107
- watchStatuses: answers.watchStatuses || current.watchStatuses,
108
- quality: answers.quality,
109
- concurrency: answers.concurrency,
110
- outputDir: answers.outputDir,
108
+ anilistUsername: answers.anilistUsername || null,
109
+ anilistToken: answers.anilistToken || null,
110
+ watchStatuses: answers.watchStatuses || current.watchStatuses,
111
+ quality: answers.quality,
112
+ concurrency: answers.concurrency,
113
+ outputDir: answers.outputDir,
111
114
  daemonIntervalMinutes: answers.daemonIntervalMinutes,
112
- telegramChatId: answers.telegramChatId || null,
115
+ telegramChatId: answers.telegramChatId || null,
113
116
  });
114
117
 
115
- console.log(chalk.green(` Config saved to ${CONFIG_FILE}`));
116
- console.log(` Run ${chalk.bold('ani-auto check')} to do a manual download cycle.`);
117
- console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.`);
118
+ console.log(chalk.green(`\n Done Config saved to ${CONFIG_FILE}\n`));
119
+ console.log(` Run ${chalk.bold('ani-auto download')} to start downloading.`);
120
+ console.log(` Run ${chalk.bold('ani-auto daemon install')} to set up the background service.\n`);
118
121
  });
119
122
 
123
+ // ── download ──────────────────────────────────────────────────────────────
124
+
120
125
  program
121
- .command('check')
126
+ .command('download')
122
127
  .description('Run one download cycle immediately')
123
- .option('--auto', 'Skip prompts, auto-select all anime and best site match')
124
- .action(async (opts) => {
128
+ .action(async () => {
125
129
  banner();
126
130
  try {
127
- const config = loadConfig();
128
- let quality = config.quality || '1080p';
129
- let language = config.language || 'sub';
130
- let range = null;
131
- let runStatuses = config.watchStatuses || ['CURRENT'];
132
-
133
- if (!opts.auto) {
134
- const { pickedQuality, saveAsDefault } = await inquirer.prompt([
135
- {
136
- type: 'list',
137
- name: 'pickedQuality',
138
- message: 'Quality for this run:',
139
- choices: ['1080p', '720p', '360p'],
140
- default: quality,
141
- },
142
- {
143
- type: 'confirm',
144
- name: 'saveAsDefault',
145
- message: 'Save as default quality?',
146
- default: false,
147
- },
148
- ]);
149
- quality = pickedQuality;
150
- if (saveAsDefault) {
151
- updateConfig({ quality });
152
- console.log(chalk.green(` ✔ Saved default quality: ${quality}`));
153
- }
154
-
155
- const { pickedStatuses } = await inquirer.prompt([{
156
- type: 'checkbox',
157
- name: 'pickedStatuses',
158
- message: 'AniList statuses to fetch for this run:',
159
- choices: [
160
- { name: 'CURRENT (Watching)', value: 'CURRENT', checked: (config.watchStatuses || ['CURRENT']).includes('CURRENT') },
161
- { name: 'PLANNING (Plan to Watch)', value: 'PLANNING', checked: (config.watchStatuses || []).includes('PLANNING') },
162
- { name: 'PAUSED', value: 'PAUSED', checked: (config.watchStatuses || []).includes('PAUSED') },
163
- { name: 'REPEATING', value: 'REPEATING', checked: (config.watchStatuses || []).includes('REPEATING') },
164
- ],
165
- validate: v => v.length > 0 ? true : 'Select at least one status',
166
- }]);
167
- runStatuses = pickedStatuses;
168
-
169
- const { useRange } = await inquirer.prompt([{
131
+ const config = loadConfig();
132
+ let quality = config.quality || '1080p';
133
+ let language = config.language || 'sub';
134
+ let range = null;
135
+ let runStatuses = config.watchStatuses || ['CURRENT'];
136
+
137
+ const { pickedQuality, saveAsDefault } = await inquirer.prompt([
138
+ {
139
+ type: 'list',
140
+ name: 'pickedQuality',
141
+ message: 'Quality for this run:',
142
+ choices: ['1080p', '720p', '360p'],
143
+ default: quality,
144
+ },
145
+ {
170
146
  type: 'confirm',
171
- name: 'useRange',
172
- message: 'Download a specific episode range?',
147
+ name: 'saveAsDefault',
148
+ message: 'Save as default quality?',
173
149
  default: false,
174
- }]);
175
- if (useRange) {
176
- const { rangeInput } = await inquirer.prompt([{
177
- type: 'input',
178
- name: 'rangeInput',
179
- message: 'Enter range (e.g. 1-24 or 5 for single episode):',
180
- validate: (v) => {
181
- const clean = v.trim();
182
- if (/^\d+$/.test(clean)) return true;
183
- if (/^\d+-\d+$/.test(clean)) {
184
- const [a, b] = clean.split('-').map(Number);
185
- if (a > b) return 'Start must be less than or equal to end';
186
- return true;
187
- }
188
- return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
150
+ },
151
+ ]);
152
+ quality = pickedQuality;
153
+ if (saveAsDefault) {
154
+ updateConfig({ quality });
155
+ console.log(chalk.green(` Done Saved default quality: ${quality}\n`));
156
+ }
157
+
158
+ const { pickedStatuses } = await inquirer.prompt([{
159
+ type: 'checkbox',
160
+ name: 'pickedStatuses',
161
+ message: 'AniList statuses to fetch for this run:',
162
+ choices: [
163
+ { name: 'CURRENT (Watching)', value: 'CURRENT', checked: (config.watchStatuses || ['CURRENT']).includes('CURRENT') },
164
+ { name: 'PLANNING (Plan to Watch)', value: 'PLANNING', checked: (config.watchStatuses || []).includes('PLANNING') },
165
+ { name: 'PAUSED', value: 'PAUSED', checked: (config.watchStatuses || []).includes('PAUSED') },
166
+ { name: 'REPEATING', value: 'REPEATING', checked: (config.watchStatuses || []).includes('REPEATING') },
167
+ ],
168
+ validate: v => v.length > 0 ? true : 'Select at least one status',
169
+ }]);
170
+ runStatuses = pickedStatuses;
171
+
172
+ const { useRange } = await inquirer.prompt([{
173
+ type: 'confirm',
174
+ name: 'useRange',
175
+ message: 'Download a specific episode range?',
176
+ default: false,
177
+ }]);
178
+ if (useRange) {
179
+ const { rangeInput } = await inquirer.prompt([{
180
+ type: 'input',
181
+ name: 'rangeInput',
182
+ message: 'Enter range (e.g. 1-24 or 5 for single episode):',
183
+ validate: (v) => {
184
+ const clean = v.trim();
185
+ if (/^\d+$/.test(clean)) return true;
186
+ if (/^\d+-\d+$/.test(clean)) {
187
+ const [a, b] = clean.split('-').map(Number);
188
+ if (a > b) return 'Start must be less than or equal to end';
189
+ return true;
189
190
  }
190
- }]);
191
- range = rangeInput.trim();
192
- }
191
+ return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
192
+ }
193
+ }]);
194
+ range = rangeInput.trim();
193
195
  }
194
196
 
195
- await runCycle({ auto: !!opts.auto, quality, language, range, statuses: runStatuses });
197
+ await runCycle({ auto: false, quality, language, range, statuses: runStatuses });
196
198
  }
197
199
  catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
198
200
  });
199
201
 
202
+ // ── list ──────────────────────────────────────────────────────────────────
203
+
200
204
  program
201
205
  .command('list')
202
206
  .description('Show all tracked anime and their episode download status')
@@ -204,14 +208,14 @@ program
204
208
  banner();
205
209
  await db.initDb();
206
210
  const rawDb = db.getDb();
207
- const stmt = rawDb.prepare('SELECT * FROM anime');
208
- const all = [];
211
+ const stmt = rawDb.prepare('SELECT * FROM anime');
212
+ const all = [];
209
213
  while (stmt.step()) all.push(stmt.getAsObject());
210
214
  stmt.free();
211
215
 
212
216
  if (!all.length) {
213
217
  console.log(chalk.yellow(' No anime tracked yet.'));
214
- console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.`);
218
+ console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.\n`);
215
219
  return;
216
220
  }
217
221
 
@@ -220,17 +224,18 @@ program
220
224
  for (const row of all) {
221
225
  const stats = db.getEpisodeStats(row.id);
222
226
  const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
223
- const title = row.title.length > 38 ? row.title.slice(0, 37) + '' : row.title;
227
+ const title = row.title.length > 38 ? row.title.slice(0, 37) + '...' : row.title;
224
228
  console.log(` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`);
225
229
  }
226
230
  console.log('');
227
231
  });
228
232
 
233
+ // ── add ───────────────────────────────────────────────────────────────────
234
+
229
235
  program
230
236
  .command('add <title>')
231
237
  .description('Search AniList and add an anime to the manual download list')
232
238
  .option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
233
- .option('-l, --language <lang>', 'Language override (sub/dub)', null)
234
239
  .action(async (title, opts) => {
235
240
  banner();
236
241
  await db.initDb();
@@ -241,7 +246,7 @@ program
241
246
  try { results = await searchAnime(title); spinner.stop(); }
242
247
  catch (e) { spinner.fail(`AniList search failed: ${e.message}`); return; }
243
248
 
244
- if (!results.length) { console.log(chalk.yellow(` No results found on AniList for ${title}.`)); return; }
249
+ if (!results.length) { console.log(chalk.yellow(` No results found on AniList for "${title}".\n`)); return; }
245
250
 
246
251
  const { picked } = await inquirer.prompt([{
247
252
  type: 'list', name: 'picked', message: 'Select the anime:',
@@ -252,11 +257,19 @@ program
252
257
  }]);
253
258
 
254
259
  const resolvedTitle = pickTitle(picked.titles);
255
- const config = loadConfig();
256
- const manualList = config.manualList || [];
260
+ const config = loadConfig();
261
+ const manualList = config.manualList || [];
257
262
 
258
263
  const existsManual = manualList.find(m => m.title.toLowerCase() === resolvedTitle.toLowerCase());
259
- if (existsManual) { console.log(chalk.yellow(` "${resolvedTitle}" is already in your manual list.`)); return; }
264
+ if (existsManual) {
265
+ if (opts.quality) {
266
+ db.setAnimeQuality(resolvedTitle, opts.quality);
267
+ console.log(chalk.green(`\n Done Quality for "${resolvedTitle}" set to ${opts.quality}.\n`));
268
+ } else {
269
+ console.log(chalk.yellow(` "${resolvedTitle}" is already in your manual list.\n`));
270
+ }
271
+ return;
272
+ }
260
273
 
261
274
  // Check if already in AniList watch list (fuzzy match)
262
275
  const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -272,37 +285,43 @@ program
272
285
  const inAnilist = anilistEntries.find(e => {
273
286
  const t = e.titles;
274
287
  return [t.english, t.romaji, t.native].some(
275
- title => title && normalize(title) === normResolved
288
+ t2 => t2 && normalize(t2) === normResolved
276
289
  );
277
290
  });
278
291
  if (inAnilist) {
279
- console.log(chalk.yellow(` "${resolvedTitle}" is already in your AniList list (${inAnilist.status}) - no need to add manually.`));
292
+ if (opts.quality) {
293
+ db.setAnimeQuality(resolvedTitle, opts.quality);
294
+ console.log(chalk.green(`\n Done Quality for "${resolvedTitle}" set to ${opts.quality}.\n`));
295
+ } else {
296
+ console.log(chalk.yellow(` "${resolvedTitle}" is already in your AniList list (${inAnilist.status}) - no need to add manually.\n`));
297
+ }
280
298
  return;
281
299
  }
282
300
  } catch (e) {}
283
301
  }
284
302
 
285
- const entry = { title: resolvedTitle, anilistId: picked.anilistId, enabled: true, quality: opts.quality || null, language: opts.language || null };
303
+ const entry = { title: resolvedTitle, anilistId: picked.anilistId, enabled: true, quality: opts.quality || null, language: null };
286
304
  manualList.push(entry);
287
305
  updateConfig({ manualList });
288
- db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: entry.language });
306
+ db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: null });
289
307
 
290
- console.log(chalk.green(` Added ${resolvedTitle} to manual list.`));
291
- if (opts.quality) console.log(` Quality: ${opts.quality}`);
292
- if (opts.language) console.log(` Language: ${opts.language}`);
308
+ console.log(chalk.green(`\n Done Added "${resolvedTitle}" to manual list.`));
309
+ if (opts.quality) console.log(` Quality: ${opts.quality}`);
293
310
  console.log('');
294
311
  });
295
312
 
313
+ // ── remove ────────────────────────────────────────────────────────────────
314
+
296
315
  program
297
316
  .command('remove [title]')
298
317
  .description('Remove an anime from the manual list (interactive if no title given)')
299
318
  .action(async (title) => {
300
319
  banner();
301
320
  await db.initDb();
302
- const config = loadConfig();
321
+ const config = loadConfig();
303
322
  const manualList = config.manualList || [];
304
323
 
305
- if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.')); return; }
324
+ if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.\n')); return; }
306
325
 
307
326
  let target = title;
308
327
  if (!target) {
@@ -314,24 +333,29 @@ program
314
333
  }
315
334
 
316
335
  const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
317
- if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.`)); return; }
336
+ if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.\n`)); return; }
318
337
 
319
- const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: `Remove "${target}" and all its episode history?`, default: false }]);
338
+ const { confirm } = await inquirer.prompt([{
339
+ type: 'confirm', name: 'confirm',
340
+ message: `Remove "${target}" and all its episode history?`, default: false,
341
+ }]);
320
342
  if (!confirm) return;
321
343
 
322
344
  updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
323
345
  db.removeAnime(target);
324
- console.log(chalk.green(` Removed "${target}".`));
346
+ console.log(chalk.green(`\n Done Removed "${target}".\n`));
325
347
  });
326
348
 
349
+ // ── enable / disable ──────────────────────────────────────────────────────
350
+
327
351
  async function pickFromWatchList(message) {
328
352
  const { resolveWatchList } = require('./engine');
329
- const config = loadConfig();
353
+ const config = loadConfig();
330
354
  const watchList = await resolveWatchList(config);
331
355
 
332
- if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.')); return null; }
356
+ if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return null; }
333
357
 
334
- console.log(chalk.bold(' Watch list:'));
358
+ console.log(chalk.bold('\n Watch list:'));
335
359
  watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
336
360
  console.log('');
337
361
 
@@ -354,7 +378,7 @@ program
354
378
  const item = await pickFromWatchList('Select anime to enable:');
355
379
  if (!item) return;
356
380
  db.setAnimeEnabled(item.title, true);
357
- console.log(chalk.green(` Enabled "${item.title}".`));
381
+ console.log(chalk.green(`\n Done Enabled "${item.title}".\n`));
358
382
  });
359
383
 
360
384
  program
@@ -365,18 +389,51 @@ program
365
389
  const item = await pickFromWatchList('Select anime to disable:');
366
390
  if (!item) return;
367
391
  db.setAnimeEnabled(item.title, false);
368
- console.log(chalk.yellow(` Disabled "${item.title}".`));
392
+ console.log(chalk.yellow(`\n Done Disabled "${item.title}".\n`));
369
393
  });
370
394
 
395
+ // ── status ────────────────────────────────────────────────────────────────
396
+
371
397
  program
372
398
  .command('status')
373
- .description('Show recent run log')
374
- .option('-n, --runs <n>', 'Number of recent runs to show', '10')
399
+ .description('Show recent run log or per-anime episode breakdown')
400
+ .option('-n, --runs <n>', 'Number of recent runs to show', '10')
401
+ .option('--anime <title>', 'Show episode breakdown for a specific anime')
375
402
  .action(async (opts) => {
376
403
  banner();
377
404
  await db.initDb();
405
+
406
+ if (opts.anime) {
407
+ const row = db.getAnimeBySiteTitle(opts.anime);
408
+ if (!row) {
409
+ console.log(chalk.yellow(` "${opts.anime}" not found. Run ani-auto list to see tracked anime.\n`));
410
+ return;
411
+ }
412
+ const episodes = db.getAllEpisodes(row.id);
413
+ if (!episodes.length) {
414
+ console.log(chalk.yellow(` No episodes tracked for "${opts.anime}" yet.\n`));
415
+ return;
416
+ }
417
+ const title = row.anilist_title || row.title;
418
+ console.log(chalk.bold(`\n ${title}\n`));
419
+ console.log(chalk.bold(` ${'EP'.padEnd(6)} ${'Status'.padEnd(14)} ${'Filename'.padEnd(45)} Downloaded`));
420
+ console.log(' ' + '─'.repeat(85));
421
+ for (const ep of episodes) {
422
+ const status = ep.status === 'done'
423
+ ? chalk.green('done')
424
+ : ep.status === 'failed'
425
+ ? chalk.red('failed')
426
+ : chalk.gray(ep.status);
427
+ const filename = ep.filename ? ep.filename.slice(0, 43) : '-';
428
+ const date = ep.downloaded_at ? ep.downloaded_at.slice(0, 10) : '-';
429
+ console.log(` ${String(ep.episode_num).padEnd(6)} ${status.padEnd(22)} ${filename.padEnd(45)} ${date}`);
430
+ }
431
+ console.log('');
432
+ return;
433
+ }
434
+
378
435
  const runs = db.getRecentRuns(parseInt(opts.runs, 10));
379
- if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start.')); return; }
436
+ if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run ani-auto download to start.\n')); return; }
380
437
 
381
438
  console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
382
439
  console.log(' ' + '─'.repeat(55));
@@ -386,45 +443,45 @@ program
386
443
  console.log('');
387
444
  });
388
445
 
446
+ // ── config ────────────────────────────────────────────────────────────────
447
+
389
448
  program
390
449
  .command('config')
391
450
  .description('View, update, or open config file')
392
- .option('--open', 'Open config file in default editor')
393
- .option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
394
- .option('--set-language <lang>', 'Set default language (sub/dub)')
395
- .option('--set-concurrency <n>', 'Set parallel download count')
396
- .option('--set-output <dir>', 'Set output directory')
397
- .option('--set-interval <minutes>', 'Set daemon polling interval')
398
- .option('--set-cron <schedule>', 'Set cron schedule (e.g. "0 */2 * * *")')
451
+ .option('--open', 'Open config file in default editor')
452
+ .option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
453
+ .option('--set-concurrency <n>', 'Set parallel download count')
454
+ .option('--set-output <dir>', 'Set output directory')
455
+ .option('--set-interval <minutes>', 'Set daemon fallback interval in minutes')
456
+ .option('--set-airing-buffer <min>', 'Set airing buffer in minutes (default: 5)')
399
457
  .action((opts) => {
400
458
  banner();
401
459
 
402
460
  if (opts.open) {
403
- const { execSync } = require('child_process');
461
+ const { execSync } = require('child_process');
404
462
  const { getConfigPath } = require('./config');
405
- const configPath = getConfigPath();
406
- const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
407
- console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}`));
463
+ const configPath = getConfigPath();
464
+ const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
465
+ console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
408
466
  try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
409
467
  catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
410
468
  return;
411
469
  }
412
470
 
413
471
  const updates = {};
414
- if (opts.setQuality) updates.quality = opts.setQuality;
415
- if (opts.setLanguage) updates.language = opts.setLanguage;
416
- if (opts.setConcurrency) updates.concurrency = parseInt(opts.setConcurrency, 10);
417
- if (opts.setOutput) updates.outputDir = opts.setOutput;
418
- if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
419
- if (opts.setCron) updates.cronSchedule = opts.setCron;
472
+ if (opts.setQuality) updates.quality = opts.setQuality;
473
+ if (opts.setConcurrency) updates.concurrency = parseInt(opts.setConcurrency, 10);
474
+ if (opts.setOutput) updates.outputDir = opts.setOutput;
475
+ if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
476
+ if (opts.setAiringBuffer) updates.airingBufferMinutes = parseInt(opts.setAiringBuffer, 10);
420
477
 
421
478
  if (Object.keys(updates).length) {
422
479
  updateConfig(updates);
423
- console.log(chalk.green(' Config updated:'));
480
+ console.log(chalk.green(' Done Config updated:\n'));
424
481
  for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
425
482
  } else {
426
483
  const config = loadConfig();
427
- console.log(chalk.bold(' Current configuration:'));
484
+ console.log(chalk.bold(' Current configuration:\n'));
428
485
  for (const [k, v] of Object.entries(config)) {
429
486
  if (k === 'anilistToken' && v) console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
430
487
  else console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
@@ -433,11 +490,119 @@ program
433
490
  console.log('');
434
491
  });
435
492
 
436
- // ── notify ───────────────────────────────────────────────────────────────
493
+ // ── retry ─────────────────────────────────────────────────────────────────
494
+
495
+ program
496
+ .command('retry')
497
+ .description('Re-attempt all failed episode downloads')
498
+ .action(async () => {
499
+ banner();
500
+ await db.initDb();
501
+ const failed = db.getFailedEpisodes();
502
+
503
+ if (!failed.length) {
504
+ console.log(chalk.green(' No failed episodes found.\n'));
505
+ return;
506
+ }
507
+
508
+ const byAnime = {};
509
+ for (const ep of failed) {
510
+ const key = ep.anilist_title || ep.title;
511
+ if (!byAnime[key]) byAnime[key] = { episodes: [] };
512
+ byAnime[key].episodes.push(ep);
513
+ }
514
+
515
+ console.log(chalk.bold('\n Failed episodes:'));
516
+ Object.entries(byAnime).forEach(([title, { episodes }], i) => {
517
+ console.log(` ${chalk.cyan(i + 1)}. ${title} - ${chalk.red(episodes.length + ' failed')}`);
518
+ });
519
+ console.log(` ${chalk.cyan('a')}. Retry all\n`);
520
+
521
+ const { input } = await inquirer.prompt([{
522
+ type: 'input', name: 'input',
523
+ message: 'Select anime to retry (e.g. 1,2 or "a" for all):',
524
+ default: 'a',
525
+ validate: v => {
526
+ if (v.trim().toLowerCase() === 'a') return true;
527
+ const nums = v.split(',').map(s => parseInt(s.trim()));
528
+ if (nums.some(isNaN)) return 'Enter numbers or "a"';
529
+ return true;
530
+ }
531
+ }]);
532
+
533
+ const keys = Object.keys(byAnime);
534
+ const selected = input.trim().toLowerCase() === 'a'
535
+ ? keys
536
+ : input.split(',').map(s => keys[parseInt(s.trim()) - 1]).filter(Boolean);
537
+
538
+ for (const key of selected) {
539
+ const { episodes } = byAnime[key];
540
+ for (const ep of episodes) {
541
+ db.markEpisodeStatus(ep.anime_id, ep.episode_num, 'pending', null, null);
542
+ }
543
+ console.log(chalk.cyan(` Reset ${episodes.length} episode(s) for ${key}`));
544
+ }
545
+
546
+ console.log(chalk.green('\n Done - run ani-auto download to re-attempt them.\n'));
547
+ });
548
+
549
+ // ── quality ───────────────────────────────────────────────────────────────
550
+
551
+ program
552
+ .command('quality [title] [quality]')
553
+ .description('Set per-anime quality override (interactive if no args given)')
554
+ .action(async (title, quality) => {
555
+ banner();
556
+ await db.initDb();
557
+
558
+ if (!title) {
559
+ const { resolveWatchList } = require('./engine');
560
+ const config = loadConfig();
561
+ const watchList = await resolveWatchList(config);
562
+
563
+ if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return; }
564
+
565
+ console.log(chalk.bold('\n Select anime:'));
566
+ watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
567
+ console.log('');
568
+
569
+ const { idx } = await inquirer.prompt([{
570
+ type: 'input', name: 'idx', message: 'Enter number:',
571
+ validate: v => {
572
+ const n = parseInt(v.trim());
573
+ return (isNaN(n) || n < 1 || n > watchList.length) ? `Enter 1-${watchList.length}` : true;
574
+ }
575
+ }]);
576
+ title = watchList[parseInt(idx.trim()) - 1].title;
577
+ }
578
+
579
+ if (!quality) {
580
+ const { picked } = await inquirer.prompt([{
581
+ type: 'list', name: 'picked',
582
+ message: `Quality for "${title}":`,
583
+ choices: [
584
+ { name: '1080p', value: '1080p' },
585
+ { name: '720p', value: '720p' },
586
+ { name: '360p', value: '360p' },
587
+ { name: 'Use global default', value: null },
588
+ ],
589
+ }]);
590
+ quality = picked;
591
+ }
592
+
593
+ const ok = db.setAnimeQuality(title, quality);
594
+ if (ok) {
595
+ console.log(chalk.green(`\n Done Quality for "${title}" set to ${quality || 'global default'}.\n`));
596
+ } else {
597
+ console.log(chalk.yellow(`\n "${title}" not found in DB - run ani-auto download first to track it.\n`));
598
+ }
599
+ });
600
+
601
+ // ── notify ────────────────────────────────────────────────────────────────
437
602
 
438
603
  program
439
604
  .command('notify [action]')
440
- .description('Enable or disable Telegram notifications (on, off, status)')
605
+ .description('Enable or disable Telegram notifications (on, off)')
441
606
  .action(async (action) => {
442
607
  banner();
443
608
  const config = loadConfig();
@@ -445,7 +610,7 @@ program
445
610
  if (!action) {
446
611
  const enabled = !!config.telegramChatId && config.telegramNotify !== false;
447
612
  console.log(` Telegram notifications: ${enabled ? chalk.green('enabled') : chalk.gray('disabled')}`);
448
- if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure. '));
613
+ if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure.\n'));
449
614
  else console.log('');
450
615
  return;
451
616
  }
@@ -453,31 +618,29 @@ program
453
618
  switch (action.toLowerCase()) {
454
619
  case 'on':
455
620
  if (!config.telegramChatId) {
456
- console.log(chalk.yellow(' No chat ID set - run ani-auto setup first. '));
621
+ console.log(chalk.yellow(' No chat ID set - run ani-auto setup first.\n'));
457
622
  return;
458
623
  }
459
624
  updateConfig({ telegramNotify: true });
460
- console.log(chalk.green(' Telegram notifications enabled. '));
625
+ console.log(chalk.green(' Done Telegram notifications enabled.\n'));
461
626
  break;
462
-
463
627
  case 'off':
464
628
  updateConfig({ telegramNotify: false });
465
- console.log(chalk.yellow(' Telegram notifications disabled. '));
629
+ console.log(chalk.yellow(' Done Telegram notifications disabled.\n'));
466
630
  break;
467
-
468
631
  default:
469
- console.log(chalk.yellow(` Unknown action "${action}". Use: on, off`));
632
+ console.log(chalk.yellow(` Unknown action "${action}". Use: on, off\n`));
470
633
  }
471
634
  });
472
635
 
473
- // ── update ───────────────────────────────────────────────────────────────
636
+ // ── update ────────────────────────────────────────────────────────────────
474
637
 
475
638
  program
476
639
  .command('update')
477
640
  .description('Check for updates and install the latest version')
478
641
  .action(async () => {
479
642
  banner();
480
- const fetch = require('node-fetch');
643
+ const fetch = require('node-fetch');
481
644
  const { execSync } = require('child_process');
482
645
 
483
646
  const spinner = ora('Checking for updates...').start();
@@ -488,20 +651,17 @@ program
488
651
  spinner.stop();
489
652
 
490
653
  if (latest === version) {
491
- console.log(chalk.green(` Already on the latest version (v${version}).`));
654
+ console.log(chalk.green(` Done Already on the latest version (v${version}).\n`));
492
655
  return;
493
656
  }
494
657
 
495
658
  console.log(chalk.cyan(` Current version: ${chalk.bold(version)}`));
496
- console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}`));
659
+ console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}\n`));
497
660
 
498
661
  const { confirm } = await inquirer.prompt([{
499
- type: 'confirm',
500
- name: 'confirm',
501
- message: `Update to v${latest}?`,
502
- default: true,
662
+ type: 'confirm', name: 'confirm',
663
+ message: `Update to v${latest}?`, default: true,
503
664
  }]);
504
-
505
665
  if (!confirm) return;
506
666
 
507
667
  const installing = ora(`Installing v${latest}...`).start();
@@ -521,11 +681,11 @@ program
521
681
 
522
682
  program
523
683
  .command('daemon [action]')
524
- .description('Manage the background daemon (start, stop, restart, status, install)')
684
+ .description('Manage the background daemon (install, start, stop, restart, status, logs)')
525
685
  .action(async (action) => {
526
- const { execSync, spawnSync } = require('child_process');
686
+ const { spawnSync, spawn } = require('child_process');
687
+ const SERVICE = 'ani-auto';
527
688
 
528
- const SERVICE = 'ani-auto';
529
689
  const run = (cmd) => {
530
690
  try {
531
691
  const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
@@ -535,19 +695,14 @@ program
535
695
  }
536
696
  };
537
697
 
538
- // No action - run inline (legacy behaviour)
539
- if (!action) {
540
- require('./daemon');
541
- return;
542
- }
698
+ if (!action) { require('./daemon'); return; }
543
699
 
544
700
  switch (action.toLowerCase()) {
545
-
546
701
  case 'install': {
547
702
  banner();
548
- const nodeBin = run('which node').out;
703
+ const nodeBin = run('which node').out;
549
704
  const daemonPath = run('npm root -g').out + '/ani-auto/src/daemon.js';
550
- const serviceContent = [
705
+ const service = [
551
706
  '[Unit]',
552
707
  'Description=ani-auto daemon',
553
708
  'After=network-online.target',
@@ -566,57 +721,50 @@ program
566
721
  const path = require('path');
567
722
  const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
568
723
  fs.mkdirSync(dir, { recursive: true });
569
- fs.writeFileSync(path.join(dir, `${SERVICE}.service`), serviceContent);
724
+ fs.writeFileSync(path.join(dir, `${SERVICE}.service`), service);
570
725
 
571
726
  run('systemctl --user daemon-reload');
572
727
  run(`systemctl --user enable ${SERVICE}`);
573
728
  run(`systemctl --user start ${SERVICE}`);
574
- console.log(chalk.green(` ani-auto daemon installed and started.`));
575
- console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon status')} to check.
576
- `));
729
+ console.log(chalk.green(`\n Done Daemon installed and started.`));
730
+ console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon status')} to check.\n`));
577
731
  break;
578
732
  }
579
-
580
733
  case 'start': {
581
734
  const r = run(`systemctl --user start ${SERVICE}`);
582
- if (r.code === 0) console.log(chalk.green(' Daemon started. '));
583
- else console.log(chalk.red(` Failed to start: ${r.err}
584
- Try: ani-auto daemon install`));
735
+ if (r.code === 0) console.log(chalk.green('\n Done Daemon started.\n'));
736
+ else console.log(chalk.red(`\n Failed to start: ${r.err}\n Try: ani-auto daemon install\n`));
585
737
  break;
586
738
  }
587
-
588
739
  case 'stop': {
589
740
  const r = run(`systemctl --user stop ${SERVICE}`);
590
- if (r.code === 0) console.log(chalk.yellow(' Daemon stopped. '));
591
- else console.log(chalk.red(` Failed to stop: ${r.err}`));
741
+ if (r.code === 0) console.log(chalk.yellow('\n Done Daemon stopped.\n'));
742
+ else console.log(chalk.red(`\n Failed to stop: ${r.err}\n`));
592
743
  break;
593
744
  }
594
-
595
745
  case 'restart': {
596
746
  const r = run(`systemctl --user restart ${SERVICE}`);
597
- if (r.code === 0) console.log(chalk.green(' Daemon restarted. '));
598
- else console.log(chalk.red(` Failed to restart: ${r.err}`));
747
+ if (r.code === 0) console.log(chalk.green('\n Done Daemon restarted.\n'));
748
+ else console.log(chalk.red(`\n Failed to restart: ${r.err}\n`));
599
749
  break;
600
750
  }
601
-
602
751
  case 'status': {
603
752
  const r = run(`systemctl --user status ${SERVICE}`);
604
753
  console.log(r.out || r.err);
605
754
  break;
606
755
  }
607
-
608
756
  case 'logs': {
609
- console.log(chalk.gray(' Streaming logs (Ctrl+C to stop)... '));
610
- const { spawn } = require('child_process');
757
+ console.log(chalk.gray('\n Streaming logs (Ctrl+C to stop)...\n'));
611
758
  spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
612
759
  break;
613
760
  }
614
-
615
761
  default:
616
- console.log(chalk.yellow(` Unknown action "${action}". Use: install, start, stop, restart, status, logs`));
762
+ console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
617
763
  }
618
764
  });
619
765
 
766
+ // ── main ──────────────────────────────────────────────────────────────────
767
+
620
768
  program
621
769
  .name('ani-auto')
622
770
  .version(version)