ani-auto 1.0.1 → 1.0.4

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/README.md CHANGED
@@ -23,20 +23,28 @@ ani-auto check
23
23
 
24
24
  ## commands
25
25
 
26
- | command | description |
27
- |---|---|
28
- | `setup` | first-time setup wizard |
29
- | `check` | download new episodes (interactive) |
30
- | `check --auto` | download everything, no prompts |
31
- | `list` | show tracked anime |
32
- | `add "<title>"` | add anime to manual list |
33
- | `remove` | remove from manual list |
34
- | `enable` | re-enable a paused anime |
35
- | `disable` | pause downloads for an anime |
36
- | `status` | view recent download history |
37
- | `config` | view or update settings |
38
- | `config --open` | open config file in editor |
39
- | `daemon` | start background auto-downloader |
26
+ ```
27
+ Usage: ani-auto [options] [command]
28
+
29
+ Automatic anime episode downloader AniList-aware
30
+
31
+ Options:
32
+ -V, --version output the version number
33
+ -h, --help display help for command
34
+
35
+ Commands:
36
+ setup Interactive first-time configuration wizard
37
+ check [options] Run one download cycle immediately
38
+ list Show all tracked anime and their episode download status
39
+ add [options] <title> Search AniList and add an anime to the manual download list
40
+ remove [title] Remove an anime from the manual list (interactive if no title given)
41
+ enable Re-enable downloads for an anime
42
+ disable Pause downloads for an anime (keeps history)
43
+ status [options] Show recent run log
44
+ config [options] View, update, or open config file
45
+ daemon [options] Start the background daemon
46
+ help [command] display help for command
47
+ ```
40
48
 
41
49
  ---
42
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Automatic anime episode downloader",
5
5
  "bin": {
6
6
  "ani-auto": "./src/cli.js"
package/src/cli.js CHANGED
@@ -1,22 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
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
4
  const { program } = require('commander');
21
5
  const inquirer = require('inquirer');
22
6
  const chalk = require('chalk');
@@ -28,10 +12,8 @@ const { runCycle } = require('./engine');
28
12
  const { getViewer } = require('./anilist');
29
13
  const { version } = require('../package.json');
30
14
 
31
- // ── Banner ────────────────────────────────────────────────────────────────
32
-
33
15
  function banner() {
34
- console.log(chalk.bold.red(`
16
+ console.log(chalk.bold.red(`
35
17
  █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
36
18
  ██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
37
19
  ███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
@@ -40,8 +22,7 @@ function banner() {
40
22
  ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
41
23
  A N I M E A U T O D O W N L O A D E R
42
24
  `));
43
- }
44
- // ── setup ─────────────────────────────────────────────────────────────────
25
+ }
45
26
 
46
27
  program
47
28
  .command('setup')
@@ -49,27 +30,21 @@ program
49
30
  .action(async () => {
50
31
  banner();
51
32
  const current = loadConfig();
52
-
53
33
  console.log(chalk.cyan(' Let\'s configure your auto-downloader.\n'));
54
34
 
55
- // Step 1: get username
56
35
  const { anilistUsername } = await inquirer.prompt([{
57
- type: 'input',
58
- name: 'anilistUsername',
36
+ type: 'input', name: 'anilistUsername',
59
37
  message: 'AniList username (leave blank to skip AniList integration):',
60
38
  default: current.anilistUsername || '',
61
39
  }]);
62
40
 
63
- // Step 2: if username given, open browser THEN ask for token
64
41
  let anilistToken = current.anilistToken || '';
65
42
  let watchStatuses = current.watchStatuses || ['CURRENT'];
66
43
 
67
44
  if (anilistUsername) {
68
45
  const { openBrowser } = await inquirer.prompt([{
69
- type: 'confirm',
70
- name: 'openBrowser',
71
- message: 'Open browser to get AniList token?',
72
- default: true,
46
+ type: 'confirm', name: 'openBrowser',
47
+ message: 'Open browser to get AniList token?', default: true,
73
48
  }]);
74
49
 
75
50
  if (openBrowser) {
@@ -80,11 +55,7 @@ program
80
55
  : ['xdg-open', 'firefox'];
81
56
  let opened = false;
82
57
  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) {}
58
+ try { spawn(opener, [url], { stdio: 'ignore', detached: true, env: process.env }).unref(); opened = true; break; } catch (e) {}
88
59
  }
89
60
  console.log(chalk.cyan(`\n ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
90
61
  console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).\n'));
@@ -92,16 +63,13 @@ program
92
63
  }
93
64
 
94
65
  const tokenAnswer = await inquirer.prompt([{
95
- type: 'password',
96
- name: 'anilistToken',
97
- message: 'Paste your AniList token here:',
98
- default: current.anilistToken || '',
66
+ type: 'password', name: 'anilistToken',
67
+ message: 'Paste your AniList token here:', default: current.anilistToken || '',
99
68
  }]);
100
69
  anilistToken = tokenAnswer.anilistToken;
101
70
 
102
71
  const statusAnswer = await inquirer.prompt([{
103
- type: 'list',
104
- name: 'watchStatuses',
72
+ type: 'list', name: 'watchStatuses',
105
73
  message: 'Which AniList status to download?',
106
74
  choices: [
107
75
  { name: 'CURRENT (Watching)', value: 'CURRENT' },
@@ -110,70 +78,36 @@ program
110
78
  { name: 'REPEATING', value: 'REPEATING' },
111
79
  ],
112
80
  default: (current.watchStatuses || ['CURRENT'])[0],
113
- filter: v => [v],
81
+ filter: v => [v],
114
82
  }]);
115
83
  watchStatuses = statusAnswer.watchStatuses;
116
84
  }
117
85
 
118
- // Step 3: rest of config
119
86
  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
- },
87
+ { type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '480p', '360p'], default: current.quality || '1080p' },
88
+ { type: 'list', name: 'language', message: 'Default audio language:', choices: ['sub', 'dub'], default: current.language || 'sub' },
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 1–10' },
90
+ { 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 },
153
92
  ]);
