ani-auto 1.2.5 → 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,6 +25,8 @@ 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')
@@ -39,7 +41,7 @@ program
39
41
  default: current.anilistUsername || '',
40
42
  }]);
41
43
 
42
- let anilistToken = current.anilistToken || '';
44
+ let anilistToken = current.anilistToken || '';
43
45
  let watchStatuses = current.watchStatuses || ['CURRENT'];
44
46
 
45
47
  if (anilistUsername) {
@@ -86,12 +88,11 @@ program
86
88
  }
87
89
 
88
90
  const answers = await inquirer.prompt([
89
- { type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '480p', '360p'], default: current.quality || '1080p' },
90
- { type: 'list', name: 'language', message: 'Default audio language:', choices: ['sub', 'dub'], default: current.language || 'sub' },
91
- { 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' },
91
+ { type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '360p'], default: current.quality || '1080p' },
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' },
92
93
  { type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
93
- { type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon polling interval (minutes):', default: current.daemonIntervalMinutes || 30 },
94
- { 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 || '' },
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 || '' },
95
96
  ]);
96
97
  answers.anilistUsername = anilistUsername;
97
98
  answers.anilistToken = anilistToken;
@@ -100,106 +101,106 @@ program
100
101
  if (answers.anilistToken) {
101
102
  const spinner = ora('Verifying AniList token...').start();
102
103
  try { const viewer = await getViewer(answers.anilistToken); spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`); }
103
- catch { spinner.warn('Could not verify token saved anyway, check it manually.'); }
104
+ catch { spinner.warn('Could not verify token - saved anyway, check it manually.'); }
104
105
  }
105
106
 
106
107
  updateConfig({
107
- anilistUsername: answers.anilistUsername || null,
108
- anilistToken: answers.anilistToken || null,
109
- watchStatuses: answers.watchStatuses || current.watchStatuses,
110
- quality: answers.quality,
111
- language: answers.language,
112
- concurrency: answers.concurrency,
113
- 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,
114
114
  daemonIntervalMinutes: answers.daemonIntervalMinutes,
115
- telegramChatId: answers.telegramChatId || null,
115
+ telegramChatId: answers.telegramChatId || null,
116
116
  });
117
117
 
118
- console.log(chalk.green(`\n Config saved to ${CONFIG_FILE}\n`));
119
- console.log(` Run ${chalk.bold('ani-auto check')} to do a manual download cycle.`);
120
- console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.\n`);
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`);
121
121
  });
122
122
 
123
+ // ── download ──────────────────────────────────────────────────────────────
124
+
123
125
  program
124
- .command('check')
126
+ .command('download')
125
127
  .description('Run one download cycle immediately')
126
- .option('--auto', 'Skip prompts, auto-select all anime and best site match')
127
- .action(async (opts) => {
128
+ .action(async () => {
128
129
  banner();
129
130
  try {
130
- const config = loadConfig();
131
- let quality = config.quality || '1080p';
132
- let language = config.language || 'sub';
133
- let range = null;
134
- let runStatuses = config.watchStatuses || ['CURRENT'];
135
-
136
- if (!opts.auto) {
137
- const { pickedQuality, saveAsDefault } = await inquirer.prompt([
138
- {
139
- type: 'list',
140
- name: 'pickedQuality',
141
- message: 'Quality for this run:',
142
- choices: ['1080p', '720p', '480p', '360p'],
143
- default: quality,
144
- },
145
- {
146
- type: 'confirm',
147
- name: 'saveAsDefault',
148
- message: 'Save as default quality?',
149
- default: false,
150
- },
151
- ]);
152
- quality = pickedQuality;
153
- if (saveAsDefault) {
154
- updateConfig({ quality });
155
- console.log(chalk.green(` ✔ 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([{
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
+ {
173
146
  type: 'confirm',
174
- name: 'useRange',
175
- message: 'Download a specific episode range?',
147
+ name: 'saveAsDefault',
148
+ message: 'Save as default quality?',
176
149
  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;
190
- }
191
- 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;
192
190
  }
193
- }]);
194
- range = rangeInput.trim();
195
- }
191
+ return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
192
+ }
193
+ }]);
194
+ range = rangeInput.trim();
196
195
  }
197
196
 
198
- await runCycle({ auto: !!opts.auto, quality, language, range, statuses: runStatuses });
197
+ await runCycle({ auto: false, quality, language, range, statuses: runStatuses });
199
198
  }
200
199
  catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
201
200
  });
202
201
 
202
+ // ── list ──────────────────────────────────────────────────────────────────
203
+
203
204
  program
204
205
  .command('list')
205
206
  .description('Show all tracked anime and their episode download status')
@@ -207,8 +208,8 @@ program
207
208
  banner();
208
209
  await db.initDb();
209
210
  const rawDb = db.getDb();
