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 +37 -109
- package/package.json +1 -1
- package/src/cli.js +86 -221
- package/src/daemon.js +1 -1
- package/src/db.js +9 -2
- package/src/engine.js +20 -11
- package/src/scraper.js +3 -1
package/README.md
CHANGED
|
@@ -1,152 +1,81 @@
|
|
|
1
1
|
# ani-auto
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
7
|
+
## installation
|
|
14
8
|
|
|
15
9
|
```bash
|
|
16
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
83
|
-
ani-auto
|
|
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
|
-
##
|
|
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 ~/
|
|
95
|
-
ani-auto config --set-interval 60
|
|
96
|
-
ani-auto config --
|
|
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
|
-
##
|
|
64
|
+
## background service (linux)
|
|
121
65
|
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
|
|
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=
|
|
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
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
|
-
|
|
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:
|
|
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:
|
|
70
|
-
|
|
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:
|
|
96
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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:
|
|
171
|
-
anilistToken:
|
|
172
|
-
watchStatuses:
|
|
173
|
-
quality:
|
|
174
|
-
language:
|
|
175
|
-
concurrency:
|
|
176
|
-
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
326
|
-
name:
|
|
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:
|
|
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',
|
|
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
|
-
|
|
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)
|
|
469
|
-
if (opts.setLanguage)
|
|
470
|
-
if (opts.setConcurrency)updates.concurrency = parseInt(opts.setConcurrency, 10);
|
|
471
|
-
if (opts.setOutput)
|
|
472
|
-
if (opts.setInterval)
|
|
473
|
-
if (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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
145
|
-
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|