154
93
  answers.anilistUsername = anilistUsername;
155
94
  answers.anilistToken = anilistToken;
156
95
  answers.watchStatuses = watchStatuses;
157
96
 
158
- // Verify AniList token if provided
159
97
  if (answers.anilistToken) {
160
98
  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
- }
99
+ try { const viewer = await getViewer(answers.anilistToken); spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`); }
100
+ catch { spinner.warn('Could not verify token — saved anyway, check it manually.'); }
167
101
  }
168
102
 
169
103
  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,
104
+ anilistUsername: answers.anilistUsername || null,
105
+ anilistToken: answers.anilistToken || null,
106
+ watchStatuses: answers.watchStatuses || current.watchStatuses,
107
+ quality: answers.quality,
108
+ language: answers.language,
109
+ concurrency: answers.concurrency,
110
+ outputDir: answers.outputDir,
177
111
  daemonIntervalMinutes: answers.daemonIntervalMinutes,
178
112
  });
179
113
 
@@ -182,8 +116,6 @@ program
182
116
  console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.\n`);
183
117
  });
184
118
 
185
- // ── check ─────────────────────────────────────────────────────────────────
186
-
187
119
  program
188
120
  .command('check')
189
121
  .description('Run one download cycle immediately')
@@ -191,15 +123,38 @@ program
191
123
  .action(async (opts) => {
192
124
  banner();
193
125
  try {
194
- await runCycle({ auto: !!opts.auto });
195
- } catch (e) {
196
- console.error(chalk.red('[ERROR]'), e.message);
197
- process.exit(1);
126
+ const config = loadConfig();
127
+ let quality = config.quality || '1080p';
128
+ let language = config.language || 'sub';
129
+
130
+ if (!opts.auto) {
131
+ const { pickedQuality, saveAsDefault } = await inquirer.prompt([
132
+ {
133
+ type: 'list',
134
+ name: 'pickedQuality',
135
+ message: 'Quality for this run:',
136
+ choices: ['1080p', '720p', '480p', '360p'],
137
+ default: quality,
138
+ },
139
+ {
140
+ type: 'confirm',
141
+ name: 'saveAsDefault',
142
+ message: 'Save as default quality?',
143
+ default: false,
144
+ },
145
+ ]);
146
+ quality = pickedQuality;
147
+ if (saveAsDefault) {
148
+ updateConfig({ quality });
149
+ console.log(chalk.green(` ✔ Saved default quality: ${quality}\n`));
150
+ }
151
+ }
152
+
153
+ await runCycle({ auto: !!opts.auto, quality, language });
198
154
  }
155
+ catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
199
156
  });