210
- const stmt = rawDb.prepare('SELECT * FROM anime');
211
- const all = [];
211
+ const stmt = rawDb.prepare('SELECT * FROM anime');
212
+ const all = [];
212
213
  while (stmt.step()) all.push(stmt.getAsObject());
213
214
  stmt.free();
214
215
 
@@ -223,17 +224,18 @@ program
223
224
  for (const row of all) {
224
225
  const stats = db.getEpisodeStats(row.id);
225
226
  const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
226
- 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;
227
228
  console.log(` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`);
228
229
  }
229
230
  console.log('');
230
231
  });
231
232
 
233
+ // ── add ───────────────────────────────────────────────────────────────────
234
+
232
235
  program
233
236
  .command('add <title>')
234
237
  .description('Search AniList and add an anime to the manual download list')
235
238
  .option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
236
- .option('-l, --language <lang>', 'Language override (sub/dub)', null)
237
239
  .action(async (title, opts) => {
238
240
  banner();
239
241
  await db.initDb();
@@ -244,22 +246,30 @@ program
244
246
  try { results = await searchAnime(title); spinner.stop(); }
245
247
  catch (e) { spinner.fail(`AniList search failed: ${e.message}`); return; }
246
248
 
247
- 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; }
248
250
 
249
251
  const { picked } = await inquirer.prompt([{
250
252
  type: 'list', name: 'picked', message: 'Select the anime:',
251
253
  choices: results.map(r => ({
252
- name: `${pickTitle(r.titles)} (${r.year || '?'}) ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
254
+ name: `${pickTitle(r.titles)} (${r.year || '?'}) - ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
253
255
  value: r,
254
256
  })),
255
257
  }]);
256
258
 
257
259
  const resolvedTitle = pickTitle(picked.titles);
258
- const config = loadConfig();
259
- const manualList = config.manualList || [];
260
+ const config = loadConfig();
261
+ const manualList = config.manualList || [];
260
262
 
261
263
  const existsManual = manualList.find(m => m.title.toLowerCase() === resolvedTitle.toLowerCase());
262
- 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
+ }
263
273
 
264
274
  // Check if already in AniList watch list (fuzzy match)
265
275
  const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -275,34 +285,40 @@ program
275
285
  const inAnilist = anilistEntries.find(e => {
276
286
  const t = e.titles;
277
287
  return [t.english, t.romaji, t.native].some(
278
- title => title && normalize(title) === normResolved
288
+ t2 => t2 && normalize(t2) === normResolved
279
289
  );
280
290
  });
281
291
  if (inAnilist) {
282
- 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
+ }
283
298
  return;
284
299
  }
285
300
  } catch (e) {}
286
301
  }
287
302
 
288
- 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 };
289
304
  manualList.push(entry);
290
305
  updateConfig({ manualList });
291
- 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 });
292
307
 
293
- console.log(chalk.green(`\n Added ${resolvedTitle} to manual list.`));
294
- if (opts.quality) console.log(` Quality: ${opts.quality}`);
295
- 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}`);
296
310
  console.log('');
297
311
  });
298
312
 
313
+ // ── remove ────────────────────────────────────────────────────────────────
314
+
299
315
  program
300
316
  .command('remove [title]')
301
317
  .description('Remove an anime from the manual list (interactive if no title given)')
302
318
  .action(async (title) => {
303
319
  banner();
304
320
  await db.initDb();
305
- const config = loadConfig();
321
+ const config = loadConfig();
306
322
  const manualList = config.manualList || [];
307
323
 
308
324
  if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.\n')); return; }
@@ -319,17 +335,22 @@ program
319
335
  const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
320
336
  if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.\n`)); return; }
321
337
 
322
- 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
+ }]);
323
342
  if (!confirm) return;
324
343
 
325
344
  updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
326
345
  db.removeAnime(target);
327
- console.log(chalk.green(` Removed "${target}".\n`));
346
+ console.log(chalk.green(`\n Done Removed "${target}".\n`));
328
347
  });
329
348
 
349
+ // ── enable / disable ──────────────────────────────────────────────────────
350
+
330
351
  async function pickFromWatchList(message) {
331
352
  const { resolveWatchList } = require('./engine');
332
- const config = loadConfig();
353
+ const config = loadConfig();
333
354
  const watchList = await resolveWatchList(config);
334
355
 
335
356
  if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return null; }
@@ -357,7 +378,7 @@ program
357
378
  const item = await pickFromWatchList('Select anime to enable:');
358
379
  if (!item) return;
359
380
  db.setAnimeEnabled(item.title, true);
360
- console.log(chalk.green(`\n Enabled "${item.title}".\n`));
381
+ console.log(chalk.green(`\n Done Enabled "${item.title}".\n`));
361
382
  });
362
383
 
363
384
  program
