ani-auto 1.3.0 → 1.3.1
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 +10 -33
- package/package.json +2 -3
- package/src/cli.js +331 -183
- package/src/config.js +5 -3
- package/src/daemon.js +90 -30
- package/src/db.js +39 -7
- package/src/engine.js +44 -23
- package/src/network.js +88 -0
- package/src/notify.js +70 -47
package/src/cli.js
CHANGED
|
@@ -8,10 +8,10 @@ const chalk = require('chalk');
|
|
|
8
8
|
const ora = require('ora');
|
|
9
9
|
|
|
10
10
|
const { loadConfig, updateConfig, CONFIG_FILE } = require('./config');
|
|
11
|
-
const db
|
|
11
|
+
const db = require('./db');
|
|
12
12
|
const { runCycle } = require('./engine');
|
|
13
13
|
const { getViewer } = require('./anilist');
|
|
14
|
-
const { version }
|
|
14
|
+
const { version } = require('../package.json');
|
|
15
15
|
|
|
16
16
|
function banner() {
|
|
17
17
|
console.log(chalk.bold.red(`
|
|
@@ -25,20 +25,23 @@ function banner() {
|
|
|
25
25
|
`));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── setup ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
28
30
|
program
|
|
29
31
|
.command('setup')
|
|
30
32
|
.description('Interactive first-time configuration wizard')
|
|
31
33
|
.action(async () => {
|
|
32
34
|
banner();
|
|
33
35
|
const current = loadConfig();
|
|
34
|
-
console.log(chalk.cyan(' Let\'s configure your auto-downloader
|
|
36
|
+
console.log(chalk.cyan(' Let\'s configure your auto-downloader.\n'));
|
|
37
|
+
|
|
35
38
|
const { anilistUsername } = await inquirer.prompt([{
|
|
36
39
|
type: 'input', name: 'anilistUsername',
|
|
37
40
|
message: 'AniList username (leave blank to skip AniList integration):',
|
|
38
41
|
default: current.anilistUsername || '',
|
|
39
42
|
}]);
|
|
40
43
|
|
|
41
|
-
let anilistToken
|
|
44
|
+
let anilistToken = current.anilistToken || '';
|
|
42
45
|
let watchStatuses = current.watchStatuses || ['CURRENT'];
|
|
43
46
|
|
|
44
47
|
if (anilistUsername) {
|
|
@@ -57,8 +60,8 @@ program
|
|
|
57
60
|
for (const opener of openers) {
|
|
58
61
|
try { spawn(opener, [url], { stdio: 'ignore', detached: true, env: process.env }).unref(); opened = true; break; } catch (e) {}
|
|
59
62
|
}
|
|
60
|
-
console.log(chalk.cyan(
|
|
61
|
-
console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=)
|
|
63
|
+
console.log(chalk.cyan(`\n ${opened ? 'Browser opened!' : 'Visit:'} ${url}`));
|
|
64
|
+
console.log(chalk.gray(' Authorize, then copy the token from the URL (the part after access_token=).\n'));
|
|
62
65
|
await new Promise(r => setTimeout(r, 1500));
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -86,10 +89,10 @@ program
|
|
|
86
89
|
|
|
87
90
|
const answers = await inquirer.prompt([
|
|
88
91
|
{ type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '360p'], default: current.quality || '1080p' },
|
|
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
|
|
92
|
+
{ 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
93
|
{ type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
|
|
91
|
-
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon
|
|
92
|
-
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip)
|
|
94
|
+
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon fallback interval (minutes):', default: current.daemonIntervalMinutes || 120 },
|
|
95
|
+
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip):\n -> Message @userinfobot on Telegram to get your ID:', default: current.telegramChatId || '' },
|
|
93
96
|
]);
|
|
94
97
|
answers.anilistUsername = anilistUsername;
|
|
95
98
|
answers.anilistToken = anilistToken;
|
|
@@ -102,101 +105,102 @@ program
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
updateConfig({
|
|
105
|
-
anilistUsername:
|
|
106
|
-
anilistToken:
|
|
107
|
-
watchStatuses:
|
|
108
|
-
quality:
|
|
109
|
-
concurrency:
|
|
110
|
-
outputDir:
|
|
108
|
+
anilistUsername: answers.anilistUsername || null,
|
|
109
|
+
anilistToken: answers.anilistToken || null,
|
|
110
|
+
watchStatuses: answers.watchStatuses || current.watchStatuses,
|
|
111
|
+
quality: answers.quality,
|
|
112
|
+
concurrency: answers.concurrency,
|
|
113
|
+
outputDir: answers.outputDir,
|
|
111
114
|
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
112
|
-
telegramChatId:
|
|
115
|
+
telegramChatId: answers.telegramChatId || null,
|
|
113
116
|
});
|
|
114
117
|
|
|
115
|
-
console.log(chalk.green(
|
|
116
|
-
console.log(` Run ${chalk.bold('ani-auto
|
|
117
|
-
console.log(` Run ${chalk.bold('ani-auto daemon')} to
|
|
118
|
+
console.log(chalk.green(`\n Done Config saved to ${CONFIG_FILE}\n`));
|
|
119
|
+
console.log(` Run ${chalk.bold('ani-auto download')} to start downloading.`);
|
|
120
|
+
console.log(` Run ${chalk.bold('ani-auto daemon install')} to set up the background service.\n`);
|
|
118
121
|
});
|
|
119
122
|
|
|
123
|
+
// ── download ──────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
120
125
|
program
|
|
121
|
-
.command('
|
|
126
|
+
.command('download')
|
|
122
127
|
.description('Run one download cycle immediately')
|
|
123
|
-
.
|
|
124
|
-
.action(async (opts) => {
|
|
128
|
+
.action(async () => {
|
|
125
129
|
banner();
|
|
126
130
|
try {
|
|
127
|
-
const config
|
|
128
|
-
let quality
|
|
129
|
-
let language
|
|
130
|
-
let range
|
|
131
|
-
let runStatuses
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
{
|
|
143
|
-
type: 'confirm',
|
|
144
|
-
name: 'saveAsDefault',
|
|
145
|
-
message: 'Save as default quality?',
|
|
146
|
-
default: false,
|
|
147
|
-
},
|
|
148
|
-
]);
|
|
149
|
-
quality = pickedQuality;
|
|
150
|
-
if (saveAsDefault) {
|
|
151
|
-
updateConfig({ quality });
|
|
152
|
-
console.log(chalk.green(` ✔ Saved default quality: ${quality}`));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const { pickedStatuses } = await inquirer.prompt([{
|
|
156
|
-
type: 'checkbox',
|
|
157
|
-
name: 'pickedStatuses',
|
|
158
|
-
message: 'AniList statuses to fetch for this run:',
|
|
159
|
-
choices: [
|
|
160
|
-
{ name: 'CURRENT (Watching)', value: 'CURRENT', checked: (config.watchStatuses || ['CURRENT']).includes('CURRENT') },
|
|
161
|
-
{ name: 'PLANNING (Plan to Watch)', value: 'PLANNING', checked: (config.watchStatuses || []).includes('PLANNING') },
|
|
162
|
-
{ name: 'PAUSED', value: 'PAUSED', checked: (config.watchStatuses || []).includes('PAUSED') },
|
|
163
|
-
{ name: 'REPEATING', value: 'REPEATING', checked: (config.watchStatuses || []).includes('REPEATING') },
|
|
164
|
-
],
|
|
165
|
-
validate: v => v.length > 0 ? true : 'Select at least one status',
|
|
166
|
-
}]);
|
|
167
|
-
runStatuses = pickedStatuses;
|
|
168
|
-
|
|
169
|
-
const { useRange } = await inquirer.prompt([{
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
let quality = config.quality || '1080p';
|
|
133
|
+
let language = config.language || 'sub';
|
|
134
|
+
let range = null;
|
|
135
|
+
let runStatuses = config.watchStatuses || ['CURRENT'];
|
|
136
|
+
|
|
137
|
+
const { pickedQuality, saveAsDefault } = await inquirer.prompt([
|
|
138
|
+
{
|
|
139
|
+
type: 'list',
|
|
140
|
+
name: 'pickedQuality',
|
|
141
|
+
message: 'Quality for this run:',
|
|
142
|
+
choices: ['1080p', '720p', '360p'],
|
|
143
|
+
default: quality,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
170
146
|
type: 'confirm',
|
|
171
|
-
name: '
|
|
172
|
-
message: '
|
|
147
|
+
name: 'saveAsDefault',
|
|
148
|
+
message: 'Save as default quality?',
|
|
173
149
|
default: false,
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
quality = pickedQuality;
|
|
153
|
+
if (saveAsDefault) {
|
|
154
|
+
updateConfig({ quality });
|
|
155
|
+
console.log(chalk.green(` Done Saved default quality: ${quality}\n`));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { pickedStatuses } = await inquirer.prompt([{
|
|
159
|
+
type: 'checkbox',
|
|
160
|
+
name: 'pickedStatuses',
|
|
161
|
+
message: 'AniList statuses to fetch for this run:',
|
|
162
|
+
choices: [
|
|
163
|
+
{ name: 'CURRENT (Watching)', value: 'CURRENT', checked: (config.watchStatuses || ['CURRENT']).includes('CURRENT') },
|
|
164
|
+
{ name: 'PLANNING (Plan to Watch)', value: 'PLANNING', checked: (config.watchStatuses || []).includes('PLANNING') },
|
|
165
|
+
{ name: 'PAUSED', value: 'PAUSED', checked: (config.watchStatuses || []).includes('PAUSED') },
|
|
166
|
+
{ name: 'REPEATING', value: 'REPEATING', checked: (config.watchStatuses || []).includes('REPEATING') },
|
|
167
|
+
],
|
|
168
|
+
validate: v => v.length > 0 ? true : 'Select at least one status',
|
|
169
|
+
}]);
|
|
170
|
+
runStatuses = pickedStatuses;
|
|
171
|
+
|
|
172
|
+
const { useRange } = await inquirer.prompt([{
|
|
173
|
+
type: 'confirm',
|
|
174
|
+
name: 'useRange',
|
|
175
|
+
message: 'Download a specific episode range?',
|
|
176
|
+
default: false,
|
|
177
|
+
}]);
|
|
178
|
+
if (useRange) {
|
|
179
|
+
const { rangeInput } = await inquirer.prompt([{
|
|
180
|
+
type: 'input',
|
|
181
|
+
name: 'rangeInput',
|
|
182
|
+
message: 'Enter range (e.g. 1-24 or 5 for single episode):',
|
|
183
|
+
validate: (v) => {
|
|
184
|
+
const clean = v.trim();
|
|
185
|
+
if (/^\d+$/.test(clean)) return true;
|
|
186
|
+
if (/^\d+-\d+$/.test(clean)) {
|
|
187
|
+
const [a, b] = clean.split('-').map(Number);
|
|
188
|
+
if (a > b) return 'Start must be less than or equal to end';
|
|
189
|
+
return true;
|
|
189
190
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
191
|
+
return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
|
|
192
|
+
}
|
|
193
|
+
}]);
|
|
194
|
+
range = rangeInput.trim();
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
await runCycle({ auto:
|
|
197
|
+
await runCycle({ auto: false, quality, language, range, statuses: runStatuses });
|
|
196
198
|
}
|
|
197
199
|
catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
|
|
198
200
|
});
|
|
199
201
|
|
|
202
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
200
204
|
program
|
|
201
205
|
.command('list')
|
|
202
206
|
.description('Show all tracked anime and their episode download status')
|
|
@@ -204,14 +208,14 @@ program
|
|
|
204
208
|
banner();
|
|
205
209
|
await db.initDb();
|
|
206
210
|
const rawDb = db.getDb();
|
|
207
|
-
const stmt
|
|
208
|
-
const all
|
|
211
|
+
const stmt = rawDb.prepare('SELECT * FROM anime');
|
|
212
|
+
const all = [];
|
|
209
213
|
while (stmt.step()) all.push(stmt.getAsObject());
|
|
210
214
|
stmt.free();
|
|
211
215
|
|
|
212
216
|
if (!all.length) {
|
|
213
217
|
console.log(chalk.yellow(' No anime tracked yet.'));
|
|
214
|
-
console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually
|
|
218
|
+
console.log(` Use ${chalk.bold('ani-auto add "<title>"')} to add one manually.\n`);
|
|
215
219
|
return;
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -220,17 +224,18 @@ program
|
|
|
220
224
|
for (const row of all) {
|
|
221
225
|
const stats = db.getEpisodeStats(row.id);
|
|
222
226
|
const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
|
|
223
|
-
const title = row.title.length > 38 ? row.title.slice(0, 37) + '
|
|
227
|
+
const title = row.title.length > 38 ? row.title.slice(0, 37) + '...' : row.title;
|
|
224
228
|
console.log(` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`);
|
|
225
229
|
}
|
|
226
230
|
console.log('');
|
|
227
231
|
});
|
|
228
232
|
|
|
233
|
+
// ── add ───────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
229
235
|
program
|
|
230
236
|
.command('add <title>')
|
|
231
237
|
.description('Search AniList and add an anime to the manual download list')
|
|
232
238
|
.option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
|
|
233
|
-
.option('-l, --language <lang>', 'Language override (sub/dub)', null)
|
|
234
239
|
.action(async (title, opts) => {
|
|
235
240
|
banner();
|
|
236
241
|
await db.initDb();
|
|
@@ -241,7 +246,7 @@ program
|
|
|
241
246
|
try { results = await searchAnime(title); spinner.stop(); }
|
|
242
247
|
catch (e) { spinner.fail(`AniList search failed: ${e.message}`); return; }
|
|
243
248
|
|
|
244
|
-
if (!results.length) { console.log(chalk.yellow(` No results found on AniList for ${title}
|
|
249
|
+
if (!results.length) { console.log(chalk.yellow(` No results found on AniList for "${title}".\n`)); return; }
|
|
245
250
|
|
|
246
251
|
const { picked } = await inquirer.prompt([{
|
|
247
252
|
type: 'list', name: 'picked', message: 'Select the anime:',
|
|
@@ -252,11 +257,19 @@ program
|
|
|
252
257
|
}]);
|
|
253
258
|
|
|
254
259
|
const resolvedTitle = pickTitle(picked.titles);
|
|
255
|
-
const config
|
|
256
|
-
const manualList
|
|
260
|
+
const config = loadConfig();
|
|
261
|
+
const manualList = config.manualList || [];
|
|
257
262
|
|
|
258
263
|
const existsManual = manualList.find(m => m.title.toLowerCase() === resolvedTitle.toLowerCase());
|
|
259
|
-
if (existsManual) {
|
|
264
|
+
if (existsManual) {
|
|
265
|
+
if (opts.quality) {
|
|
266
|
+
db.setAnimeQuality(resolvedTitle, opts.quality);
|
|
267
|
+
console.log(chalk.green(`\n Done Quality for "${resolvedTitle}" set to ${opts.quality}.\n`));
|
|
268
|
+
} else {
|
|
269
|
+
console.log(chalk.yellow(` "${resolvedTitle}" is already in your manual list.\n`));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
260
273
|
|
|
261
274
|
// Check if already in AniList watch list (fuzzy match)
|
|
262
275
|
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
@@ -272,37 +285,43 @@ program
|
|
|
272
285
|
const inAnilist = anilistEntries.find(e => {
|
|
273
286
|
const t = e.titles;
|
|
274
287
|
return [t.english, t.romaji, t.native].some(
|
|
275
|
-
|
|
288
|
+
t2 => t2 && normalize(t2) === normResolved
|
|
276
289
|
);
|
|
277
290
|
});
|
|
278
291
|
if (inAnilist) {
|
|
279
|
-
|
|
292
|
+
if (opts.quality) {
|
|
293
|
+
db.setAnimeQuality(resolvedTitle, opts.quality);
|
|
294
|
+
console.log(chalk.green(`\n Done Quality for "${resolvedTitle}" set to ${opts.quality}.\n`));
|
|
295
|
+
} else {
|
|
296
|
+
console.log(chalk.yellow(` "${resolvedTitle}" is already in your AniList list (${inAnilist.status}) - no need to add manually.\n`));
|
|
297
|
+
}
|
|
280
298
|
return;
|
|
281
299
|
}
|
|
282
300
|
} catch (e) {}
|
|
283
301
|
}
|
|
284
302
|
|
|
285
|
-
const entry = { title: resolvedTitle, anilistId: picked.anilistId, enabled: true, quality: opts.quality || null, language:
|
|
303
|
+
const entry = { title: resolvedTitle, anilistId: picked.anilistId, enabled: true, quality: opts.quality || null, language: null };
|
|
286
304
|
manualList.push(entry);
|
|
287
305
|
updateConfig({ manualList });
|
|
288
|
-
db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language:
|
|
306
|
+
db.upsertAnime({ title: resolvedTitle, siteId: resolvedTitle, anilistId: picked.anilistId, source: 'manual', quality: entry.quality, language: null });
|
|
289
307
|
|
|
290
|
-
console.log(chalk.green(
|
|
291
|
-
if (opts.quality)
|
|
292
|
-
if (opts.language) console.log(` Language: ${opts.language}`);
|
|
308
|
+
console.log(chalk.green(`\n Done Added "${resolvedTitle}" to manual list.`));
|
|
309
|
+
if (opts.quality) console.log(` Quality: ${opts.quality}`);
|
|
293
310
|
console.log('');
|
|
294
311
|
});
|
|
295
312
|
|
|
313
|
+
// ── remove ────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
296
315
|
program
|
|
297
316
|
.command('remove [title]')
|
|
298
317
|
.description('Remove an anime from the manual list (interactive if no title given)')
|
|
299
318
|
.action(async (title) => {
|
|
300
319
|
banner();
|
|
301
320
|
await db.initDb();
|
|
302
|
-
const config
|
|
321
|
+
const config = loadConfig();
|
|
303
322
|
const manualList = config.manualList || [];
|
|
304
323
|
|
|
305
|
-
if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty
|
|
324
|
+
if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.\n')); return; }
|
|
306
325
|
|
|
307
326
|
let target = title;
|
|
308
327
|
if (!target) {
|
|
@@ -314,24 +333,29 @@ program
|
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
|
|
317
|
-
if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list
|
|
336
|
+
if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.\n`)); return; }
|
|
318
337
|
|
|
319
|
-
const { confirm } = await inquirer.prompt([{
|
|
338
|
+
const { confirm } = await inquirer.prompt([{
|
|
339
|
+
type: 'confirm', name: 'confirm',
|
|
340
|
+
message: `Remove "${target}" and all its episode history?`, default: false,
|
|
341
|
+
}]);
|
|
320
342
|
if (!confirm) return;
|
|
321
343
|
|
|
322
344
|
updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
|
|
323
345
|
db.removeAnime(target);
|
|
324
|
-
console.log(chalk.green(
|
|
346
|
+
console.log(chalk.green(`\n Done Removed "${target}".\n`));
|
|
325
347
|
});
|
|
326
348
|
|
|
349
|
+
// ── enable / disable ──────────────────────────────────────────────────────
|
|
350
|
+
|
|
327
351
|
async function pickFromWatchList(message) {
|
|
328
352
|
const { resolveWatchList } = require('./engine');
|
|
329
|
-
const config
|
|
353
|
+
const config = loadConfig();
|
|
330
354
|
const watchList = await resolveWatchList(config);
|
|
331
355
|
|
|
332
|
-
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty
|
|
356
|
+
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return null; }
|
|
333
357
|
|
|
334
|
-
console.log(chalk.bold(' Watch list:'));
|
|
358
|
+
console.log(chalk.bold('\n Watch list:'));
|
|
335
359
|
watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
|
|
336
360
|
console.log('');
|
|
337
361
|
|
|
@@ -354,7 +378,7 @@ program
|
|
|
354
378
|
const item = await pickFromWatchList('Select anime to enable:');
|
|
355
379
|
if (!item) return;
|
|
356
380
|
db.setAnimeEnabled(item.title, true);
|
|
357
|
-
console.log(chalk.green(
|
|
381
|
+
console.log(chalk.green(`\n Done Enabled "${item.title}".\n`));
|
|
358
382
|
});
|
|
359
383
|
|
|
360
384
|
program
|
|
@@ -365,18 +389,51 @@ program
|
|
|
365
389
|
const item = await pickFromWatchList('Select anime to disable:');
|
|
366
390
|
if (!item) return;
|
|
367
391
|
db.setAnimeEnabled(item.title, false);
|
|
368
|
-
console.log(chalk.yellow(
|
|
392
|
+
console.log(chalk.yellow(`\n Done Disabled "${item.title}".\n`));
|
|
369
393
|
});
|
|
370
394
|
|
|
395
|
+
// ── status ────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
371
397
|
program
|
|
372
398
|
.command('status')
|
|
373
|
-
.description('Show recent run log')
|
|
374
|
-
.option('-n, --runs <n>',
|
|
399
|
+
.description('Show recent run log or per-anime episode breakdown')
|
|
400
|
+
.option('-n, --runs <n>', 'Number of recent runs to show', '10')
|
|
401
|
+
.option('--anime <title>', 'Show episode breakdown for a specific anime')
|
|
375
402
|
.action(async (opts) => {
|
|
376
403
|
banner();
|
|
377
404
|
await db.initDb();
|
|
405
|
+
|
|
406
|
+
if (opts.anime) {
|
|
407
|
+
const row = db.getAnimeBySiteTitle(opts.anime);
|
|
408
|
+
if (!row) {
|
|
409
|
+
console.log(chalk.yellow(` "${opts.anime}" not found. Run ani-auto list to see tracked anime.\n`));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const episodes = db.getAllEpisodes(row.id);
|
|
413
|
+
if (!episodes.length) {
|
|
414
|
+
console.log(chalk.yellow(` No episodes tracked for "${opts.anime}" yet.\n`));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const title = row.anilist_title || row.title;
|
|
418
|
+
console.log(chalk.bold(`\n ${title}\n`));
|
|
419
|
+
console.log(chalk.bold(` ${'EP'.padEnd(6)} ${'Status'.padEnd(14)} ${'Filename'.padEnd(45)} Downloaded`));
|
|
420
|
+
console.log(' ' + '─'.repeat(85));
|
|
421
|
+
for (const ep of episodes) {
|
|
422
|
+
const status = ep.status === 'done'
|
|
423
|
+
? chalk.green('done')
|
|
424
|
+
: ep.status === 'failed'
|
|
425
|
+
? chalk.red('failed')
|
|
426
|
+
: chalk.gray(ep.status);
|
|
427
|
+
const filename = ep.filename ? ep.filename.slice(0, 43) : '-';
|
|
428
|
+
const date = ep.downloaded_at ? ep.downloaded_at.slice(0, 10) : '-';
|
|
429
|
+
console.log(` ${String(ep.episode_num).padEnd(6)} ${status.padEnd(22)} ${filename.padEnd(45)} ${date}`);
|
|
430
|
+
}
|
|
431
|
+
console.log('');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
378
435
|
const runs = db.getRecentRuns(parseInt(opts.runs, 10));
|
|
379
|
-
if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run
|
|
436
|
+
if (!runs.length) { console.log(chalk.yellow(' No runs recorded yet. Run ani-auto download to start.\n')); return; }
|
|
380
437
|
|
|
381
438
|
console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
|
|
382
439
|
console.log(' ' + '─'.repeat(55));
|
|
@@ -386,45 +443,45 @@ program
|
|
|
386
443
|
console.log('');
|
|
387
444
|
});
|
|
388
445
|
|
|
446
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
389
448
|
program
|
|
390
449
|
.command('config')
|
|
391
450
|
.description('View, update, or open config file')
|
|
392
|
-
.option('--open',
|
|
393
|
-
.option('--set-quality <quality>',
|
|
394
|
-
.option('--set-
|
|
395
|
-
.option('--set-
|
|
396
|
-
.option('--set-
|
|
397
|
-
.option('--set-
|
|
398
|
-
.option('--set-cron <schedule>', 'Set cron schedule (e.g. "0 */2 * * *")')
|
|
451
|
+
.option('--open', 'Open config file in default editor')
|
|
452
|
+
.option('--set-quality <quality>', 'Set default quality (e.g. 1080p)')
|
|
453
|
+
.option('--set-concurrency <n>', 'Set parallel download count')
|
|
454
|
+
.option('--set-output <dir>', 'Set output directory')
|
|
455
|
+
.option('--set-interval <minutes>', 'Set daemon fallback interval in minutes')
|
|
456
|
+
.option('--set-airing-buffer <min>', 'Set airing buffer in minutes (default: 5)')
|
|
399
457
|
.action((opts) => {
|
|
400
458
|
banner();
|
|
401
459
|
|
|
402
460
|
if (opts.open) {
|
|
403
|
-
const { execSync }
|
|
461
|
+
const { execSync } = require('child_process');
|
|
404
462
|
const { getConfigPath } = require('./config');
|
|
405
|
-
const configPath
|
|
406
|
-
const editor
|
|
407
|
-
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}`));
|
|
463
|
+
const configPath = getConfigPath();
|
|
464
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
465
|
+
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
|
|
408
466
|
try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
|
|
409
467
|
catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
|
|
410
468
|
return;
|
|
411
469
|
}
|
|
412
470
|
|
|
413
471
|
const updates = {};
|
|
414
|
-
if (opts.setQuality)
|
|
415
|
-
if (opts.
|
|
416
|
-
if (opts.
|
|
417
|
-
if (opts.
|
|
418
|
-
if (opts.
|
|
419
|
-
if (opts.setCron) updates.cronSchedule = opts.setCron;
|
|
472
|
+
if (opts.setQuality) updates.quality = opts.setQuality;
|
|
473
|
+
if (opts.setConcurrency) updates.concurrency = parseInt(opts.setConcurrency, 10);
|
|
474
|
+
if (opts.setOutput) updates.outputDir = opts.setOutput;
|
|
475
|
+
if (opts.setInterval) updates.daemonIntervalMinutes = parseInt(opts.setInterval, 10);
|
|
476
|
+
if (opts.setAiringBuffer) updates.airingBufferMinutes = parseInt(opts.setAiringBuffer, 10);
|
|
420
477
|
|
|
421
478
|
if (Object.keys(updates).length) {
|
|
422
479
|
updateConfig(updates);
|
|
423
|
-
console.log(chalk.green('
|
|
480
|
+
console.log(chalk.green(' Done Config updated:\n'));
|
|
424
481
|
for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
|
|
425
482
|
} else {
|
|
426
483
|
const config = loadConfig();
|
|
427
|
-
console.log(chalk.bold(' Current configuration
|
|
484
|
+
console.log(chalk.bold(' Current configuration:\n'));
|
|
428
485
|
for (const [k, v] of Object.entries(config)) {
|
|
429
486
|
if (k === 'anilistToken' && v) console.log(` ${chalk.cyan(k.padEnd(26))} ${chalk.gray('[set]')}`);
|
|
430
487
|
else console.log(` ${chalk.cyan(k.padEnd(26))} ${JSON.stringify(v)}`);
|
|
@@ -433,11 +490,119 @@ program
|
|
|
433
490
|
console.log('');
|
|
434
491
|
});
|
|
435
492
|
|
|
436
|
-
// ──
|
|
493
|
+
// ── retry ─────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
program
|
|
496
|
+
.command('retry')
|
|
497
|
+
.description('Re-attempt all failed episode downloads')
|
|
498
|
+
.action(async () => {
|
|
499
|
+
banner();
|
|
500
|
+
await db.initDb();
|
|
501
|
+
const failed = db.getFailedEpisodes();
|
|
502
|
+
|
|
503
|
+
if (!failed.length) {
|
|
504
|
+
console.log(chalk.green(' No failed episodes found.\n'));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const byAnime = {};
|
|
509
|
+
for (const ep of failed) {
|
|
510
|
+
const key = ep.anilist_title || ep.title;
|
|
511
|
+
if (!byAnime[key]) byAnime[key] = { episodes: [] };
|
|
512
|
+
byAnime[key].episodes.push(ep);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
console.log(chalk.bold('\n Failed episodes:'));
|
|
516
|
+
Object.entries(byAnime).forEach(([title, { episodes }], i) => {
|
|
517
|
+
console.log(` ${chalk.cyan(i + 1)}. ${title} - ${chalk.red(episodes.length + ' failed')}`);
|
|
518
|
+
});
|
|
519
|
+
console.log(` ${chalk.cyan('a')}. Retry all\n`);
|
|
520
|
+
|
|
521
|
+
const { input } = await inquirer.prompt([{
|
|
522
|
+
type: 'input', name: 'input',
|
|
523
|
+
message: 'Select anime to retry (e.g. 1,2 or "a" for all):',
|
|
524
|
+
default: 'a',
|
|
525
|
+
validate: v => {
|
|
526
|
+
if (v.trim().toLowerCase() === 'a') return true;
|
|
527
|
+
const nums = v.split(',').map(s => parseInt(s.trim()));
|
|
528
|
+
if (nums.some(isNaN)) return 'Enter numbers or "a"';
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
}]);
|
|
532
|
+
|
|
533
|
+
const keys = Object.keys(byAnime);
|
|
534
|
+
const selected = input.trim().toLowerCase() === 'a'
|
|
535
|
+
? keys
|
|
536
|
+
: input.split(',').map(s => keys[parseInt(s.trim()) - 1]).filter(Boolean);
|
|
537
|
+
|
|
538
|
+
for (const key of selected) {
|
|
539
|
+
const { episodes } = byAnime[key];
|
|
540
|
+
for (const ep of episodes) {
|
|
541
|
+
db.markEpisodeStatus(ep.anime_id, ep.episode_num, 'pending', null, null);
|
|
542
|
+
}
|
|
543
|
+
console.log(chalk.cyan(` Reset ${episodes.length} episode(s) for ${key}`));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
console.log(chalk.green('\n Done - run ani-auto download to re-attempt them.\n'));
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ── quality ───────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
program
|
|
552
|
+
.command('quality [title] [quality]')
|
|
553
|
+
.description('Set per-anime quality override (interactive if no args given)')
|
|
554
|
+
.action(async (title, quality) => {
|
|
555
|
+
banner();
|
|
556
|
+
await db.initDb();
|
|
557
|
+
|
|
558
|
+
if (!title) {
|
|
559
|
+
const { resolveWatchList } = require('./engine');
|
|
560
|
+
const config = loadConfig();
|
|
561
|
+
const watchList = await resolveWatchList(config);
|
|
562
|
+
|
|
563
|
+
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return; }
|
|
564
|
+
|
|
565
|
+
console.log(chalk.bold('\n Select anime:'));
|
|
566
|
+
watchList.forEach((item, i) => console.log(` ${chalk.cyan(i + 1)}. ${item.title}`));
|
|
567
|
+
console.log('');
|
|
568
|
+
|
|
569
|
+
const { idx } = await inquirer.prompt([{
|
|
570
|
+
type: 'input', name: 'idx', message: 'Enter number:',
|
|
571
|
+
validate: v => {
|
|
572
|
+
const n = parseInt(v.trim());
|
|
573
|
+
return (isNaN(n) || n < 1 || n > watchList.length) ? `Enter 1-${watchList.length}` : true;
|
|
574
|
+
}
|
|
575
|
+
}]);
|
|
576
|
+
title = watchList[parseInt(idx.trim()) - 1].title;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!quality) {
|
|
580
|
+
const { picked } = await inquirer.prompt([{
|
|
581
|
+
type: 'list', name: 'picked',
|
|
582
|
+
message: `Quality for "${title}":`,
|
|
583
|
+
choices: [
|
|
584
|
+
{ name: '1080p', value: '1080p' },
|
|
585
|
+
{ name: '720p', value: '720p' },
|
|
586
|
+
{ name: '360p', value: '360p' },
|
|
587
|
+
{ name: 'Use global default', value: null },
|
|
588
|
+
],
|
|
589
|
+
}]);
|
|
590
|
+
quality = picked;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const ok = db.setAnimeQuality(title, quality);
|
|
594
|
+
if (ok) {
|
|
595
|
+
console.log(chalk.green(`\n Done Quality for "${title}" set to ${quality || 'global default'}.\n`));
|
|
596
|
+
} else {
|
|
597
|
+
console.log(chalk.yellow(`\n "${title}" not found in DB - run ani-auto download first to track it.\n`));
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ── notify ────────────────────────────────────────────────────────────────
|
|
437
602
|
|
|
438
603
|
program
|
|
439
604
|
.command('notify [action]')
|
|
440
|
-
.description('Enable or disable Telegram notifications (on, off
|
|
605
|
+
.description('Enable or disable Telegram notifications (on, off)')
|
|
441
606
|
.action(async (action) => {
|
|
442
607
|
banner();
|
|
443
608
|
const config = loadConfig();
|
|
@@ -445,7 +610,7 @@ program
|
|
|
445
610
|
if (!action) {
|
|
446
611
|
const enabled = !!config.telegramChatId && config.telegramNotify !== false;
|
|
447
612
|
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
|
|
613
|
+
if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure.\n'));
|
|
449
614
|
else console.log('');
|
|
450
615
|
return;
|
|
451
616
|
}
|
|
@@ -453,31 +618,29 @@ program
|
|
|
453
618
|
switch (action.toLowerCase()) {
|
|
454
619
|
case 'on':
|
|
455
620
|
if (!config.telegramChatId) {
|
|
456
|
-
console.log(chalk.yellow(' No chat ID set - run ani-auto setup first
|
|
621
|
+
console.log(chalk.yellow(' No chat ID set - run ani-auto setup first.\n'));
|
|
457
622
|
return;
|
|
458
623
|
}
|
|
459
624
|
updateConfig({ telegramNotify: true });
|
|
460
|
-
console.log(chalk.green('
|
|
625
|
+
console.log(chalk.green(' Done Telegram notifications enabled.\n'));
|
|
461
626
|
break;
|
|
462
|
-
|
|
463
627
|
case 'off':
|
|
464
628
|
updateConfig({ telegramNotify: false });
|
|
465
|
-
console.log(chalk.yellow('
|
|
629
|
+
console.log(chalk.yellow(' Done Telegram notifications disabled.\n'));
|
|
466
630
|
break;
|
|
467
|
-
|
|
468
631
|
default:
|
|
469
|
-
console.log(chalk.yellow(` Unknown action "${action}". Use: on, off`));
|
|
632
|
+
console.log(chalk.yellow(` Unknown action "${action}". Use: on, off\n`));
|
|
470
633
|
}
|
|
471
634
|
});
|
|
472
635
|
|
|
473
|
-
// ── update
|
|
636
|
+
// ── update ────────────────────────────────────────────────────────────────
|
|
474
637
|
|
|
475
638
|
program
|
|
476
639
|
.command('update')
|
|
477
640
|
.description('Check for updates and install the latest version')
|
|
478
641
|
.action(async () => {
|
|
479
642
|
banner();
|
|
480
|
-
const fetch
|
|
643
|
+
const fetch = require('node-fetch');
|
|
481
644
|
const { execSync } = require('child_process');
|
|
482
645
|
|
|
483
646
|
const spinner = ora('Checking for updates...').start();
|
|
@@ -488,20 +651,17 @@ program
|
|
|
488
651
|
spinner.stop();
|
|
489
652
|
|
|
490
653
|
if (latest === version) {
|
|
491
|
-
console.log(chalk.green(`
|
|
654
|
+
console.log(chalk.green(` Done Already on the latest version (v${version}).\n`));
|
|
492
655
|
return;
|
|
493
656
|
}
|
|
494
657
|
|
|
495
658
|
console.log(chalk.cyan(` Current version: ${chalk.bold(version)}`));
|
|
496
|
-
console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}`));
|
|
659
|
+
console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}\n`));
|
|
497
660
|
|
|
498
661
|
const { confirm } = await inquirer.prompt([{
|
|
499
|
-
type:
|
|
500
|
-
|
|
501
|
-
message: `Update to v${latest}?`,
|
|
502
|
-
default: true,
|
|
662
|
+
type: 'confirm', name: 'confirm',
|
|
663
|
+
message: `Update to v${latest}?`, default: true,
|
|
503
664
|
}]);
|
|
504
|
-
|
|
505
665
|
if (!confirm) return;
|
|
506
666
|
|
|
507
667
|
const installing = ora(`Installing v${latest}...`).start();
|
|
@@ -521,11 +681,11 @@ program
|
|
|
521
681
|
|
|
522
682
|
program
|
|
523
683
|
.command('daemon [action]')
|
|
524
|
-
.description('Manage the background daemon (start, stop, restart, status,
|
|
684
|
+
.description('Manage the background daemon (install, start, stop, restart, status, logs)')
|
|
525
685
|
.action(async (action) => {
|
|
526
|
-
const {
|
|
686
|
+
const { spawnSync, spawn } = require('child_process');
|
|
687
|
+
const SERVICE = 'ani-auto';
|
|
527
688
|
|
|
528
|
-
const SERVICE = 'ani-auto';
|
|
529
689
|
const run = (cmd) => {
|
|
530
690
|
try {
|
|
531
691
|
const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
@@ -535,19 +695,14 @@ program
|
|
|
535
695
|
}
|
|
536
696
|
};
|
|
537
697
|
|
|
538
|
-
|
|
539
|
-
if (!action) {
|
|
540
|
-
require('./daemon');
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
698
|
+
if (!action) { require('./daemon'); return; }
|
|
543
699
|
|
|
544
700
|
switch (action.toLowerCase()) {
|
|
545
|
-
|
|
546
701
|
case 'install': {
|
|
547
702
|
banner();
|
|
548
|
-
const nodeBin
|
|
703
|
+
const nodeBin = run('which node').out;
|
|
549
704
|
const daemonPath = run('npm root -g').out + '/ani-auto/src/daemon.js';
|
|
550
|
-
const
|
|
705
|
+
const service = [
|
|
551
706
|
'[Unit]',
|
|
552
707
|
'Description=ani-auto daemon',
|
|
553
708
|
'After=network-online.target',
|
|
@@ -566,57 +721,50 @@ program
|
|
|
566
721
|
const path = require('path');
|
|
567
722
|
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
568
723
|
fs.mkdirSync(dir, { recursive: true });
|
|
569
|
-
fs.writeFileSync(path.join(dir, `${SERVICE}.service`),
|
|
724
|
+
fs.writeFileSync(path.join(dir, `${SERVICE}.service`), service);
|
|
570
725
|
|
|
571
726
|
run('systemctl --user daemon-reload');
|
|
572
727
|
run(`systemctl --user enable ${SERVICE}`);
|
|
573
728
|
run(`systemctl --user start ${SERVICE}`);
|
|
574
|
-
console.log(chalk.green(
|
|
575
|
-
console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon status')} to check
|
|
576
|
-
`));
|
|
729
|
+
console.log(chalk.green(`\n Done Daemon installed and started.`));
|
|
730
|
+
console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon status')} to check.\n`));
|
|
577
731
|
break;
|
|
578
732
|
}
|
|
579
|
-
|
|
580
733
|
case 'start': {
|
|
581
734
|
const r = run(`systemctl --user start ${SERVICE}`);
|
|
582
|
-
if (r.code === 0) console.log(chalk.green('
|
|
583
|
-
else console.log(chalk.red(
|
|
584
|
-
Try: ani-auto daemon install`));
|
|
735
|
+
if (r.code === 0) console.log(chalk.green('\n Done Daemon started.\n'));
|
|
736
|
+
else console.log(chalk.red(`\n Failed to start: ${r.err}\n Try: ani-auto daemon install\n`));
|
|
585
737
|
break;
|
|
586
738
|
}
|
|
587
|
-
|
|
588
739
|
case 'stop': {
|
|
589
740
|
const r = run(`systemctl --user stop ${SERVICE}`);
|
|
590
|
-
if (r.code === 0) console.log(chalk.yellow('
|
|
591
|
-
else console.log(chalk.red(
|
|
741
|
+
if (r.code === 0) console.log(chalk.yellow('\n Done Daemon stopped.\n'));
|
|
742
|
+
else console.log(chalk.red(`\n Failed to stop: ${r.err}\n`));
|
|
592
743
|
break;
|
|
593
744
|
}
|
|
594
|
-
|
|
595
745
|
case 'restart': {
|
|
596
746
|
const r = run(`systemctl --user restart ${SERVICE}`);
|
|
597
|
-
if (r.code === 0) console.log(chalk.green('
|
|
598
|
-
else console.log(chalk.red(
|
|
747
|
+
if (r.code === 0) console.log(chalk.green('\n Done Daemon restarted.\n'));
|
|
748
|
+
else console.log(chalk.red(`\n Failed to restart: ${r.err}\n`));
|
|
599
749
|
break;
|
|
600
750
|
}
|
|
601
|
-
|
|
602
751
|
case 'status': {
|
|
603
752
|
const r = run(`systemctl --user status ${SERVICE}`);
|
|
604
753
|
console.log(r.out || r.err);
|
|
605
754
|
break;
|
|
606
755
|
}
|
|
607
|
-
|
|
608
756
|
case 'logs': {
|
|
609
|
-
console.log(chalk.gray(' Streaming logs (Ctrl+C to stop)
|
|
610
|
-
const { spawn } = require('child_process');
|
|
757
|
+
console.log(chalk.gray('\n Streaming logs (Ctrl+C to stop)...\n'));
|
|
611
758
|
spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
|
|
612
759
|
break;
|
|
613
760
|
}
|
|
614
|
-
|
|
615
761
|
default:
|
|
616
|
-
console.log(chalk.yellow(
|
|
762
|
+
console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
|
|
617
763
|
}
|
|
618
764
|
});
|
|
619
765
|
|
|
766
|
+
// ── main ──────────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
620
768
|
program
|
|
621
769
|
.name('ani-auto')
|
|
622
770
|
.version(version)
|