200
157
 
201
- // ── list ──────────────────────────────────────────────────────────────────
202
-
203
158
  program
204
159
  .command('list')
205
160
  .description('Show all tracked anime and their episode download status')
@@ -220,21 +175,15 @@ program
220
175
 
221
176
  console.log(chalk.bold(` ${'Title'.padEnd(40)} ${'Source'.padEnd(8)} ${'Enabled'.padEnd(8)} Done Failed`));
222
177
  console.log(' ' + '─'.repeat(72));
223
-
224
178
  for (const row of all) {
225
179
  const stats = db.getEpisodeStats(row.id);
226
180
  const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
227
181
  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
- );
182
+ console.log(` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`);
232
183
  }
233
184
  console.log('');
234
185
  });
235
186
 
236
- // ── add ───────────────────────────────────────────────────────────────────
237
-
238
187
  program
239
188
  .command('add <title>')
240
189
  .description('Search AniList and add an anime to the manual download list')
@@ -243,30 +192,17 @@ program
243
192
  .action(async (title, opts) => {
244
193
  banner();
245
194
  await db.initDb();
246
-
247
195
  const { searchAnime, pickTitle } = require('./anilist');
248
- const ora = require('ora');
249
196
 
250
197
  const spinner = ora(`Searching AniList for "${title}"...`).start();
251
198
  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
- }
199
+ try { results = await searchAnime(title); spinner.stop(); }
200
+ catch (e) { spinner.fail(`AniList search failed: ${e.message}`); return; }
259
201
 
260
- if (!results.length) {
261
- console.log(chalk.yellow(` No results found on AniList for ${title}.`));
262
- return;
263
- }
202
+ if (!results.length) { console.log(chalk.yellow(` No results found on AniList for ${title}.`)); return; }
264
203
 
265
- // Let user pick the right match
266
204
  const { picked } = await inquirer.prompt([{
267
- type: 'list',
268
- name: 'picked',
269
- message: 'Select the anime:',
205
+ type: 'list', name: 'picked', message: 'Select the anime:',
270
206
  choices: results.map(r => ({
271
207
  name: `${pickTitle(r.titles)} (${r.year || '?'}) — ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
272
208
  value: r,
@@ -278,19 +214,9 @@ program
278
214
  const manualList = config.manualList || [];
279
215
 
280
216
  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
- };
217
+ if (exists) { console.log(chalk.yellow(` ${resolvedTitle} is already in your manual list.`)); return; }
293
218
 
219
+ const entry = { title: resolvedTitle, anilistId: picked.anilistId, enabled: true, quality: opts.quality || null, language: opts.language || null };
294
220
  manualList.push(entry);
295
221
  updateConfig({ manualList });
296
222
  db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: entry.language });
@@ -301,8 +227,6 @@ program
301
227
  console.log('');
302
228
  });
303
229
 
304
- // ── remove ────────────────────────────────────────────────────────────────
305
-
306
230
  program
307
231
  .command('remove [title]')
308
232
  .description('Remove an anime from the manual list (interactive if no title given)')
@@ -312,39 +236,21 @@ program
312
236
  const config = loadConfig();
313
237
  const manualList = config.manualList || [];
314
238
 
315
- if (!manualList.length) {
316
- console.log(chalk.yellow(' Your manual list is empty.\n'));
317
- return;
318
- }
239
+ if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.\n')); return; }
319
240
 
320
241
  let target = title;
321
-
322
- // If no title given, show interactive picker
323
242
  if (!target) {
324
243
  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
- })),
244
+ type: 'list', name: 'picked', message: 'Select anime to remove:',
245
+ choices: manualList.map(m => ({ name: `${m.title}${m.enabled ? '' : chalk.gray(' (disabled)')}`, value: m.title })),
332
246
  }]);
333
247
  target = picked;
334
248
  }
335
249
 
336
250
  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
- }
251
+ if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.\n`)); return; }
341
252
 
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
- }]);
253
+ const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: `Remove "${target}" and all its episode history?`, default: false }]);
348
254
  if (!confirm) return;