@@ -368,18 +389,51 @@ program
368
389
  const item = await pickFromWatchList('Select anime to disable:');
369
390
  if (!item) return;
370
391
  db.setAnimeEnabled(item.title, false);
371
- console.log(chalk.yellow(`\n Disabled "${item.title}".\n`));
392
+ console.log(chalk.yellow(`\n Done Disabled "${item.title}".\n`));
372
393
  });
373
394
 
395
+ // ── status ────────────────────────────────────────────────────────────────
396
+
374
397
  program
375
398
  .command('status')
376
- .description('Show recent run log')
377
- .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')
378
402
  .action(async (opts) => {
379
403
  banner();
380
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
+
381
435
  const runs = db.getRecentRuns(parseInt(opts.runs, 10));
382
- if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start.\n')); return; }
436
+ if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run ani-auto download to start.\n')); return; }
383
437
 
384
438
  console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
385
439
  console.log(' ' + '─'.repeat(55));
@@ -389,24 +443,25 @@ program
389
443
  console.log('');
390
444
  });
391
445
 
446
+ // ── config ────────────────────────────────────────────────────────────────
447
+
392
448
  program
393
449
  .command('config')
394
450
  .description('View, update, or open config file')
395
- .option('--open', 'Open config file in default editor')
396
- .option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
397
- .option('--set-language <lang>', 'Set default language (sub/dub)')
398
- .option('--set-concurrency <n>', 'Set parallel download count')
399
- .option('--set-output <dir>', 'Set output directory')
400
- .option('--set-interval <minutes>', 'Set daemon polling interval')
401
- .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)')
402
457
  .action((opts) => {
403
458
  banner();
404
459
 
405
460
  if (opts.open) {
406
- const { execSync } = require('child_process');
461
+ const { execSync } = require('child_process');
407
462
  const { getConfigPath } = require('./config');
408
- const configPath = getConfigPath();
409
- const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
463
+ const configPath = getConfigPath();
464
+ const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
410
465
  console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
411
466
  try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
412
467
  catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
@@ -414,16 +469,15 @@ program
414
469
  }
415
470
 
416
471
  const updates = {};
417
- if (opts.setQuality) updates.quality = opts.setQuality;
418
- if (opts.setLanguage) updates.language = opts.setLanguage;
419
- if (opts.setConcurrency) updates.concurrency = parseInt(opts.setConcurrency, 10);
420
- if (opts.setOutput) updates.outputDir = opts.setOutput;
421
- if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
422
- 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);
423
477
 
424
478
  if (Object.keys(updates).length) {
425
479
  updateConfig(updates);
426
- console.log(chalk.green(' Config updated:\n'));
480
+ console.log(chalk.green(' Done Config updated:\n'));
427
481
  for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
428
482
  } else {
429
483
  const config = loadConfig();
@@ -436,14 +490,157 @@ program
436
490
  console.log('');
437
491
  });
438
492
 
439
- // ── update ───────────────────────────────────────────────────────────────
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 ────────────────────────────────────────────────────────────────
602
+
603
+ program
604
+ .command('notify [action]')
605
+ .description('Enable or disable Telegram notifications (on, off)')
606
+ .action(async (action) => {
607
+ banner();
608
+ const config = loadConfig();
609
+
610
+ if (!action) {
611
+ const enabled = !!config.telegramChatId && config.telegramNotify !== false;
612
+ console.log(` Telegram notifications: ${enabled ? chalk.green('enabled') : chalk.gray('disabled')}`);
613
+ if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure.\n'));
614
+ else console.log('');
615
+ return;
616
+ }
617
+
618
+ switch (action.toLowerCase()) {
619
+ case 'on':
620
+ if (!config.telegramChatId) {
621
+ console.log(chalk.yellow(' No chat ID set - run ani-auto setup first.\n'));
622
+ return;
623
+ }
624
+ updateConfig({ telegramNotify: true });
625
+ console.log(chalk.green(' Done Telegram notifications enabled.\n'));
626
+ break;
627
+ case 'off':
628
+ updateConfig({ telegramNotify: false });
629
+ console.log(chalk.yellow(' Done Telegram notifications disabled.\n'));
630
+ break;
631
+ default:
632
+ console.log(chalk.yellow(` Unknown action "${action}". Use: on, off\n`));
633
+ }
634
+ });
635
+
636
+ // ── update ────────────────────────────────────────────────────────────────
440
637
 
441
638
  program
442
639
  .command('update')
443
640
  .description('Check for updates and install the latest version')
