ani-auto 1.0.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 +159 -0
- package/package.json +23 -0
- package/src/anilist.js +132 -0
- package/src/cli.js +517 -0
- package/src/config.js +79 -0
- package/src/daemon.js +75 -0
- package/src/db.js +232 -0
- package/src/engine.js +343 -0
- package/src/scraper.js +137 -0
- package/utils.js +118 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
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
|
+
const { program } = require('commander');
|
|
21
|
+
const inquirer = require('inquirer');
|
|
22
|
+
const chalk = require('chalk');
|
|
23
|
+
const ora = require('ora');
|
|
24
|
+
|
|
25
|
+
const { loadConfig, updateConfig, CONFIG_FILE } = require('./config');
|
|
26
|
+
const db = require('./db');
|
|
27
|
+
const { runCycle } = require('./engine');
|
|
28
|
+
const { getViewer } = require('./anilist');
|
|
29
|
+
const { version } = require('../package.json');
|
|
30
|
+
|
|
31
|
+
// ── Banner ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function banner() {
|
|
34
|
+
console.log(chalk.bold.red(`
|
|
35
|
+
█████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
|
|
36
|
+
██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
|
|
37
|
+
███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
|
|
38
|
+
██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
|
|
39
|
+
██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
|
|
40
|
+
╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
|
|
41
|
+
A N I M E A U T O D O W N L O A D E R
|
|
42
|
+
`));
|
|
43
|
+
}
|
|
44
|
+
// ── setup ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command('setup')
|
|
48
|
+
.description('Interactive first-time configuration wizard')
|
|
49
|
+
.action(async () => {
|
|
50
|
+
banner();
|
|
51
|
+
const current = loadConfig();
|
|
52
|
+
|
|
53
|
+
console.log(chalk.cyan(' Let\'s configure your auto-downloader.\n'));
|
|
54
|
+
|
|
55
|
+
// Step 1: get username
|
|
56
|
+
const { anilistUsername } = await inquirer.prompt([{
|
|
57
|
+
type: 'input',
|
|
58
|
+
name: 'anilistUsername',
|
|
59
|
+
message: 'AniList username (leave blank to skip AniList integration):',
|
|
60
|
+
default: current.anilistUsername || '',
|
|
61
|
+
}]);
|
|
62
|
+
|
|
63
|
+
// Step 2: if username given, open browser THEN ask for token
|
|
64
|
+
let anilistToken = current.anilistToken || '';
|
|
65
|
+
let watchStatuses = current.watchStatuses || ['CURRENT'];
|
|
66
|
+
|
|
67
|
+
if (anilistUsername) {
|
|
68
|
+
const { openBrowser } = await inquirer.prompt([{
|
|
69
|
+
type: 'confirm',
|
|
70
|
+
name: 'openBrowser',
|
|
71
|
+
message: 'Open browser to get AniList token?',
|
|
72
|
+
default: true,
|
|
73
|
+
}]);
|
|
74
|
+
|
|
75
|
+
if (openBrowser) {
|
|
76
|
+
const url = 'https://anilist.co/api/v2/oauth/authorize?client_id=37183&response_type=token';
|
|
77
|
+
const { spawn } = require('child_process');
|
|
78
|
+
const openers = process.platform === 'win32' ? ['start']
|
|
79
|
+
: process.platform === 'darwin' ? ['open']
|
|
80
|
+
: ['xdg-open', 'firefox'];
|
|
81
|
+
let opened = false;
|
|
82
|
+
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) {}
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.cyan(`\n ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
|
|
90
|
+
console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).\n'));
|
|
91
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tokenAnswer = await inquirer.prompt([{
|
|
95
|
+
type: 'password',
|
|
96
|
+
name: 'anilistToken',
|
|
97
|
+
message: 'Paste your AniList token here:',
|
|
98
|
+
default: current.anilistToken || '',
|
|
99
|
+
}]);
|
|
100
|
+
anilistToken = tokenAnswer.anilistToken;
|
|
101
|
+
|
|
102
|
+
const statusAnswer = await inquirer.prompt([{
|
|
103
|
+
type: 'list',
|
|
104
|
+
name: 'watchStatuses',
|
|
105
|
+
message: 'Which AniList status to download?',
|
|
106
|
+
choices: [
|
|
107
|
+
{ name: 'CURRENT (Watching)', value: 'CURRENT' },
|
|
108
|
+
{ name: 'PLANNING (Plan to Watch)', value: 'PLANNING' },
|
|
109
|
+
{ name: 'PAUSED', value: 'PAUSED' },
|
|
110
|
+
{ name: 'REPEATING', value: 'REPEATING' },
|
|
111
|
+
],
|
|
112
|
+
default: (current.watchStatuses || ['CURRENT'])[0],
|
|
113
|
+
filter: v => [v],
|
|
114
|
+
}]);
|
|
115
|
+
watchStatuses = statusAnswer.watchStatuses;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Step 3: rest of config
|
|
119
|
+
const answers = await inquirer.prompt([
|
|
120
|
+
{
|
|
121
|
+
type: 'list',
|
|
122
|
+
name: 'quality',
|
|
123
|
+
message: 'Default download quality:',
|
|
124
|
+
choices: ['1080p', '720p', '480p', '360p'],
|
|
125
|
+
default: current.quality || '1080p',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'list',
|
|
129
|
+
name: 'language',
|
|
130
|
+
message: 'Default audio language:',
|
|
131
|
+
choices: ['sub', 'dub'],
|
|
132
|
+
default: current.language || 'sub',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
type: 'number',
|
|
136
|
+
name: 'concurrency',
|
|
137
|
+
message: 'Parallel downloads per run (1-10):',
|
|
138
|
+
default: current.concurrency || 2,
|
|
139
|
+
validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 1–10',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'input',
|
|
143
|
+
name: 'outputDir',
|
|
144
|
+
message: 'Output directory for downloads:',
|
|
145
|
+
default: current.outputDir,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: 'number',
|
|
149
|
+
name: 'daemonIntervalMinutes',
|
|
150
|
+
message: 'Daemon polling interval (minutes):',
|
|
151
|
+
default: current.daemonIntervalMinutes || 30,
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
answers.anilistUsername = anilistUsername;
|
|
155
|
+
answers.anilistToken = anilistToken;
|
|
156
|
+
answers.watchStatuses = watchStatuses;
|
|
157
|
+
|
|
158
|
+
// Verify AniList token if provided
|
|
159
|
+
if (answers.anilistToken) {
|
|
160
|
+
const spinner = ora('Verifying AniList token...').start();
|
|
161
|
+
try {
|
|
162
|
+
const viewer = await getViewer(answers.anilistToken);
|
|
163
|
+
spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`);
|
|
164
|
+
} catch {
|
|
165
|
+
spinner.warn('Could not verify token — saved anyway, check it manually.');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
updateConfig({
|
|
170
|
+
anilistUsername: answers.anilistUsername || null,
|
|
171
|
+
anilistToken: answers.anilistToken || null,
|
|
172
|
+
watchStatuses: answers.watchStatuses || current.watchStatuses,
|
|
173
|
+
quality: answers.quality,
|
|
174
|
+
language: answers.language,
|
|
175
|
+
concurrency: answers.concurrency,
|
|
176
|
+
outputDir: answers.outputDir,
|
|
177
|
+
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log(chalk.green(`\n ✔ Config saved to ${CONFIG_FILE}\n`));
|
|
181
|
+
console.log(` Run ${chalk.bold('ani-auto check')} to do a manual download cycle.`);
|
|
182
|
+
console.log(` Run ${chalk.bold('ani-auto daemon')} to start the background daemon.\n`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── check ─────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
program
|
|
188
|
+
.command('check')
|
|
189
|
+
.description('Run one download cycle immediately')
|
|
190
|
+
.option('--auto', 'Skip prompts, auto-select all anime and best site match')
|
|
191
|
+
.action(async (opts) => {
|
|
192
|
+
banner();
|
|
193
|
+
try {
|
|
194
|
+
await runCycle({ auto: !!opts.auto });
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error(chalk.red('[ERROR]'), e.message);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
program
|
|
204
|
+
.command('list')
|
|
205
|
+
.description('Show all tracked anime and their episode download status')
|
|
206
|
+
.action(async () => {
|
|
207
|
+
banner();
|
|
208
|
+
await db.initDb();
|
|
209
|
+
const rawDb = db.getDb();
|
|
210
|
+
const stmt = rawDb.prepare('SELECT * FROM anime');
|
|
211
|
+
const all = [];
|
|
212
|
+
while (stmt.step()) all.push(stmt.getAsObject());
|
|
213
|
+
stmt.free();
|
|
214
|
+
|
|
215
|
+
if (!all.length) {
|
|
216
|
+
console.log(chalk.yellow(' No anime tracked yet.'));
|
|
217
|
+
console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.\n`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(chalk.bold(` ${'Title'.padEnd(40)} ${'Source'.padEnd(8)} ${'Enabled'.padEnd(8)} Done Failed`));
|
|
222
|
+
console.log(' ' + '─'.repeat(72));
|
|
223
|
+
|
|
224
|
+
for (const row of all) {
|
|
225
|
+
const stats = db.getEpisodeStats(row.id);
|
|
226
|
+
const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
|
|
227
|
+
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
|
+
);
|
|
232
|
+
}
|
|
233
|
+
console.log('');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── add ───────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
program
|
|
239
|
+
.command('add <title>')
|
|
240
|
+
.description('Search AniList and add an anime to the manual download list')
|
|
241
|
+
.option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
|
|
242
|
+
.option('-l, --language <lang>', 'Language override (sub/dub)', null)
|
|
243
|
+
.action(async (title, opts) => {
|
|
244
|
+
banner();
|
|
245
|
+
await db.initDb();
|
|
246
|
+
|
|
247
|
+
const { searchAnime, pickTitle } = require('./anilist');
|
|
248
|
+
const ora = require('ora');
|
|
249
|
+
|
|
250
|
+
const spinner = ora(`Searching AniList for "${title}"...`).start();
|
|
251
|
+
let results;
|
|
252
|
+
try {
|
|
253
|
+
results = await searchAnime(title);
|
|
254
|
+
spinner.stop();
|
|
255
|
+
} catch (e) {
|
|
256
|
+
spinner.fail(`AniList search failed: ${e.message}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!results.length) {
|
|
261
|
+
console.log(chalk.yellow(` No results found on AniList for ${title}.`));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Let user pick the right match
|
|
266
|
+
const { picked } = await inquirer.prompt([{
|
|
267
|
+
type: 'list',
|
|
268
|
+
name: 'picked',
|
|
269
|
+
message: 'Select the anime:',
|
|
270
|
+
choices: results.map(r => ({
|
|
271
|
+
name: `${pickTitle(r.titles)} (${r.year || '?'}) — ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
|
|
272
|
+
value: r,
|
|
273
|
+
})),
|
|
274
|
+
}]);
|
|
275
|
+
|
|
276
|
+
const resolvedTitle = pickTitle(picked.titles);
|
|
277
|
+
const config = loadConfig();
|
|
278
|
+
const manualList = config.manualList || [];
|
|
279
|
+
|
|
280
|
+
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
|
+
};
|
|
293
|
+
|
|
294
|
+
manualList.push(entry);
|
|
295
|
+
updateConfig({ manualList });
|
|
296
|
+
db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: entry.language });
|
|
297
|
+
|
|
298
|
+
console.log(chalk.green(`\n ✔ Added ${resolvedTitle} to manual list.`));
|
|
299
|
+
if (opts.quality) console.log(` Quality: ${opts.quality}`);
|
|
300
|
+
if (opts.language) console.log(` Language: ${opts.language}`);
|
|
301
|
+
console.log('');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── remove ────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
program
|
|
307
|
+
.command('remove [title]')
|
|
308
|
+
.description('Remove an anime from the manual list (interactive if no title given)')
|
|
309
|
+
.action(async (title) => {
|
|
310
|
+
banner();
|
|
311
|
+
await db.initDb();
|
|
312
|
+
const config = loadConfig();
|
|
313
|
+
const manualList = config.manualList || [];
|
|
314
|
+
|
|
315
|
+
if (!manualList.length) {
|
|
316
|
+
console.log(chalk.yellow(' Your manual list is empty.\n'));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let target = title;
|
|
321
|
+
|
|
322
|
+
// If no title given, show interactive picker
|
|
323
|
+
if (!target) {
|
|
324
|
+
const { picked } = await inquirer.prompt([{
|
|
325
|
+
type: 'list',
|
|
326
|
+
name: 'picked',
|
|
327
|
+
message: 'Select anime to remove:',
|
|
328
|
+
choices: manualList.map(m => ({
|
|
329
|
+
name: `${m.title}${m.enabled ? '' : chalk.gray(' (disabled)')}`,
|
|
330
|
+
value: m.title,
|
|
331
|
+
})),
|
|
332
|
+
}]);
|
|
333
|
+
target = picked;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
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
|
+
}
|
|
341
|
+
|
|
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
|
+
}]);
|
|
348
|
+
if (!confirm) return;
|
|
349
|
+
|
|
350
|
+
updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
|
|
351
|
+
db.removeAnime(target);
|
|
352
|
+
console.log(chalk.green(` ✔ Removed "${target}".\n`));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ── enable / disable ──────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
async function pickFromWatchList(message) {
|
|
358
|
+
const { resolveWatchList } = require('./engine');
|
|
359
|
+
const config = loadConfig();
|
|
360
|
+
const watchList = await resolveWatchList(config);
|
|
361
|
+
|
|
362
|
+
if (!watchList.length) {
|
|
363
|
+
console.log(chalk.yellow(' Watch list is empty.\n'));
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log(chalk.bold('\n Watch list:'));
|
|
368
|
+
watchList.forEach((item, i) => {
|
|
369
|
+
console.log(` ${chalk.cyan(i + 1)}. ${item.title}`);
|
|
370
|
+
});
|
|
371
|
+
console.log('');
|
|
372
|
+
|
|
373
|
+
const { input } = await inquirer.prompt([{
|
|
374
|
+
type: 'input',
|
|
375
|
+
name: 'input',
|
|
376
|
+
message,
|
|
377
|
+
validate: (val) => {
|
|
378
|
+
const n = parseInt(val.trim());
|
|
379
|
+
if (isNaN(n) || n < 1 || n > watchList.length)
|
|
380
|
+
return `Enter a number between 1 and ${watchList.length}`;
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
}]);
|
|
384
|
+
|
|
385
|
+
return watchList[parseInt(input.trim()) - 1];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
program
|
|
389
|
+
.command('enable')
|
|
390
|
+
.description('Re-enable downloads for an anime')
|
|
391
|
+
.action(async () => {
|
|
392
|
+
await db.initDb();
|
|
393
|
+
const item = await pickFromWatchList('Select anime to enable:');
|
|
394
|
+
if (!item) return;
|
|
395
|
+
db.setAnimeEnabled(item.title, true);
|
|
396
|
+
console.log(chalk.green(`\n ✔ Enabled "${item.title}".\n`));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
program
|
|
400
|
+
.command('disable')
|
|
401
|
+
.description('Pause downloads for an anime (keeps history)')
|
|
402
|
+
.action(async () => {
|
|
403
|
+
await db.initDb();
|
|
404
|
+
const item = await pickFromWatchList('Select anime to disable:');
|
|
405
|
+
if (!item) return;
|
|
406
|
+
db.setAnimeEnabled(item.title, false);
|
|
407
|
+
console.log(chalk.yellow(`\n ✔ Disabled "${item.title}".\n`));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ── status ────────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
program
|
|
413
|
+
.command('status')
|
|
414
|
+
.description('Show recent run log')
|
|
415
|
+
.option('-n, --runs <n>', 'Number of recent runs to show', '10')
|
|
416
|
+
.action(async (opts) => {
|
|
417
|
+
banner();
|
|
418
|
+
await db.initDb();
|
|
419
|
+
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
|
+
}
|
|
424
|
+
|
|
425
|
+
console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
|
|
426
|
+
console.log(' ' + '─'.repeat(55));
|
|
427
|
+
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
|
+
);
|
|
434
|
+
}
|
|
435
|
+
console.log('');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
program
|
|
441
|
+
.command('config')
|
|
442
|
+
.description('View, update, or open config file')
|
|
443
|
+
.option('--open', 'Open config file in default editor')
|
|
444
|
+
.option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
|
|
445
|
+
.option('--set-language <lang>', 'Set default language (sub/dub)')
|
|
446
|
+
.option('--set-concurrency <n>', 'Set parallel download count')
|
|
447
|
+
.option('--set-output <dir>', 'Set output directory')
|
|
448
|
+
.option('--set-interval <minutes>', 'Set daemon polling interval')
|
|
449
|
+
.option('--set-cron <schedule>', 'Set cron schedule (e.g. "0 */2 * * *")')
|
|
450
|
+
.action((opts) => {
|
|
451
|
+
banner();
|
|
452
|
+
|
|
453
|
+
if (opts.open) {
|
|
454
|
+
const { execSync } = require('child_process');
|
|
455
|
+
const { getConfigPath } = require('./config');
|
|
456
|
+
const configPath = getConfigPath();
|
|
457
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
458
|
+
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
|
|
459
|
+
try {
|
|
460
|
+
execSync(`${editor} "${configPath}"`, { stdio: 'inherit' });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`));
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const updates = {};
|
|
468
|
+
if (opts.setQuality) updates.quality = opts.setQuality;
|
|
469
|
+
if (opts.setLanguage) updates.language = opts.setLanguage;
|
|
470
|
+
if (opts.setConcurrency)updates.concurrency = parseInt(opts.setConcurrency, 10);
|
|
471
|
+
if (opts.setOutput) updates.outputDir = opts.setOutput;
|
|
472
|
+
if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
|
|
473
|
+
if (opts.setCron) updates.cronSchedule = opts.setCron;
|
|
474
|
+
|
|
475
|
+
if (Object.keys(updates).length) {
|
|
476
|
+
const config = updateConfig(updates);
|
|
477
|
+
console.log(chalk.green(' ✔ Config updated:\n'));
|
|
478
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
479
|
+
console.log(` ${chalk.bold(k)}: ${v}`);
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
// Print full config
|
|
483
|
+
const config = loadConfig();
|
|
484
|
+
console.log(chalk.bold(' Current configuration:\n'));
|
|
485
|
+
for (const [k, v] of Object.entries(config)) {
|
|
486
|
+
if (k === 'anilistToken' && v) {
|
|
487
|
+
console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
|
|
488
|
+
} else {
|
|
489
|
+
console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
console.log('');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── daemon ────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
program
|
|
499
|
+
.command('daemon')
|
|
500
|
+
.description('Start the background daemon')
|
|
501
|
+
.option('--auto', 'Skip prompts, auto-select all (default: true for daemon)')
|
|
502
|
+
.action(() => {
|
|
503
|
+
require('./daemon');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
program
|
|
509
|
+
.name('ani-auto')
|
|
510
|
+
.version(version)
|
|
511
|
+
.description('Automatic anime episode downloader — AniList-aware')
|
|
512
|
+
.parse(process.argv);
|
|
513
|
+
|
|
514
|
+
if (!process.argv.slice(2).length) {
|
|
515
|
+
banner();
|
|
516
|
+
program.help();
|
|
517
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
// AniList
|
|
12
|
+
anilistToken: null,
|
|
13
|
+
anilistUsername: null,
|
|
14
|
+
|
|
15
|
+
// Which AniList statuses to auto-download
|
|
16
|
+
watchStatuses: ['CURRENT'],
|
|
17
|
+
|
|
18
|
+
// Manual overrides: array of { title, quality, language, enabled }
|
|
19
|
+
manualList: [],
|
|
20
|
+
|
|
21
|
+
// Download settings
|
|
22
|
+
quality: '1080p',
|
|
23
|
+
language: 'sub',
|
|
24
|
+
concurrency: 2,
|
|
25
|
+
outputDir: path.join(os.homedir(), 'anime'),
|
|
26
|
+
maxRetries: 3,
|
|
27
|
+
|
|
28
|
+
// Scheduling
|
|
29
|
+
daemonIntervalMinutes: 30, // how often the daemon polls
|
|
30
|
+
cronSchedule: '0 */2 * * *', // every 2 hours (for cron mode)
|
|
31
|
+
|
|
32
|
+
// Behaviour
|
|
33
|
+
skipExisting: true, // skip episodes already in DB
|
|
34
|
+
maxEpisodesPerRun: 50, // safety cap per check cycle
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function ensureConfigDir() {
|
|
38
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
39
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadConfig() {
|
|
44
|
+
ensureConfigDir();
|
|
45
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
46
|
+
saveConfig(DEFAULTS);
|
|
47
|
+
return { ...DEFAULTS };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
51
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...DEFAULTS };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveConfig(config) {
|
|
58
|
+
ensureConfigDir();
|
|
59
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function updateConfig(partial) {
|
|
63
|
+
const current = loadConfig();
|
|
64
|
+
const updated = { ...current, ...partial };
|
|
65
|
+
saveConfig(updated);
|
|
66
|
+
return updated;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const getConfigPath = () => CONFIG_FILE;
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
CONFIG_FILE,
|
|
73
|
+
CONFIG_DIR,
|
|
74
|
+
DEFAULTS,
|
|
75
|
+
loadConfig,
|
|
76
|
+
saveConfig,
|
|
77
|
+
updateConfig,
|
|
78
|
+
getConfigPath,
|
|
79
|
+
};
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* daemon.js
|
|
5
|
+
* Keeps running indefinitely, polling on a configurable interval.
|
|
6
|
+
* Also registers a cron schedule as a secondary trigger.
|
|
7
|
+
*
|
|
8
|
+
* Usage: node src/daemon.js
|
|
9
|
+
* npm start
|
|
10
|
+
*/
|
|
11
|
+
const chalk = require('chalk');
|
|
12
|
+
const { CronJob } = require('cron');
|
|
13
|
+
const { loadConfig } = require('./config');
|
|
14
|
+
const { runCycle } = require('./engine');
|
|
15
|
+
|
|
16
|
+
let running = false; // prevents overlapping runs
|
|
17
|
+
|
|
18
|
+
async function safeRun() {
|
|
19
|
+
if (running) {
|
|
20
|
+
console.log(chalk.yellow(`[${ts()}] Previous run still in progress — skipping.`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
running = true;
|
|
24
|
+
try {
|
|
25
|
+
await runCycle({ auto: true });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(chalk.red(`[${ts()}] Unhandled error in run cycle:`), e.message);
|
|
28
|
+
} finally {
|
|
29
|
+
running = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ts() { return new Date().toISOString().replace('T',' ').slice(0,19); }
|
|
34
|
+
|
|
35
|
+
async function startDaemon() {
|
|
36
|
+
const config = loadConfig();
|
|
37
|
+
const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
|
|
38
|
+
const cronSchedule = config.cronSchedule || '0 */2 * * *';
|
|
39
|
+
|
|
40
|
+
console.log(chalk.bold.red(`
|
|
41
|
+
█████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
|
|
42
|
+
██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
|
|
43
|
+
███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
|
|
44
|
+
██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
|
|
45
|
+
██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
|
|
46
|
+
╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
|
|
47
|
+
A N I M E A U T O D O W N L O A D E R D A E M O N
|
|
48
|
+
`));
|
|
49
|
+
|
|
50
|
+
console.log(chalk.cyan(` Interval: every ${config.daemonIntervalMinutes || 30} minutes`));
|
|
51
|
+
console.log(chalk.cyan(` Cron: ${cronSchedule}`));
|
|
52
|
+
console.log(chalk.cyan(` Output: ${config.outputDir}`));
|
|
53
|
+
console.log(chalk.gray(` Started: ${ts()}\n`));
|
|
54
|
+
|
|
55
|
+
// Run immediately on start
|
|
56
|
+
await safeRun();
|
|
57
|
+
|
|
58
|
+
// Interval-based polling
|
|
59
|
+
setInterval(safeRun, intervalMs);
|
|
60
|
+
|
|
61
|
+
// Cron-based trigger (secondary — e.g. aligned to midnight/air times)
|
|
62
|
+
const job = new CronJob(cronSchedule, safeRun, null, true, 'UTC');
|
|
63
|
+
job.start();
|
|
64
|
+
|
|
65
|
+
console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
|
|
66
|
+
|
|
67
|
+
// Graceful shutdown
|
|
68
|
+
process.on('SIGINT', () => { console.log(chalk.yellow('\n Shutting down daemon...')); process.exit(0); });
|
|
69
|
+
process.on('SIGTERM', () => { console.log(chalk.yellow('\n Daemon terminated.')); process.exit(0); });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
startDaemon().catch(e => {
|
|
73
|
+
console.error(chalk.red('[FATAL]'), e.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|