349
255
 
350
256
  updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
@@ -352,36 +258,25 @@ program
352
258
  console.log(chalk.green(` ✔ Removed "${target}".\n`));
353
259
  });
354
260
 
355
- // ── enable / disable ──────────────────────────────────────────────────────
356
-
357
261
  async function pickFromWatchList(message) {
358
262
  const { resolveWatchList } = require('./engine');
359
263
  const config = loadConfig();
360
264
  const watchList = await resolveWatchList(config);
361
265
 
362
- if (!watchList.length) {
363
- console.log(chalk.yellow(' Watch list is empty.\n'));
364
- return null;
365
- }
266
+ if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return null; }
366
267
 
367
268
  console.log(chalk.bold('\n Watch list:'));
368
- watchList.forEach((item, i) => {
369
- console.log(` ${chalk.cyan(i + 1)}. ${item.title}`);
370
- });
269
+ watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
371
270
  console.log('');
372
271
 
373
272
  const { input } = await inquirer.prompt([{
374
- type: 'input',
375
- name: 'input',
376
- message,
273
+ type: 'input', name: 'input', message,
377
274
  validate: (val) => {
378
275
  const n = parseInt(val.trim());
379
- if (isNaN(n) || n < 1 || n > watchList.length)
380
- return `Enter a number between 1 and ${watchList.length}`;
276
+ if (isNaN(n) || n < 1 || n > watchList.length) return `Enter a number between 1 and ${watchList.length}`;
381
277
  return true;
382
278
  }
383
279
  }]);
384
-
385
280
  return watchList[parseInt(input.trim()) - 1];
386
281
  }
387
282
 
@@ -407,8 +302,6 @@ program
407
302
  console.log(chalk.yellow(`\n ✔ Disabled "${item.title}".\n`));
408
303
  });
409
304
 
410
- // ── status ────────────────────────────────────────────────────────────────
411
-
412
305
  program
413
306
  .command('status')
414
307
  .description('Show recent run log')
@@ -417,30 +310,20 @@ program
417
310
  banner();
418
311
  await db.initDb();
419
312
  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
- }
313
+ if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start.\n')); return; }
424
314
 
425
315
  console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
426
316
  console.log(' ' + '─'.repeat(55));
427
317
  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
- );
318
+ console.log(` ${run.ran_at.padEnd(20)} ${chalk.green(String(run.downloaded).padEnd(12))}${chalk.gray(String(run.skipped).padEnd(10))}${chalk.red(run.failed)}`);
434
319
  }
435
320
  console.log('');
436
321
  });
437
322
 
438
- // ── config ────────────────────────────────────────────────────────────────
439
-
440
323
  program
441
324
  .command('config')
442
325
  .description('View, update, or open config file')
443
- .option('--open', 'Open config file in default editor')
326
+ .option('--open', 'Open config file in default editor')
444
327
  .option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
445
328
  .option('--set-language <lang>', 'Set default language (sub/dub)')
446
329
  .option('--set-concurrency <n>', 'Set parallel download count')
@@ -456,54 +339,39 @@ program
456
339
  const configPath = getConfigPath();
457
340
  const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
458
341
  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
- }
342
+ try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
343
+ catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
464
344
  return;
465
345
  }
466
346
 
467
347
  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;
348
+ if (opts.setQuality) updates.quality = opts.setQuality;
349
+ if (opts.setLanguage) updates.language = opts.setLanguage;
350
+ if (opts.setConcurrency) updates.concurrency = parseInt(opts.setConcurrency, 10);
351
+ if (opts.setOutput) updates.outputDir = opts.setOutput;
352
+ if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
353
+ if (opts.setCron) updates.cronSchedule = opts.setCron;
474
354
 
