ani-auto 1.2.5 → 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 -34
- package/package.json +2 -3
- package/src/cli.js +417 -143
- package/src/config.js +5 -3
- package/src/daemon.js +90 -30
- package/src/db.js +57 -11
- package/src/engine.js +51 -21
- package/src/network.js +88 -0
- package/src/notify.js +70 -46
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,6 +25,8 @@ 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')
|
|
@@ -39,7 +41,7 @@ program
|
|
|
39
41
|
default: current.anilistUsername || '',
|
|
40
42
|
}]);
|
|
41
43
|
|
|
42
|
-
let anilistToken
|
|
44
|
+
let anilistToken = current.anilistToken || '';
|
|
43
45
|
let watchStatuses = current.watchStatuses || ['CURRENT'];
|
|
44
46
|
|
|
45
47
|
if (anilistUsername) {
|
|
@@ -86,12 +88,11 @@ program
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
const answers = await inquirer.prompt([
|
|
89
|
-
{ type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '
|
|
90
|
-
{ type: '
|
|
91
|
-
{ 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' },
|
|
91
|
+
{ type: 'list', name: 'quality', message: 'Default download quality:', choices: ['1080p', '720p', '360p'], default: current.quality || '1080p' },
|
|
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' },
|
|
92
93
|
{ type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
|
|
93
|
-
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon
|
|
94
|
-
{ type: 'input', name: 'telegramChatId', message: 'Telegram chat ID for notifications (leave blank to skip):\n
|
|
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 || '' },
|
|
95
96
|
]);
|
|
96
97
|
answers.anilistUsername = anilistUsername;
|
|
97
98
|
answers.anilistToken = anilistToken;
|
|
@@ -100,106 +101,106 @@ program
|
|
|
100
101
|
if (answers.anilistToken) {
|
|
101
102
|
const spinner = ora('Verifying AniList token...').start();
|
|
102
103
|
try { const viewer = await getViewer(answers.anilistToken); spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`); }
|
|
103
|
-
catch { spinner.warn('Could not verify token
|
|
104
|
+
catch { spinner.warn('Could not verify token - saved anyway, check it manually.'); }
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
updateConfig({
|
|
107
|
-
anilistUsername:
|
|
108
|
-
anilistToken:
|
|
109
|
-
watchStatuses:
|
|
110
|
-
quality:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
outputDir: answers.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,
|
|
114
114
|
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
115
|
-
telegramChatId:
|
|
115
|
+
telegramChatId: answers.telegramChatId || null,
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
console.log(chalk.green(`\n
|
|
119
|
-
console.log(` Run ${chalk.bold('ani-auto
|
|
120
|
-
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`);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
// ── download ──────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
123
125
|
program
|
|
124
|
-
.command('
|
|
126
|
+
.command('download')
|
|
125
127
|
.description('Run one download cycle immediately')
|
|
126
|
-
.
|
|
127
|
-
.action(async (opts) => {
|
|
128
|
+
.action(async () => {
|
|
128
129
|
banner();
|
|
129
130
|
try {
|
|
130
|
-
const config
|
|
131
|
-
let quality
|
|
132
|
-
let language
|
|
133
|
-
let range
|
|
134
|
-
let runStatuses
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{
|
|
146
|
-
type: 'confirm',
|
|
147
|
-
name: 'saveAsDefault',
|
|
148
|
-
message: 'Save as default quality?',
|
|
149
|
-
default: false,
|
|
150
|
-
},
|
|
151
|
-
]);
|
|
152
|
-
quality = pickedQuality;
|
|
153
|
-
if (saveAsDefault) {
|
|
154
|
-
updateConfig({ quality });
|
|
155
|
-
console.log(chalk.green(` ✔ 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([{
|
|
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
|
+
{
|
|
173
146
|
type: 'confirm',
|
|
174
|
-
name: '
|
|
175
|
-
message: '
|
|
147
|
+
name: 'saveAsDefault',
|
|
148
|
+
message: 'Save as default quality?',
|
|
176
149
|
default: false,
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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;
|
|
192
190
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
191
|
+
return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
|
|
192
|
+
}
|
|
193
|
+
}]);
|
|
194
|
+
range = rangeInput.trim();
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
await runCycle({ auto:
|
|
197
|
+
await runCycle({ auto: false, quality, language, range, statuses: runStatuses });
|
|
199
198
|
}
|
|
200
199
|
catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
|
|
201
200
|
});
|
|
202
201
|
|
|
202
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
203
204
|
program
|
|
204
205
|
.command('list')
|
|
205
206
|
.description('Show all tracked anime and their episode download status')
|
|
@@ -207,8 +208,8 @@ program
|
|
|
207
208
|
banner();
|
|
208
209
|
await db.initDb();
|
|
209
210
|
const rawDb = db.getDb();
|
|
210
|
-
const stmt
|
|
211
|
-
const all
|
|
211
|
+
const stmt = rawDb.prepare('SELECT * FROM anime');
|
|
212
|
+
const all = [];
|
|
212
213
|
while (stmt.step()) all.push(stmt.getAsObject());
|
|
213
214
|
stmt.free();
|
|
214
215
|
|
|
@@ -223,17 +224,18 @@ program
|
|
|
223
224
|
for (const row of all) {
|
|
224
225
|
const stats = db.getEpisodeStats(row.id);
|
|
225
226
|
const enabled = row.enabled ? chalk.green('yes') : chalk.gray('no');
|
|
226
|
-
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;
|
|
227
228
|
console.log(` ${title.padEnd(40)} ${row.source.padEnd(8)} ${enabled.padEnd(16)} ${chalk.green(String(stats.done).padEnd(6))}${chalk.red(stats.failed)}`);
|
|
228
229
|
}
|
|
229
230
|
console.log('');
|
|
230
231
|
});
|
|
231
232
|
|
|
233
|
+
// ── add ───────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
232
235
|
program
|
|
233
236
|
.command('add <title>')
|
|
234
237
|
.description('Search AniList and add an anime to the manual download list')
|
|
235
238
|
.option('-q, --quality <quality>', 'Quality override (e.g. 1080p)', null)
|
|
236
|
-
.option('-l, --language <lang>', 'Language override (sub/dub)', null)
|
|
237
239
|
.action(async (title, opts) => {
|
|
238
240
|
banner();
|
|
239
241
|
await db.initDb();
|
|
@@ -244,22 +246,30 @@ program
|
|
|
244
246
|
try { results = await searchAnime(title); spinner.stop(); }
|
|
245
247
|
catch (e) { spinner.fail(`AniList search failed: ${e.message}`); return; }
|
|
246
248
|
|
|
247
|
-
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; }
|
|
248
250
|
|
|
249
251
|
const { picked } = await inquirer.prompt([{
|
|
250
252
|
type: 'list', name: 'picked', message: 'Select the anime:',
|
|
251
253
|
choices: results.map(r => ({
|
|
252
|
-
name: `${pickTitle(r.titles)} (${r.year || '?'})
|
|
254
|
+
name: `${pickTitle(r.titles)} (${r.year || '?'}) - ${r.episodes ? r.episodes + ' eps' : 'ongoing'} [${r.status}]`,
|
|
253
255
|
value: r,
|
|
254
256
|
})),
|
|
255
257
|
}]);
|
|
256
258
|
|
|
257
259
|
const resolvedTitle = pickTitle(picked.titles);
|
|
258
|
-
const config
|
|
259
|
-
const manualList
|
|
260
|
+
const config = loadConfig();
|
|
261
|
+
const manualList = config.manualList || [];
|
|
260
262
|
|
|
261
263
|
const existsManual = manualList.find(m => m.title.toLowerCase() === resolvedTitle.toLowerCase());
|
|
262
|
-
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
|
+
}
|
|
263
273
|
|
|
264
274
|
// Check if already in AniList watch list (fuzzy match)
|
|
265
275
|
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
@@ -275,34 +285,40 @@ program
|
|
|
275
285
|
const inAnilist = anilistEntries.find(e => {
|
|
276
286
|
const t = e.titles;
|
|
277
287
|
return [t.english, t.romaji, t.native].some(
|
|
278
|
-
|
|
288
|
+
t2 => t2 && normalize(t2) === normResolved
|
|
279
289
|
);
|
|
280
290
|
});
|
|
281
291
|
if (inAnilist) {
|
|
282
|
-
|
|
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
|
+
}
|
|
283
298
|
return;
|
|
284
299
|
}
|
|
285
300
|
} catch (e) {}
|
|
286
301
|
}
|
|
287
302
|
|
|
288
|
-
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 };
|
|
289
304
|
manualList.push(entry);
|
|
290
305
|
updateConfig({ manualList });
|
|
291
|
-
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 });
|
|
292
307
|
|
|
293
|
-
console.log(chalk.green(`\n
|
|
294
|
-
if (opts.quality)
|
|
295
|
-
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}`);
|
|
296
310
|
console.log('');
|
|
297
311
|
});
|
|
298
312
|
|
|
313
|
+
// ── remove ────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
299
315
|
program
|
|
300
316
|
.command('remove [title]')
|
|
301
317
|
.description('Remove an anime from the manual list (interactive if no title given)')
|
|
302
318
|
.action(async (title) => {
|
|
303
319
|
banner();
|
|
304
320
|
await db.initDb();
|
|
305
|
-
const config
|
|
321
|
+
const config = loadConfig();
|
|
306
322
|
const manualList = config.manualList || [];
|
|
307
323
|
|
|
308
324
|
if (!manualList.length) { console.log(chalk.yellow(' Your manual list is empty.\n')); return; }
|
|
@@ -319,17 +335,22 @@ program
|
|
|
319
335
|
const exists = manualList.find(m => m.title.toLowerCase() === target.toLowerCase());
|
|
320
336
|
if (!exists) { console.log(chalk.yellow(` "${target}" is not in your manual list.\n`)); return; }
|
|
321
337
|
|
|
322
|
-
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
|
+
}]);
|
|
323
342
|
if (!confirm) return;
|
|
324
343
|
|
|
325
344
|
updateConfig({ manualList: manualList.filter(m => m.title.toLowerCase() !== target.toLowerCase()) });
|
|
326
345
|
db.removeAnime(target);
|
|
327
|
-
console.log(chalk.green(
|
|
346
|
+
console.log(chalk.green(`\n Done Removed "${target}".\n`));
|
|
328
347
|
});
|
|
329
348
|
|
|
349
|
+
// ── enable / disable ──────────────────────────────────────────────────────
|
|
350
|
+
|
|
330
351
|
async function pickFromWatchList(message) {
|
|
331
352
|
const { resolveWatchList } = require('./engine');
|
|
332
|
-
const config
|
|
353
|
+
const config = loadConfig();
|
|
333
354
|
const watchList = await resolveWatchList(config);
|
|
334
355
|
|
|
335
356
|
if (!watchList.length) { console.log(chalk.yellow(' Watch list is empty.\n')); return null; }
|
|
@@ -357,7 +378,7 @@ program
|
|
|
357
378
|
const item = await pickFromWatchList('Select anime to enable:');
|
|
358
379
|
if (!item) return;
|
|
359
380
|
db.setAnimeEnabled(item.title, true);
|
|
360
|
-
console.log(chalk.green(`\n
|
|
381
|
+
console.log(chalk.green(`\n Done Enabled "${item.title}".\n`));
|
|
361
382
|
});
|
|
362
383
|
|
|
363
384
|
program
|
|
@@ -368,18 +389,51 @@ program
|
|
|
368
389
|
const item = await pickFromWatchList('Select anime to disable:');
|
|
369
390
|
if (!item) return;
|
|
370
391
|
db.setAnimeEnabled(item.title, false);
|
|
371
|
-
console.log(chalk.yellow(`\n
|
|
392
|
+
console.log(chalk.yellow(`\n Done Disabled "${item.title}".\n`));
|
|
372
393
|
});
|
|
373
394
|
|
|
395
|
+
// ── status ────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
374
397
|
program
|
|
375
398
|
.command('status')
|
|
376
|
-
.description('Show recent run log')
|
|
377
|
-
.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')
|
|
378
402
|
.action(async (opts) => {
|
|
379
403
|
banner();
|
|
380
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
|
+
|
|
381
435
|
const runs = db.getRecentRuns(parseInt(opts.runs, 10));
|
|
382
|
-
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; }
|
|
383
437
|
|
|
384
438
|
console.log(chalk.bold(` ${'Time'.padEnd(20)} ${'Downloaded'.padEnd(12)} ${'Skipped'.padEnd(10)} Failed`));
|
|
385
439
|
console.log(' ' + '─'.repeat(55));
|
|
@@ -389,24 +443,25 @@ program
|
|
|
389
443
|
console.log('');
|
|
390
444
|
});
|
|
391
445
|
|
|
446
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
392
448
|
program
|
|
393
449
|
.command('config')
|
|
394
450
|
.description('View, update, or open config file')
|
|
395
|
-
.option('--open',
|
|
396
|
-
.option('--set-quality <quality>',
|
|
397
|
-
.option('--set-
|
|
398
|
-
.option('--set-
|
|
399
|
-
.option('--set-
|
|
400
|
-
.option('--set-
|
|
401
|
-
.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)')
|
|
402
457
|
.action((opts) => {
|
|
403
458
|
banner();
|
|
404
459
|
|
|
405
460
|
if (opts.open) {
|
|
406
|
-
const { execSync }
|
|
461
|
+
const { execSync } = require('child_process');
|
|
407
462
|
const { getConfigPath } = require('./config');
|
|
408
|
-
const configPath
|
|
409
|
-
const editor
|
|
463
|
+
const configPath = getConfigPath();
|
|
464
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
410
465
|
console.log(chalk.cyan(` Opening config in ${editor}: ${configPath}\n`));
|
|
411
466
|
try { execSync(`${editor} "${configPath}"`, { stdio: 'inherit' }); }
|
|
412
467
|
catch (e) { console.log(chalk.yellow(` Could not open editor. Config is at: ${configPath}`)); }
|
|
@@ -414,16 +469,15 @@ program
|
|
|
414
469
|
}
|
|
415
470
|
|
|
416
471
|
const updates = {};
|
|
417
|
-
if (opts.setQuality)
|
|
418
|
-
if (opts.
|
|
419
|
-
if (opts.
|
|
420
|
-
if (opts.
|
|
421
|
-
if (opts.
|
|
422
|
-
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);
|
|
423
477
|
|
|
424
478
|
if (Object.keys(updates).length) {
|
|
425
479
|
updateConfig(updates);
|
|
426
|
-
console.log(chalk.green('
|
|
480
|
+
console.log(chalk.green(' Done Config updated:\n'));
|
|
427
481
|
for (const [k, v] of Object.entries(updates)) console.log(` ${chalk.bold(k)}: ${v}`);
|
|
428
482
|
} else {
|
|
429
483
|
const config = loadConfig();
|
|
@@ -436,14 +490,157 @@ program
|
|
|
436
490
|
console.log('');
|
|
437
491
|
});
|
|
438
492
|
|
|
439
|
-
// ──
|
|
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 ────────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
program
|
|
604
|
+
.command('notify [action]')
|
|
605
|
+
.description('Enable or disable Telegram notifications (on, off)')
|
|
606
|
+
.action(async (action) => {
|
|
607
|
+
banner();
|
|
608
|
+
const config = loadConfig();
|
|
609
|
+
|
|
610
|
+
if (!action) {
|
|
611
|
+
const enabled = !!config.telegramChatId && config.telegramNotify !== false;
|
|
612
|
+
console.log(` Telegram notifications: ${enabled ? chalk.green('enabled') : chalk.gray('disabled')}`);
|
|
613
|
+
if (!config.telegramChatId) console.log(chalk.yellow(' No chat ID set - run ani-auto setup to configure.\n'));
|
|
614
|
+
else console.log('');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
switch (action.toLowerCase()) {
|
|
619
|
+
case 'on':
|
|
620
|
+
if (!config.telegramChatId) {
|
|
621
|
+
console.log(chalk.yellow(' No chat ID set - run ani-auto setup first.\n'));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
updateConfig({ telegramNotify: true });
|
|
625
|
+
console.log(chalk.green(' Done Telegram notifications enabled.\n'));
|
|
626
|
+
break;
|
|
627
|
+
case 'off':
|
|
628
|
+
updateConfig({ telegramNotify: false });
|
|
629
|
+
console.log(chalk.yellow(' Done Telegram notifications disabled.\n'));
|
|
630
|
+
break;
|
|
631
|
+
default:
|
|
632
|
+
console.log(chalk.yellow(` Unknown action "${action}". Use: on, off\n`));
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// ── update ────────────────────────────────────────────────────────────────
|
|
440
637
|
|
|
441
638
|
program
|
|
442
639
|
.command('update')
|
|
443
640
|
.description('Check for updates and install the latest version')
|
|
444
641
|
.action(async () => {
|
|
445
642
|
banner();
|
|
446
|
-
const fetch
|
|
643
|
+
const fetch = require('node-fetch');
|
|
447
644
|
const { execSync } = require('child_process');
|
|
448
645
|
|
|
449
646
|
const spinner = ora('Checking for updates...').start();
|
|
@@ -454,7 +651,7 @@ program
|
|
|
454
651
|
spinner.stop();
|
|
455
652
|
|
|
456
653
|
if (latest === version) {
|
|
457
|
-
console.log(chalk.green(`
|
|
654
|
+
console.log(chalk.green(` Done Already on the latest version (v${version}).\n`));
|
|
458
655
|
return;
|
|
459
656
|
}
|
|
460
657
|
|
|
@@ -462,18 +659,15 @@ program
|
|
|
462
659
|
console.log(chalk.cyan(` Latest version: ${chalk.bold(latest)}\n`));
|
|
463
660
|
|
|
464
661
|
const { confirm } = await inquirer.prompt([{
|
|
465
|
-
type:
|
|
466
|
-
|
|
467
|
-
message: `Update to v${latest}?`,
|
|
468
|
-
default: true,
|
|
662
|
+
type: 'confirm', name: 'confirm',
|
|
663
|
+
message: `Update to v${latest}?`, default: true,
|
|
469
664
|
}]);
|
|
470
|
-
|
|
471
665
|
if (!confirm) return;
|
|
472
666
|
|
|
473
667
|
const installing = ora(`Installing v${latest}...`).start();
|
|
474
668
|
try {
|
|
475
669
|
execSync('npm install -g ani-auto@latest', { stdio: 'pipe' });
|
|
476
|
-
installing.succeed(`Updated to v${latest}
|
|
670
|
+
installing.succeed(`Updated to v${latest} - restart your terminal to use the new version.`);
|
|
477
671
|
} catch (e) {
|
|
478
672
|
installing.fail('Update failed. Try manually: npm install -g ani-auto@latest');
|
|
479
673
|
}
|
|
@@ -486,15 +680,95 @@ program
|
|
|
486
680
|
// ── daemon ────────────────────────────────────────────────────────────────
|
|
487
681
|
|
|
488
682
|
program
|
|
489
|
-
.command('daemon')
|
|
490
|
-
.description('
|
|
491
|
-
.
|
|
492
|
-
|
|
683
|
+
.command('daemon [action]')
|
|
684
|
+
.description('Manage the background daemon (install, start, stop, restart, status, logs)')
|
|
685
|
+
.action(async (action) => {
|
|
686
|
+
const { spawnSync, spawn } = require('child_process');
|
|
687
|
+
const SERVICE = 'ani-auto';
|
|
688
|
+
|
|
689
|
+
const run = (cmd) => {
|
|
690
|
+
try {
|
|
691
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
692
|
+
return { out: r.stdout?.trim(), err: r.stderr?.trim(), code: r.status };
|
|
693
|
+
} catch (e) {
|
|
694
|
+
return { out: '', err: e.message, code: 1 };
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
if (!action) { require('./daemon'); return; }
|
|
699
|
+
|
|
700
|
+
switch (action.toLowerCase()) {
|
|
701
|
+
case 'install': {
|
|
702
|
+
banner();
|
|
703
|
+
const nodeBin = run('which node').out;
|
|
704
|
+
const daemonPath = run('npm root -g').out + '/ani-auto/src/daemon.js';
|
|
705
|
+
const service = [
|
|
706
|
+
'[Unit]',
|
|
707
|
+
'Description=ani-auto daemon',
|
|
708
|
+
'After=network-online.target',
|
|
709
|
+
'',
|
|
710
|
+
'[Service]',
|
|
711
|
+
`ExecStart=${nodeBin} ${daemonPath}`,
|
|
712
|
+
'Restart=on-failure',
|
|
713
|
+
'RestartSec=10',
|
|
714
|
+
'',
|
|
715
|
+
'[Install]',
|
|
716
|
+
'WantedBy=default.target',
|
|
717
|
+
].join('\n');
|
|
718
|
+
|
|
719
|
+
const fs = require('fs');
|
|
720
|
+
const os = require('os');
|
|
721
|
+
const path = require('path');
|
|
722
|
+
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
723
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
724
|
+
fs.writeFileSync(path.join(dir, `${SERVICE}.service`), service);
|
|
725
|
+
|
|
726
|
+
run('systemctl --user daemon-reload');
|
|
727
|
+
run(`systemctl --user enable ${SERVICE}`);
|
|
728
|
+
run(`systemctl --user start ${SERVICE}`);
|
|
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`));
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case 'start': {
|
|
734
|
+
const r = run(`systemctl --user start ${SERVICE}`);
|
|
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`));
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
case 'stop': {
|
|
740
|
+
const r = run(`systemctl --user stop ${SERVICE}`);
|
|
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`));
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case 'restart': {
|
|
746
|
+
const r = run(`systemctl --user restart ${SERVICE}`);
|
|
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`));
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
case 'status': {
|
|
752
|
+
const r = run(`systemctl --user status ${SERVICE}`);
|
|
753
|
+
console.log(r.out || r.err);
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case 'logs': {
|
|
757
|
+
console.log(chalk.gray('\n Streaming logs (Ctrl+C to stop)...\n'));
|
|
758
|
+
spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
default:
|
|
762
|
+
console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// ── main ──────────────────────────────────────────────────────────────────
|
|
493
767
|
|
|
494
768
|
program
|
|
495
769
|
.name('ani-auto')
|
|
496
770
|
.version(version)
|
|
497
|
-
.description('Automatic anime episode downloader
|
|
771
|
+
.description('Automatic anime episode downloader - AniList-aware')
|
|
498
772
|
.parse(process.argv);
|
|
499
773
|
|
|
500
774
|
if (!process.argv.slice(2).length) { banner(); program.help(); }
|