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 +22 -14
- 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 +35 -33
- package/src/scraper.js +3 -1
package/README.md
CHANGED
|
@@ -23,20 +23,28 @@ ani-auto check
|
|
|
23
23
|
|
|
24
24
|
## commands
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
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,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
|
|
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) {
|
|
30
|
-
return title
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
231
|
-
const
|
|
232
|
-
const
|
|
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
|
|
252
|
-
const link
|
|
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
|
-
|
|
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
|
-
|
|
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();
|