475
355
  if (Object.keys(updates).length) {
476
- const config = updateConfig(updates);
356
+ updateConfig(updates);
477
357
  console.log(chalk.green(' ✔ Config updated:\n'));
478
- for (const [k, v] of Object.entries(updates)) {
479
- console.log(` ${chalk.bold(k)}: ${v}`);
480
- }
358
+ for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
481
359
  } else {
482
- // Print full config
483
360
  const config = loadConfig();
484
361
  console.log(chalk.bold(' Current configuration:\n'));
485
362
  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
- }
363
+ if (k === 'anilistToken' && v) console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
364
+ else console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
491
365
  }
492
366
  }
493
367
  console.log('');
494
368
  });
495
369
 
496
- // ── daemon ────────────────────────────────────────────────────────────────
497
-
498
370
  program
499
371
  .command('daemon')
500
372
  .description('Start the background daemon')
501
373
  .option('--auto', 'Skip prompts, auto-select all (default: true for daemon)')
502
- .action(() => {
503
- require('./daemon');
504
- });
505
-
506
- // ── Bootstrap ─────────────────────────────────────────────────────────────
374
+ .action(() => { require('./daemon'); });
507
375
 
508
376
  program
509
377
  .name('ani-auto')
@@ -511,7 +379,4 @@ program
511
379
  .description('Automatic anime episode downloader — AniList-aware')
512
380
  .parse(process.argv);
513
381
 
