ani-auto 1.0.0 → 1.0.3

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
@@ -1,152 +1,81 @@
1
1
  # ani-auto
2
2
 
3
- Automatic anime episode downloader.
4
- Watches your **AniList** list (or a manual list) and downloads new episodes the moment they appear on the site — with full quality/language control, a background daemon, cron scheduling etc.
5
- ---
6
-
7
- ## Requirements
8
-
9
- - Node.js 18+
3
+ automatically downloads new anime episodes based on your anilist watching list.
10
4
 
11
5
  ---
12
6
 
13
- ## Installation
7
+ ## installation
14
8
 
15
9
  ```bash
16
- # ani-auto/ ← this project
17
-
18
- cd ani-auto
19
- npm install
20
-
21
- # 2. Link the CLI globally (optional)
22
- npm link
10
+ npm install -g ani-auto
23
11
  ```
24
12
 
25
13
  ---
26
14
 
27
- ## Quick Start
15
+ ## quick start
28
16
 
29
17
  ```bash
30
- # First-time setup wizard
31
18
  ani-auto setup
32
-
33
- # Manually trigger one download cycle
34
19
  ani-auto check
35
-
36
- # Start the background daemon
37
- ani-auto daemon
38
- # or: npm start
39
20
  ```
40
21
 
41
22
  ---
42
23
 
43
- ## How It Works
44
-
45
- 1. **AniList** — queries your list for the configured statuses (e.g. `CURRENT`).
46
- For each anime, it searches the download site, fetches the episode list, and downloads anything not already in the database.
47
-
48
- 2. **Manual list** — entries added via `ani-auto add` are checked the same way.
49
- Manual entries support per-anime quality/language overrides.
50
-
51
- 3. **State** — SQLite (`~/.config/ani-auto/state.db`) tracks every episode as `pending → downloading → done / failed`. Already-downloaded episodes are never re-fetched.
52
-
53
- 4. **Scheduling** — the daemon runs on both:
54
- - A **polling interval** (default: every 30 min)
55
- - A **cron schedule** (default: `0 */2 * * *` — every 2 hours)
56
-
57
- ---
58
-
59
- ## Commands
60
-
61
- | Command | Description |
62
- |---|---|
63
- | `setup` | Interactive first-time wizard |
64
- | `check` | Run one download cycle now |
65
- | `list` | Show all tracked anime |
66
- | `add "<title>"` | Add to manual list |
67
- | `remove "<title>"` | Remove from tracking |
68
- | `enable "<title>"` | Re-enable downloads |
69
- | `disable "<title>"` | Pause without removing |
70
- | `status` | View recent run log |
71
- | `config` | View / update settings |
72
- | `daemon` | Start background daemon |
73
-
74
- ---
75
-
76
- ## Manual Add Options
77
-
78
- ```bash
79
- # Default quality/language from global config
80
- ani-auto add "Frieren: Beyond Journey's End"
24
+ ## commands
81
25
 
82
- # Per-anime overrides
83
- ani-auto add "One Piece" --quality 720p --language dub
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
84
47
  ```
85
48
 
86
49
  ---
87
50
 
88
- ## Config Options
51
+ ## config options
89
52
 
90
53
  ```bash
91
54
  ani-auto config --set-quality 1080p
92
55
  ani-auto config --set-language sub
93
56
  ani-auto config --set-concurrency 3
94
- ani-auto config --set-output ~/Videos/Anime
95
- ani-auto config --set-interval 60 # daemon: poll every 60 min
96
- ani-auto config --set-cron "0 6 * * *" # also trigger at 6 AM daily
57
+ ani-auto config --set-output ~/videos/anime
58
+ ani-auto config --set-interval 60
59
+ ani-auto config --open
97
60
  ```
98
61
 
99
- Full config lives at `~/.config/ani-auto/config.json`.
100
-
101
- ---
102
-
103
- ## AniList Statuses
104
-
105
- Configured during `setup` or by editing `config.json`:
106
-
107
- | Status | Meaning |
108
- |---|---|
109
- | `CURRENT` | Currently watching (recommended) |
110
- | `PLANNING` | Plan to watch |
111
- | `PAUSED` | Paused |
112
- | `REPEATING` | Re-watching |
113
-
114
- ---
115
-
116
-
117
-
118
62
  ---
119
63
 
120
- ## File Structure
64
+ ## background service (linux)
121
65
 
