ani-auto 1.2.4 → 1.3.0
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 +35 -8
- package/package.json +1 -1
- package/src/cli.js +162 -36
- package/src/db.js +24 -10
- package/src/engine.js +10 -1
- package/src/notify.js +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
```
|
|
2
|
+
█████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
|
|
3
|
+
██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
|
|
4
|
+
███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
|
|
5
|
+
██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
|
|
6
|
+
██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
|
|
7
|
+
╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
|
|
8
|
+
A N I M E A U T O D O W N L O A D E R
|
|
9
|
+
```
|
|
10
|
+
|
|
1
11
|
# ani-auto
|
|
2
12
|
|
|
3
13
|
automatically downloads new anime episodes based on your anilist watching list.
|
|
@@ -61,6 +71,7 @@ Commands:
|
|
|
61
71
|
disable Pause downloads for an anime (keeps history)
|
|
62
72
|
status [options] Show recent run log
|
|
63
73
|
config [options] View, update, or open config file
|
|
74
|
+
update Check for updates and install the latest version
|
|
64
75
|
daemon [options] Start the background daemon
|
|
65
76
|
help [command] display help for command
|
|
66
77
|
```
|
|
@@ -71,7 +82,6 @@ Commands:
|
|
|
71
82
|
|
|
72
83
|
```bash
|
|
73
84
|
ani-auto config --set-quality 1080p
|
|
74
|
-
ani-auto config --set-language sub
|
|
75
85
|
ani-auto config --set-concurrency 3
|
|
76
86
|
ani-auto config --set-output ~/videos/anime
|
|
77
87
|
ani-auto config --set-interval 60
|
|
@@ -82,25 +92,42 @@ ani-auto config --open
|
|
|
82
92
|
|
|
83
93
|
## background service (linux)
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
mkdir -p ~/.config/systemd/user
|
|
87
|
-
nano ~/.config/systemd/user/ani-auto.service
|
|
88
|
-
```
|
|
95
|
+
run this once to install and start the daemon as a systemd service:
|
|
89
96
|
|
|
90
|
-
```
|
|
97
|
+
```bash
|
|
98
|
+
mkdir -p ~/.config/systemd/user && \
|
|
99
|
+
NODE_BIN=$(which node) && \
|
|
100
|
+
DAEMON_PATH=$(npm root -g)/ani-auto/src/daemon.js && \
|
|
101
|
+
cat > ~/.config/systemd/user/ani-auto.service << SERVICE
|
|
91
102
|
[Unit]
|
|
92
103
|
Description=ani-auto daemon
|
|
93
104
|
After=network-online.target
|
|
94
105
|
|
|
95
106
|
[Service]
|
|
96
|
-
ExecStart
|
|
107
|
+
ExecStart=$NODE_BIN $DAEMON_PATH
|
|
97
108
|
Restart=on-failure
|
|
109
|
+
RestartSec=10
|
|
98
110
|
|
|
99
111
|
[Install]
|
|
100
112
|
WantedBy=default.target
|
|
113
|
+
SERVICE
|
|
114
|
+
systemctl --user daemon-reload && \
|
|
115
|
+
systemctl --user enable --now ani-auto && \
|
|
116
|
+
echo "ani-auto daemon installed and started"
|
|
101
117
|
```
|
|
102
118
|
|
|
119
|
+
**useful commands:**
|
|
120
|
+
|
|
103
121
|
```bash
|
|
104
|
-
|
|
122
|
+
# check status
|
|
123
|
+
systemctl --user status ani-auto
|
|
124
|
+
|
|
125
|
+
# view live logs
|
|
105
126
|
journalctl --user -u ani-auto -f
|
|
127
|
+
|
|
128
|
+
# restart
|
|
129
|
+
systemctl --user restart ani-auto
|
|
130
|
+
|
|
131
|
+
# stop
|
|
132
|
+
systemctl --user stop ani-auto
|
|
106
133
|
```
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -31,8 +31,7 @@ program
|
|
|
31
31
|
.action(async () => {
|
|
32
32
|
banner();
|
|
33
33
|
const current = loadConfig();
|
|
34
|
-
console.log(chalk.cyan(' Let\'s configure your auto-downloader
|
|
35
|
-
|
|
34
|
+
console.log(chalk.cyan(' Let\'s configure your auto-downloader.'));
|
|
36
35
|
const { anilistUsername } = await inquirer.prompt([{
|
|
37
36
|
type: 'input', name: 'anilistUsername',
|
|
38
37
|
message: 'AniList username (leave blank to skip AniList integration):',
|
|
@@ -58,8 +57,8 @@ program
|
|
|
58
57
|
for (const opener of openers) {
|
|
59
58
|
try { spawn(opener, [url], { stdio: 'ignore', detached: true, env: process.env }).unref(); opened = true; break; } catch (e) {}
|
|
60
59
|
}
|
|
61
|
-
console.log(chalk.cyan(
|
|
62
|
-
console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=)
|
|
60
|
+
console.log(chalk.cyan(` ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
|
|
61
|
+
console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).'));
|
|
63
62
|
await new Promise(r => setTimeout(r, 1500));
|
|
64
63
|
}
|
|
65
64
|
|
|
@@ -86,12 +85,11 @@ program
|
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
const answers = await inquirer.prompt([
|
|
89
|
-
{ type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '
|
|
90
|
-
{ type: 'list', name: 'language', message: 'Default audio language:', choices: ['sub', 'dub'], default: current.language || 'sub' },
|
|
88
|
+
{ type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '360p'], default: current.quality || '1080p' },
|
|
91
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' },
|
|
92
90
|
{ type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
|
|
93
91
|
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon polling interval (minutes):', default: current.daemonIntervalMinutes || 30 },
|
|
94
|
-
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip)
|
|
92
|
+
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip): → Message @userinfobot on Telegram to get your ID:', default: current.telegramChatId || '' },
|
|
95
93
|
]);
|
|
96
94
|
answers.anilistUsername = anilistUsername;
|
|
97
95
|
answers.anilistToken = anilistToken;
|
|
@@ -100,7 +98,7 @@ program
|
|
|
100
98
|
if (answers.anilistToken) {
|
|
101
99
|
const spinner = ora('Verifying AniList token...').start();
|
|
102
100
|
try { const viewer = await getViewer(answers.anilistToken); spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`); }
|
|
103
|
-
catch { spinner.warn('Could not verify token
|
|
101
|
+
catch { spinner.warn('Could not verify token - saved anyway, check it manually.'); }
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
updateConfig({
|
|
@@ -108,16 +106,15 @@ program
|
|
|
108
106
|
anilistToken: answers.anilistToken || null,
|
|
109
107
|
watchStatuses: answers.watchStatuses || current.watchStatuses,
|
|
110
108
|
quality: answers.quality,
|
|
111
|
-
language: answers.language,
|
|
112
109
|
concurrency: answers.concurrency,
|
|
113
110
|
outputDir: answers.outputDir,
|
|
114
111
|
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
115
112
|
telegramChatId: answers.telegramChatId || null,
|
|
116
113
|
});
|
|
117
114
|
|
|
118
|
-
console.log(chalk.green(
|
|
115
|
+
console.log(chalk.green(` ✔ Config saved to ${CONFIG_FILE}`));
|
|
119
116
|
console.log(` Run ${chalk.bold('ani-auto check')} to do a manual download cycle.`);
|
|
120
|
-
console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon
|
|
117
|
+
console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.`);
|
|
121
118
|
});
|
|
122
119
|
|
|
123
120
|
program
|
|
@@ -139,7 +136,7 @@ program
|
|
|
139
136
|
type: 'list',
|
|
140
137
|
name: 'pickedQuality',
|
|
141
138
|
message: 'Quality for this run:',
|
|
142
|
-
choices: ['1080p', '720p', '
|
|
139
|
+
choices: ['1080p', '720p', '360p'],
|
|
143
140
|
default: quality,
|
|
144
141
|
},
|
|
145
142
|
{
|
|
@@ -152,7 +149,7 @@ program
|
|
|
152
149
|
quality = pickedQuality;
|
|
153
150
|
if (saveAsDefault) {
|
|
154
151
|
updateConfig({ quality });
|
|
155
|
-
console.log(chalk.green(` ✔ Saved default quality: ${quality}
|
|
152
|
+
console.log(chalk.green(` ✔ Saved default quality: ${quality}`));
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
const { pickedStatuses } = await inquirer.prompt([{
|
|
@@ -214,7 +211,7 @@ program
|
|
|
214
211
|
|
|
215
212
|
if (!all.length) {
|
|
216
213
|
console.log(chalk.yellow(' No anime tracked yet.'));
|
|
217
|
-
console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually
|
|
214
|
+
console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.`);
|
|
218
215
|
return;
|
|
219
216
|
}
|
|
220
217
|
|
|
@@ -249,7 +246,7 @@ program
|
|
|
249
246
|
const { picked } = await inquirer.prompt([{
|
|
250
247
|
type: 'list', name: 'picked', message: 'Select the anime:',
|
|
251
248
|
choices: results.map(r => ({
|
|
252
|
-
name: `${pickTitle(r.titles)} (${r.year || '?'})
|
|
249
|
+
name: `${pickTitle(r.titles)} (${r.year || '?'}) - ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
|
|
253
250
|
value: r,
|
|
254
251
|
})),
|
|
255
252
|
}]);
|
|
@@ -279,7 +276,7 @@ program
|
|
|
279
276
|
);
|
|
280
277
|
});
|
|
281
278
|
if (inAnilist) {
|
|
282
|
-
console.log(chalk.yellow(` "${resolvedTitle}" is already in your AniList list (${inAnilist.status})
|
|
279
|
+
console.log(chalk.yellow(` "${resolvedTitle}" is already in your AniList list (${inAnilist.status}) - no need to add manually.`));
|
|
283
280
|
return;
|
|
284
281
|
}
|
|
285
282
|
} catch (e) {}
|
|
@@ -290,7 +287,7 @@ program
|
|
|
290
287
|
updateConfig({ manualList });
|
|
291
288
|
db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: entry.language });
|
|
292
289
|
|
|
293
|
-
console.log(chalk.green(
|
|
290
|
+
console.log(chalk.green(` ✔ Added ${resolvedTitle} to manual list.`));
|
|
294
291
|
if (opts.quality) console.log(` Quality: ${opts.quality}`);
|
|
295
292
|
if (opts.language) console.log(` Language: ${opts.language}`);
|
|
296
293
|
console.log('');
|
|
@@ -305,7 +302,7 @@ program
|
|
|
305
302
|
const config = loadConfig();
|
|
306
303
|
const manualList = config.manualList || [];
|
|
307
304
|
|
|
308
|
-
if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty
|
|
305
|
+
if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.')); return; }
|
|
309
306
|
|
|
310
307
|
let target = title;
|
|
311
308
|
if (!target) {
|
|
@@ -317,14 +314,14 @@ program
|
|
|
317
314
|
}
|
|
318
315
|
|
|
319
316
|
const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
|
|
320
|
-
if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list
|
|
317
|
+
if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.`)); return; }
|
|
321
318
|
|
|
322
319
|
const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', message: `Remove "${target}" and all its episode history?`, default: false }]);
|
|
323
320
|
if (!confirm) return;
|
|
324
321
|
|
|
325
322
|
updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
|
|
326
323
|
db.removeAnime(target);
|
|
327
|
-
console.log(chalk.green(` ✔ Removed "${target}"
|
|
324
|
+
console.log(chalk.green(` ✔ Removed "${target}".`));
|
|
328
325
|
});
|
|
329
326
|
|
|
330
327
|
async function pickFromWatchList(message) {
|
|
@@ -332,9 +329,9 @@ async function pickFromWatchList(message) {
|
|
|
332
329
|
const config = loadConfig();
|
|
333
330
|
const watchList = await resolveWatchList(config);
|
|
334
331
|
|
|
335
|
-
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty
|
|
332
|
+
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.')); return null; }
|
|
336
333
|
|
|
337
|
-
console.log(chalk.bold('
|
|
334
|
+
console.log(chalk.bold(' Watch list:'));
|
|
338
335
|
watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
|
|
339
336
|
console.log('');
|
|
340
337
|
|
|
@@ -357,7 +354,7 @@ program
|
|
|
357
354
|
const item = await pickFromWatchList('Select anime to enable:');
|
|
358
355
|
if (!item) return;
|
|
359
356
|
db.setAnimeEnabled(item.title, true);
|
|
360
|
-
console.log(chalk.green(
|
|
357
|
+
console.log(chalk.green(` ✔ Enabled "${item.title}".`));
|
|
361
358
|
});
|
|
362
359
|
|
|
363
360
|
program
|
|
@@ -368,7 +365,7 @@ program
|
|
|
368
365
|
const item = await pickFromWatchList('Select anime to disable:');
|
|
369
366
|
if (!item) return;
|
|
370
367
|
db.setAnimeEnabled(item.title, false);
|
|
371
|
-
console.log(chalk.yellow(
|
|
368
|
+
console.log(chalk.yellow(` ✔ Disabled "${item.title}".`));
|
|
372
369
|
});
|
|
373
370
|
|
|
374
371
|
program
|
|
@@ -379,7 +376,7 @@ program
|
|
|
379
376
|
banner();
|
|
380
377
|
await db.initDb();
|
|
381
378
|
const runs = db.getRecentRuns(parseInt(opts.runs, 10));
|
|
382
|
-
if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start
|
|
379
|
+
if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run `ani-auto check` to start.')); return; }
|
|
383
380
|
|
|
384
381
|
console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
|
|
385
382
|
console.log(' ' + '─'.repeat(55));
|
|
@@ -407,7 +404,7 @@ program
|
|
|
407
404
|
const { getConfigPath } = require('./config');
|
|
408
405
|
const configPath = getConfigPath();
|
|
409
406
|
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
410
|
-
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}
|
|
407
|
+
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}`));
|
|
411
408
|
try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
|
|
412
409
|
catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
|
|
413
410
|
return;
|
|
@@ -423,11 +420,11 @@ program
|
|
|
423
420
|
|
|
424
421
|
if (Object.keys(updates).length) {
|
|
425
422
|
updateConfig(updates);
|
|
426
|
-
console.log(chalk.green(' ✔ Config updated
|
|
423
|
+
console.log(chalk.green(' ✔ Config updated:'));
|
|
427
424
|
for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
|
|
428
425
|
} else {
|
|
429
426
|
const config = loadConfig();
|
|
430
|
-
console.log(chalk.bold(' Current configuration
|
|
427
|
+
console.log(chalk.bold(' Current configuration:'));
|
|
431
428
|
for (const [k, v] of Object.entries(config)) {
|
|
432
429
|
if (k === 'anilistToken' && v) console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
|
|
433
430
|
else console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
|
|
@@ -436,6 +433,43 @@ program
|
|
|
436
433
|
console.log('');
|
|
437
434
|
});
|
|
438
435
|
|
|
436
|
+
// ── notify ───────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
program
|
|
439
|
+
.command('notify [action]')
|
|
440
|
+
.description('Enable or disable Telegram notifications (on, off, status)')
|
|
441
|
+
.action(async (action) => {
|
|
442
|
+
banner();
|
|
443
|
+
const config = loadConfig();
|
|
444
|
+
|
|
445
|
+
if (!action) {
|
|
446
|
+
const enabled = !!config.telegramChatId && config.telegramNotify !== false;
|
|
447
|
+
console.log(` Telegram notifications: ${enabled ? chalk.green('enabled') : chalk.gray('disabled')}`);
|
|
448
|
+
if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure. '));
|
|
449
|
+
else console.log('');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
switch (action.toLowerCase()) {
|
|
454
|
+
case 'on':
|
|
455
|
+
if (!config.telegramChatId) {
|
|
456
|
+
console.log(chalk.yellow(' No chat ID set - run ani-auto setup first. '));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
updateConfig({ telegramNotify: true });
|
|
460
|
+
console.log(chalk.green(' ✔ Telegram notifications enabled. '));
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case 'off':
|
|
464
|
+
updateConfig({ telegramNotify: false });
|
|
465
|
+
console.log(chalk.yellow(' ✔ Telegram notifications disabled. '));
|
|
466
|
+
break;
|
|
467
|
+
|
|
468
|
+
default:
|
|
469
|
+
console.log(chalk.yellow(` Unknown action "${action}". Use: on, off`));
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
439
473
|
// ── update ───────────────────────────────────────────────────────────────
|
|
440
474
|
|
|
441
475
|
program
|
|
@@ -454,12 +488,12 @@ program
|
|
|
454
488
|
spinner.stop();
|
|
455
489
|
|
|
456
490
|
if (latest === version) {
|
|
457
|
-
console.log(chalk.green(` ✔ Already on the latest version (v${version})
|
|
491
|
+
console.log(chalk.green(` ✔ Already on the latest version (v${version}).`));
|
|
458
492
|
return;
|
|
459
493
|
}
|
|
460
494
|
|
|
461
495
|
console.log(chalk.cyan(` Current version: ${chalk.bold(version)}`));
|
|
462
|
-
console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}
|
|
496
|
+
console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}`));
|
|
463
497
|
|
|
464
498
|
const { confirm } = await inquirer.prompt([{
|
|
465
499
|
type: 'confirm',
|
|
@@ -473,7 +507,7 @@ program
|
|
|
473
507
|
const installing = ora(`Installing v${latest}...`).start();
|
|
474
508
|
try {
|
|
475
509
|
execSync('npm install -g ani-auto@latest', { stdio: 'pipe' });
|
|
476
|
-
installing.succeed(`Updated to v${latest}
|
|
510
|
+
installing.succeed(`Updated to v${latest} - restart your terminal to use the new version.`);
|
|
477
511
|
} catch (e) {
|
|
478
512
|
installing.fail('Update failed. Try manually: npm install -g ani-auto@latest');
|
|
479
513
|
}
|
|
@@ -486,15 +520,107 @@ program
|
|
|
486
520
|
// ── daemon ────────────────────────────────────────────────────────────────
|
|
487
521
|
|
|
488
522
|
program
|
|
489
|
-
.command('daemon')
|
|
490
|
-
.description('
|
|
491
|
-
.
|
|
492
|
-
|
|
523
|
+
.command('daemon [action]')
|
|
524
|
+
.description('Manage the background daemon (start, stop, restart, status, install)')
|
|
525
|
+
.action(async (action) => {
|
|
526
|
+
const { execSync, spawnSync } = require('child_process');
|
|
527
|
+
|
|
528
|
+
const SERVICE = 'ani-auto';
|
|
529
|
+
const run = (cmd) => {
|
|
530
|
+
try {
|
|
531
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
532
|
+
return { out: r.stdout?.trim(), err: r.stderr?.trim(), code: r.status };
|
|
533
|
+
} catch (e) {
|
|
534
|
+
return { out: '', err: e.message, code: 1 };
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// No action - run inline (legacy behaviour)
|
|
539
|
+
if (!action) {
|
|
540
|
+
require('./daemon');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
switch (action.toLowerCase()) {
|
|
545
|
+
|
|
546
|
+
case 'install': {
|
|
547
|
+
banner();
|
|
548
|
+
const nodeBin = run('which node').out;
|
|
549
|
+
const daemonPath = run('npm root -g').out + '/ani-auto/src/daemon.js';
|
|
550
|
+
const serviceContent = [
|
|
551
|
+
'[Unit]',
|
|
552
|
+
'Description=ani-auto daemon',
|
|
553
|
+
'After=network-online.target',
|
|
554
|
+
'',
|
|
555
|
+
'[Service]',
|
|
556
|
+
`ExecStart=${nodeBin} ${daemonPath}`,
|
|
557
|
+
'Restart=on-failure',
|
|
558
|
+
'RestartSec=10',
|
|
559
|
+
'',
|
|
560
|
+
'[Install]',
|
|
561
|
+
'WantedBy=default.target',
|
|
562
|
+
].join('\n');
|
|
563
|
+
|
|
564
|
+
const fs = require('fs');
|
|
565
|
+
const os = require('os');
|
|
566
|
+
const path = require('path');
|
|
567
|
+
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
568
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
569
|
+
fs.writeFileSync(path.join(dir, `${SERVICE}.service`), serviceContent);
|
|
570
|
+
|
|
571
|
+
run('systemctl --user daemon-reload');
|
|
572
|
+
run(`systemctl --user enable ${SERVICE}`);
|
|
573
|
+
run(`systemctl --user start ${SERVICE}`);
|
|
574
|
+
console.log(chalk.green(` ✔ ani-auto daemon installed and started.`));
|
|
575
|
+
console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon status')} to check.
|
|
576
|
+
`));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case 'start': {
|
|
581
|
+
const r = run(`systemctl --user start ${SERVICE}`);
|
|
582
|
+
if (r.code === 0) console.log(chalk.green(' ✔ Daemon started. '));
|
|
583
|
+
else console.log(chalk.red(` ✖ Failed to start: ${r.err}
|
|
584
|
+
Try: ani-auto daemon install`));
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
case 'stop': {
|
|
589
|
+
const r = run(`systemctl --user stop ${SERVICE}`);
|
|
590
|
+
if (r.code === 0) console.log(chalk.yellow(' ✔ Daemon stopped. '));
|
|
591
|
+
else console.log(chalk.red(` ✖ Failed to stop: ${r.err}`));
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
case 'restart': {
|
|
596
|
+
const r = run(`systemctl --user restart ${SERVICE}`);
|
|
597
|
+
if (r.code === 0) console.log(chalk.green(' ✔ Daemon restarted. '));
|
|
598
|
+
else console.log(chalk.red(` ✖ Failed to restart: ${r.err}`));
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
case 'status': {
|
|
603
|
+
const r = run(`systemctl --user status ${SERVICE}`);
|
|
604
|
+
console.log(r.out || r.err);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
case 'logs': {
|
|
609
|
+
console.log(chalk.gray(' Streaming logs (Ctrl+C to stop)... '));
|
|
610
|
+
const { spawn } = require('child_process');
|
|
611
|
+
spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
default:
|
|
616
|
+
console.log(chalk.yellow(` Unknown action "${action}". Use: install, start, stop, restart, status, logs`));
|
|
617
|
+
}
|
|
618
|
+
});
|
|
493
619
|
|
|
494
620
|
program
|
|
495
621
|
.name('ani-auto')
|
|
496
622
|
.version(version)
|
|
497
|
-
.description('Automatic anime episode downloader
|
|
623
|
+
.description('Automatic anime episode downloader - AniList-aware')
|
|
498
624
|
.parse(process.argv);
|
|
499
625
|
|
|
500
626
|
if (!process.argv.slice(2).length) { banner(); program.help(); }
|
package/src/db.js
CHANGED
|
@@ -56,6 +56,7 @@ function migrate(db) {
|
|
|
56
56
|
language TEXT,
|
|
57
57
|
confirmed_site_id TEXT,
|
|
58
58
|
confirmed_site_title TEXT,
|
|
59
|
+
anilist_title TEXT,
|
|
59
60
|
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
61
|
)
|
|
61
62
|
`);
|
|
@@ -74,6 +75,7 @@ function migrate(db) {
|
|
|
74
75
|
// Add confirmed_site columns if upgrading from older DB
|
|
75
76
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
76
77
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
|
|
78
|
+
try { db.run('ALTER TABLE anime ADD COLUMN anilist_title TEXT'); } catch(e) {}
|
|
77
79
|
|
|
78
80
|
db.run(`
|
|
79
81
|
CREATE TABLE IF NOT EXISTS run_log (
|
|
@@ -168,6 +170,13 @@ function isEpisodeDownloaded(animeDbId, episodeNum) {
|
|
|
168
170
|
);
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
function getEpisodeRow(animeDbId, episodeNum) {
|
|
174
|
+
return _get(
|
|
175
|
+
'SELECT * FROM episodes WHERE anime_id = ? AND episode_num = ?',
|
|
176
|
+
[animeDbId, episodeNum]
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
171
180
|
function markEpisodeStatus(animeDbId, episodeNum, status, filename = null, error = null) {
|
|
172
181
|
_run(`
|
|
173
182
|
INSERT INTO episodes (anime_id, episode_num, status, filename, downloaded_at, error)
|
|
@@ -209,22 +218,27 @@ function getDbInstance() {
|
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
function getAnimeBySiteTitle(title) {
|
|
212
|
-
|
|
221
|
+
// Check by anilist_title first, then fall back to title column
|
|
222
|
+
return _get('SELECT * FROM anime WHERE LOWER(anilist_title) = LOWER(?)', [title])
|
|
223
|
+
|| _get('SELECT * FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
|
|
213
224
|
}
|
|
214
225
|
|
|
215
|
-
function saveConfirmedSiteMatch(
|
|
216
|
-
//
|
|
217
|
-
const existing =
|
|
226
|
+
function saveConfirmedSiteMatch(anilistTitle, confirmedSiteId, confirmedSiteTitle) {
|
|
227
|
+
// Look up by anilist_title, AniList title matching title col, or site title matching title col
|
|
228
|
+
const existing =
|
|
229
|
+
_get('SELECT id FROM anime WHERE LOWER(anilist_title) = LOWER(?)', [anilistTitle]) ||
|
|
230
|
+
_get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [anilistTitle]) ||
|
|
231
|
+
_get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [confirmedSiteTitle]);
|
|
232
|
+
|
|
218
233
|
if (existing) {
|
|
219
234
|
_run(
|
|
220
|
-
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title = ? WHERE id = ?',
|
|
221
|
-
[confirmedSiteId, confirmedSiteTitle, existing.id]
|
|
235
|
+
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title = ?, anilist_title = ? WHERE id = ?',
|
|
236
|
+
[confirmedSiteId, confirmedSiteTitle, anilistTitle, existing.id]
|
|
222
237
|
);
|
|
223
238
|
} else {
|
|
224
|
-
// No row yet — insert one using confirmedSiteId as the site_id
|
|
225
239
|
_run(
|
|
226
|
-
'INSERT OR IGNORE INTO anime (title, site_id, confirmed_site_id, confirmed_site_title, source) VALUES (?, ?, ?, ?, ?)',
|
|
227
|
-
[
|
|
240
|
+
'INSERT OR IGNORE INTO anime (title, anilist_title, site_id, confirmed_site_id, confirmed_site_title, source) VALUES (?, ?, ?, ?, ?, ?)',
|
|
241
|
+
[anilistTitle, anilistTitle, confirmedSiteId, confirmedSiteId, confirmedSiteTitle, 'anilist']
|
|
228
242
|
);
|
|
229
243
|
}
|
|
230
244
|
_save();
|
|
@@ -234,6 +248,6 @@ module.exports = {
|
|
|
234
248
|
initDb,
|
|
235
249
|
getDb: getDbInstance,
|
|
236
250
|
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, removeAnime,
|
|
237
|
-
isEpisodeDownloaded, markEpisodeStatus, getEpisodeStats,
|
|
251
|
+
isEpisodeDownloaded, getEpisodeRow, markEpisodeStatus, getEpisodeStats,
|
|
238
252
|
logRun, getRecentRuns,
|
|
239
253
|
};
|
package/src/engine.js
CHANGED
|
@@ -155,7 +155,16 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
155
155
|
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
156
156
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
157
157
|
} else if (auto) {
|
|
158
|
-
|
|
158
|
+
// Try to find best match — prefer exact title match, then season match, then first result
|
|
159
|
+
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
160
|
+
const normTitle = normalize(workItem.title);
|
|
161
|
+
const season = detectSeason(workItem.title);
|
|
162
|
+
|
|
163
|
+
const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
|
|
164
|
+
|| (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
|
|
165
|
+
|| siteResults[0];
|
|
166
|
+
|
|
167
|
+
siteAnime = bestMatch;
|
|
159
168
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
160
169
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
161
170
|
} else {
|
package/src/notify.js
CHANGED
|
@@ -13,6 +13,7 @@ async function sendTelegram(text) {
|
|
|
13
13
|
const config = loadConfig();
|
|
14
14
|
const chatId = config.telegramChatId;
|
|
15
15
|
if (!chatId) return;
|
|
16
|
+
if (config.telegramNotify === false) return;
|
|
16
17
|
try {
|
|
17
18
|
const fetch = require('node-fetch');
|
|
18
19
|
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|