444
641
  .action(async () => {
445
642
  banner();
446
- const fetch = require('node-fetch');
643
+ const fetch = require('node-fetch');
447
644
  const { execSync } = require('child_process');
448
645
 
449
646
  const spinner = ora('Checking for updates...').start();
@@ -454,7 +651,7 @@ program
454
651
  spinner.stop();
455
652
 
456
653
  if (latest === version) {
457
- console.log(chalk.green(` Already on the latest version (v${version}).\n`));
654
+ console.log(chalk.green(` Done Already on the latest version (v${version}).\n`));
458
655
  return;
459
656
  }
460
657
 
@@ -462,18 +659,15 @@ program
462
659
  console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}\n`));
463
660
 
464
661
  const { confirm } = await inquirer.prompt([{
465
- type: 'confirm',
466
- name: 'confirm',
467
- message: `Update to v${latest}?`,
468
- default: true,
662
+ type: 'confirm', name: 'confirm',
663
+ message: `Update to v${latest}?`, default: true,
469
664
  }]);
470
-
471
665
  if (!confirm) return;
472
666
 
473
667
  const installing = ora(`Installing v${latest}...`).start();
474
668
  try {
475
669
  execSync('npm install -g ani-auto@latest', { stdio: 'pipe' });
476
- installing.succeed(`Updated to v${latest} restart your terminal to use the new version.`);
670
+ installing.succeed(`Updated to v${latest} - restart your terminal to use the new version.`);
477
671
  } catch (e) {
478
672
  installing.fail('Update failed. Try manually: npm install -g ani-auto@latest');
479
673
  }
@@ -486,15 +680,95 @@ program
486
680
  // ── daemon ────────────────────────────────────────────────────────────────
487
681
 
488
682
  program
489
- .command('daemon')
490
- .description('Start the background daemon')
491
- .option('--auto', 'Skip prompts, auto-select all (default: true for daemon)')
492
- .action(() => { require('./daemon'); });
683
+ .command('daemon [action]')
684
+ .description('Manage the background daemon (install, start, stop, restart, status, logs)')
685
+ .action(async (action) => {
686
+ const { spawnSync, spawn } = require('child_process');
687
+ const SERVICE = 'ani-auto';
688
+
689
+ const run = (cmd) => {
690
+ try {
691
+ const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
692
+ return { out: r.stdout?.trim(), err: r.stderr?.trim(), code: r.status };
693
+ } catch (e) {
694
+ return { out: '', err: e.message, code: 1 };
695
+ }
696
+ };
697
+
698
+ if (!action) { require('./daemon'); return; }
699
+
700
+ switch (action.toLowerCase()) {
701
+ case 'install': {
702
+ banner();
703
+ const nodeBin = run('which node').out;
704
+ const daemonPath = run('npm root -g').out + '/ani-auto/src/daemon.js';
705
+ const service = [
706
+ '[Unit]',
707
+ 'Description=ani-auto daemon',
708
+ 'After=network-online.target',
709
+ '',
710
+ '[Service]',
711
+ `ExecStart=${nodeBin} ${daemonPath}`,
712
+ 'Restart=on-failure',
713
+ 'RestartSec=10',
714
+ '',
715
+ '[Install]',
716
+ 'WantedBy=default.target',
717
+ ].join('\n');
718
+
719
+ const fs = require('fs');
720
+ const os = require('os');
721
+ const path = require('path');
722
+ const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
723
+ fs.mkdirSync(dir, { recursive: true });
724
+ fs.writeFileSync(path.join(dir, `${SERVICE}.service`), service);
725
+
726
+ run('systemctl --user daemon-reload');
727
+ run(`systemctl --user enable ${SERVICE}`);
728
+ run(`systemctl --user start ${SERVICE}`);
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`));
731
+ break;
732
+ }
733
+ case 'start': {
734
+ const r = run(`systemctl --user start ${SERVICE}`);
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`));
737
+ break;
738
+ }
739
+ case 'stop': {
740
+ const r = run(`systemctl --user stop ${SERVICE}`);
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`));
743
+ break;
744
+ }
745
+ case 'restart': {
746
+ const r = run(`systemctl --user restart ${SERVICE}`);
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`));
749
+ break;
750
+ }
751
+ case 'status': {
752
+ const r = run(`systemctl --user status ${SERVICE}`);
753
+ console.log(r.out || r.err);
754
+ break;
755
+ }
756
+ case 'logs': {
757
+ console.log(chalk.gray('\n Streaming logs (Ctrl+C to stop)...\n'));
758
+ spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
759
+ break;
760
+ }
761
+ default:
762
+ console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
763
+ }
764
+ });
765
+
766
+ // ── main ──────────────────────────────────────────────────────────────────
493
767
 
494
768
  program
495
769
  .name('ani-auto')
496
770
  .version(version)
497
- .description('Automatic anime episode downloader AniList-aware')
771
+ .description('Automatic anime episode downloader - AniList-aware')
498
772
  .parse(process.argv);
499
773
 
500
774
  if (!process.argv.slice(2).length) { banner(); program.help(); }