122
- ```
123
- ani-auto/
124
- ├── src/
125
- │ ├── cli.js ← entry point for all commands
126
- │ ├── daemon.js ← background daemon
127
- │ ├── engine.js ← core run cycle logic
128
- │ ├── scraper.js ← adapter over k-anime utils
129
- │ ├── anilist.js ← AniList GraphQL client
130
- │ ├── db.js ← SQLite state layer
131
- │ └── config.js ← JSON config manager
132
- ├── package.json
133
- ├── utils.js
134
- └── README.md
66
+ ```bash
67
+ mkdir -p ~/.config/systemd/user
68
+ nano ~/.config/systemd/user/ani-auto.service
135
69
  ```
136
70
 
137
- ---
138
-
139
- ## Systemd Service (Linux/Arch)
140
-
141
71
  ```ini
142
- # ~/.config/systemd/user/ani-auto.service
143
72
  [Unit]
144
- Description=K-Anime Auto Downloader
73
+ Description=ani-auto daemon
74
+ After=network-online.target
145
75
 
146
76
  [Service]
147
77
  ExecStart=/usr/bin/node /path/to/ani-auto/src/daemon.js
148
78
  Restart=on-failure
149
- RestartSec=10
150
79
 
151
80
  [Install]
152
81
  WantedBy=default.target
@@ -154,6 +83,5 @@ WantedBy=default.target
154
83
 
155
84
  ```bash
156
85
  systemctl --user enable --now ani-auto
157
- systemctl --user status ani-auto
158
86
  journalctl --user -u ani-auto -f
159
- ```
87
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
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,9 +21,10 @@ 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) {
@@ -124,9 +125,9 @@ async function promptAnimeSelection(watchList) {
124
125
 
125
126
  // ── Per-anime download ─────────────────────────────────────────────────────
126
127
 
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';
128
+ async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null) {
129
+ const q = runQuality || workItem.quality || config.quality || '1080p';
130
+ const l = runLanguage || workItem.language || config.language || 'sub';
130
131
 
131
132
  // Check if we already have a confirmed site match saved in DB
132
133
  const existingRow = db.getAnimeBySiteTitle(workItem.title);
@@ -191,7 +192,9 @@ async function processAnime(workItem, config, multiBar, auto = false) {
191
192
  language: workItem.language || null,
192
193
  });
193
194
 
194
- if (!dbRow.enabled) {
195
+ // Check enabled flag — also check by title in case site match not yet confirmed
196
+ const enabledCheck = db.getAnimeBySiteTitle(workItem.title);
197
+ if (dbRow.enabled === 0 || enabledCheck?.enabled === 0) {
195
198
  info(`[${workItem.title}] Disabled — skipping.`);
196
199
  return { downloaded: 0, skipped: 0, failed: 0 };
197
200
  }
@@ -228,7 +231,8 @@ async function processAnime(workItem, config, multiBar, auto = false) {
228
231
  info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
229
232
 
230
233
  const cleanTitle = buildFolderName(siteAnime.title || workItem.title);
231
- const season = detectSeason(cleanTitle);
234
+ // detectSeason from site title first, fall back to AniList title
235
+ const season = detectSeason(cleanTitle) || detectSeason(workItem.title);
232
236
  const outputDir = path.join(config.outputDir, cleanTitle);
233
237
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
234
238
 
@@ -252,7 +256,12 @@ async function processAnime(workItem, config, multiBar, auto = false) {
252
256
  const link = scraper.pickLink(links, q, l);
253
257
  if (!link?.mp4Url) throw new Error('No suitable download link found');
254
258
 
255
- const filename = buildFilename(cleanTitle, ep.episode, season);
259
+ // Each site listing is per-season, so episode position in sortedEpisodes
260
+ // directly equals the season episode number (1-indexed).
261
+ const relativeEp = season
262
+ ? sortedEpisodes.findIndex(e => e.episode === ep.episode) + 1
263
+ : ep.episode;
264
+ const filename = buildFilename(cleanTitle, ep.episode, season, relativeEp);
256
265
  await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
257
266
 
258
267
  db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
@@ -279,7 +288,7 @@ async function processAnime(workItem, config, multiBar, auto = false) {
279
288
 
280
289
  // ── Main cycle ─────────────────────────────────────────────────────────────
281
290
 
282
- async function runCycle({ auto = false } = {}) {
291
+ async function runCycle({ auto = false, quality = null, language = null } = {}) {
283
292
  await db.initDb();
284
293
  const config = loadConfig();
285
294
 
@@ -321,7 +330,7 @@ async function runCycle({ auto = false } = {}) {
321
330
  const totals = { downloaded: 0, skipped: 0, failed: 0 };
322
331
 
323
332
  for (const item of selected) {
324
- const result = await processAnime(item, config, multiBar, auto);
333
+ const result = await processAnime(item, config, multiBar, auto, quality, language);
325
334
  totals.downloaded += result.downloaded;
326
335
  totals.skipped += result.skipped;
327
336
  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();