514
- if (!process.argv.slice(2).length) {
515
- banner();
516
- program.help();
517
- }
382
+ if (!process.argv.slice(2).length) { banner(); program.help(); }
package/src/daemon.js CHANGED
@@ -44,7 +44,7 @@ async function startDaemon() {
44
44
  ██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
45
45
  ██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
46
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
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
48
  `));
49
49
 
50
50
  console.log(chalk.cyan(` Interval: every ${config.daemonIntervalMinutes || 30} minutes`));
package/src/db.js CHANGED
@@ -141,8 +141,15 @@ function getAllEnabledAnime() {
141
141
  return _all('SELECT * FROM anime WHERE enabled = 1');
142
142
  }
143
143
 
144
- function setAnimeEnabled(siteId, enabled) {
145
- _run('UPDATE anime SET enabled = ? WHERE site_id = ?', [enabled ? 1 : 0, siteId]);
144
+ function setAnimeEnabled(titleOrSiteId, enabled) {
145
+ // Try by title first, fall back to site_id
146
+ const byTitle = _get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [titleOrSiteId]);
147
+ if (byTitle) {
148
+ _run('UPDATE anime SET enabled = ? WHERE id = ?', [enabled ? 1 : 0, byTitle.id]);
149
+ } else {
150
+ _run('UPDATE anime SET enabled = ? WHERE site_id = ?', [enabled ? 1 : 0, titleOrSiteId]);
151
+ }
152
+ _save();
146
153
  }
147
154
 
148
155
  function removeAnime(siteId) {
package/src/engine.js CHANGED
@@ -21,13 +21,19 @@ function ts() { return new Date().toISOString().replace('T', ' ').slice(0,
21
21
 
22
22
  // ── Filename helpers ───────────────────────────────────────────────────────
23
23
 
24
- function buildFilename(title, episodeNum, season = null) {
25
- const s = season ? `Season ${season} ` : '';
26
- return `${title} ${s}Episode ${episodeNum}.mp4`;
24
+ function buildFilename(title, episodeNum, season = null, displayEpisodeNum = null) {
25
+ const s = season ? `Season ${season} ` : '';
26
+ const ep = displayEpisodeNum || episodeNum;
27
+ return `${title} ${s}Episode ${ep}.mp4`;
27
28
  }
28
29
 
29
30
  function buildFolderName(title) {
30
- return title.replace(/[<>:"/\\|?*]/g, '').trim();
31
+ return title
32
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // normalize smart single quotes
33
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // normalize smart double quotes
34
+ .replace(/[<>:"/\\|?*]/g, '') // strip illegal path chars
35
+ .replace(/ +/g, ' ') // collapse spaces
36
+ .trim();
31
37
  }
32
38
 
33
39
  function detectSeason(title) {
@@ -94,7 +100,6 @@ async function resolveWatchList(config) {
94
100
  // ── Anime selector prompt ──────────────────────────────────────────────────
95
101
 
96
102
  async function promptAnimeSelection(watchList) {
97
- // Show numbered list
98
103
  console.log(chalk.bold('\n Available anime:'));
99
104
  watchList.forEach((item, i) => {
100
105
  const watched = item.watchedEpisodes ? chalk.gray(` (watched: ${item.watchedEpisodes})`) : '';
@@ -124,20 +129,18 @@ async function promptAnimeSelection(watchList) {
124
129
 
125
130
  // ── Per-anime download ─────────────────────────────────────────────────────
126
131
 
127
- async function processAnime(workItem, config, multiBar, auto = false) {
128
- const q = workItem.quality || config.quality || '1080p';
129
- const l = workItem.language || config.language || 'sub';
132
+ async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null) {
133
+ const q = runQuality || workItem.quality || config.quality || '1080p';
134
+ const l = runLanguage || workItem.language || config.language || 'sub';
130
135
 
131
136
  // Check if we already have a confirmed site match saved in DB
132
137
  const existingRow = db.getAnimeBySiteTitle(workItem.title);
133
138
  let siteAnime;
134
139
 
135
140
  if (existingRow?.confirmed_site_id) {
136
- // Already confirmed before — use saved match directly
137
141
  siteAnime = { session: existingRow.confirmed_site_id, title: existingRow.confirmed_site_title };
138
142
  info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
139
143
  } else if (auto) {
140
- // Auto mode — use top search result, save it
141
144
  const siteResults = await scraper.searchAll(workItem.title);
142
145
  if (!siteResults.length) {
143
146
  warn(`[${workItem.title}] Not found on site — skipping.`);
@@ -147,16 +150,13 @@ async function processAnime(workItem, config, multiBar, auto = false) {
147
150
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
148
151
  info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
149
152
  } else {
150
- // Search and ask user to confirm
151
153
  const siteResults = await scraper.searchAll(workItem.title);
152
-
153
154
  if (!siteResults.length) {
154
155
  warn(`[${workItem.title}] Not found on site — skipping.`);
155
156
  return { downloaded: 0, skipped: 0, failed: 0 };
156
157
  }
157
158
 
158
159
  const topResults = siteResults.slice(0, 5);
159
-
160
160
  const { pick } = await inquirer.prompt([{
161
161
  type: 'list',
162
162
  name: 'pick',
@@ -176,8 +176,6 @@ async function processAnime(workItem, config, multiBar, auto = false) {
176
176
  }
177
177
 
178
178
  siteAnime = pick;
179
-
180
- // Save confirmed match to DB so daemon never prompts again
181
179
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
182
180
  ok(`[${workItem.title}] Site match saved: ${chalk.bold(siteAnime.title)}`);
183
181
  }
@@ -191,7 +189,9 @@ async function processAnime(workItem, config, multiBar, auto = false) {
191
189
  language: workItem.language || null,
192
190
  });
193
191
 
194
- if (!dbRow.enabled) {
192
+ // Check enabled flag by title (AniList title) to respect ani-auto disable
193
+ const enabledCheck = db.getAnimeBySiteTitle(workItem.title);
194
+ if (dbRow.enabled === 0 || enabledCheck?.enabled === 0) {
195
195
  info(`[${workItem.title}] Disabled — skipping.`);
196
196
  return { downloaded: 0, skipped: 0, failed: 0 };
197
197
  }
@@ -209,16 +209,10 @@ async function processAnime(workItem, config, multiBar, auto = false) {
209
209
  return { downloaded: 0, skipped: 0, failed: 0 };
210
210
  }
211
211
 
212
- // Sort episodes ascending by episode number
213
212
  const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
214
-
215
- // AniList progress = episodes watched *within this season/entry*.
216
- // The site may use absolute episode numbers (e.g. S2E1 = EP25).
217
- // So we skip the first N episodes by position, not by episode number.
218
- const watched = workItem.watchedEpisodes || 0;
219
- const unwatched = sortedEpisodes.slice(watched);
220
-
221
- const toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
213
+ const watched = workItem.watchedEpisodes || 0;
214
+ const unwatched = sortedEpisodes.slice(watched);
215
+ const toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
222
216
 
223
217
  if (!toDownload.length) {
224
218
  info(`[${workItem.title}] Nothing new (watched ${watched}/${sortedEpisodes.length} episodes).`);
@@ -227,9 +221,12 @@ async function processAnime(workItem, config, multiBar, auto = false) {
227
221
 
228
222
  info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
229
223
 
230
- const cleanTitle = buildFolderName(siteAnime.title || workItem.title);
231
- const season = detectSeason(cleanTitle);
232
- const outputDir = path.join(config.outputDir, cleanTitle);
224
+ // Use AniList title for folder — consistent with what user sees on AniList
225
+ const cleanTitle = buildFolderName(workItem.title);
226
+ const season = detectSeason(workItem.title);
227
+ // Strip "Season X" from the display title used in filename to avoid duplication
228
+ const displayTitle = cleanTitle.replace(/\s*season\s*\d+\s*/gi, '').trim();
229
+ const outputDir = path.join(config.outputDir, cleanTitle);
233
230
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
234
231
 
235
232
  const queue = [...toDownload];
@@ -248,11 +245,16 @@ async function processAnime(workItem, config, multiBar, auto = false) {
248
245
  while (!success && attempts < MAX_RETRIES) {
249
246
  attempts++;
250
247
  try {
251
- const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
252
- const link = scraper.pickLink(links, q, l);
248
+ const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
249
+ const link = scraper.pickLink(links, q, l);
253
250
  if (!link?.mp4Url) throw new Error('No suitable download link found');
254
251
 
255
- const filename = buildFilename(cleanTitle, ep.episode, season);
252
+ // Per-season site listing: position in sortedEpisodes = season episode number
253
+ const relativeEp = season
254
+ ? sortedEpisodes.findIndex(e => e.episode === ep.episode) + 1
255
+ : ep.episode;
256
+
257
+ const filename = buildFilename(displayTitle, ep.episode, season, relativeEp);
256
258
  await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
257
259
 
258
260
  db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
@@ -279,7 +281,7 @@ async function processAnime(workItem, config, multiBar, auto = false) {
279
281
 
280
282
  // ── Main cycle ─────────────────────────────────────────────────────────────
281
283
 
282
- async function runCycle({ auto = false } = {}) {
284
+ async function runCycle({ auto = false, quality = null, language = null } = {}) {
283
285
  await db.initDb();
284
286
  const config = loadConfig();
285
287
 
@@ -321,7 +323,7 @@ async function runCycle({ auto = false } = {}) {
321
323
  const totals = { downloaded: 0, skipped: 0, failed: 0 };
322
324
 
323
325
  for (const item of selected) {
324
- const result = await processAnime(item, config, multiBar, auto);
326
+ const result = await processAnime(item, config, multiBar, auto, quality, language);
325
327
  totals.downloaded += result.downloaded;
326
328
  totals.skipped += result.skipped;
327
329
  totals.failed += result.failed;
package/src/scraper.js CHANGED
@@ -55,7 +55,9 @@ async function searchAnime(title) {
55
55
  /** Returns all search results (up to API limit) for user confirmation. */
56
56
  async function searchAll(title) {
57
57
  const { SEARCH_API, USER_AGENT } = utils();
58
- const url = `${SEARCH_API}?q=${encodeURIComponent(title)}`;
58
+ // Strip special characters that confuse the search API
59
+ const cleaned = title.replace(/[-–—~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim();
60
+ const url = `${SEARCH_API}?q=${encodeURIComponent(cleaned)}`;
59
61
  const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
60
62
  if (!res.ok) return [];
61
63
  const data = await res.json();