ani-auto 1.0.4 → 1.2.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 +20 -1
- package/package.json +1 -1
- package/src/cli.js +36 -8
- package/src/config.js +22 -51
- package/src/daemon.js +14 -2
- package/src/engine.js +49 -24
- package/src/notify.js +90 -0
package/README.md
CHANGED
|
@@ -21,6 +21,25 @@ ani-auto check
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## features
|
|
25
|
+
|
|
26
|
+
**1. anilist sync**
|
|
27
|
+
stays in sync with your watching list — only downloads episodes you haven't seen yet.
|
|
28
|
+
|
|
29
|
+
**2. quality control**
|
|
30
|
+
choose your preferred quality on each run or set a default.
|
|
31
|
+
|
|
32
|
+
**3. background daemon**
|
|
33
|
+
set it and forget it — checks for new episodes automatically on a schedule.
|
|
34
|
+
|
|
35
|
+
**4. pause & resume**
|
|
36
|
+
disable any anime at any time without losing your download history.
|
|
37
|
+
|
|
38
|
+
**5. telegram notifications**
|
|
39
|
+
get notified on your phone when episodes download, fail, or the daemon starts. message @userinfobot on telegram to get your chat id, then run `ani-auto setup`.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
24
43
|
## commands
|
|
25
44
|
|
|
26
45
|
```
|
|
@@ -48,7 +67,7 @@ Commands:
|
|
|
48
67
|
|
|
49
68
|
---
|
|
50
69
|
|
|
51
|
-
##
|
|
70
|
+
## configuration
|
|
52
71
|
|
|
53
72
|
```bash
|
|
54
73
|
ani-auto config --set-quality 1080p
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -89,6 +89,7 @@ program
|
|
|
89
89
|
{ type: 'number', name: 'concurrency', message: 'Parallel downloads per run (1-10):', default: current.concurrency || 2, validate: v => (v >= 1 && v <= 10) ? true : 'Enter a number 1–10' },
|
|
90
90
|
{ type: 'input', name: 'outputDir', message: 'Output directory for downloads:', default: current.outputDir },
|
|
91
91
|
{ type: 'number', name: 'daemonIntervalMinutes', message: 'Daemon polling interval (minutes):', default: current.daemonIntervalMinutes || 30 },
|
|
92
|
+
{ 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 || '' },
|
|
92
93
|
]);
|
|
93
94
|
answers.anilistUsername = anilistUsername;
|
|
94
95
|
answers.anilistToken = anilistToken;
|
|
@@ -101,14 +102,15 @@ program
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
updateConfig({
|
|
104
|
-
anilistUsername:
|
|
105
|
-
anilistToken:
|
|
106
|
-
watchStatuses:
|
|
107
|
-
quality:
|
|
108
|
-
language:
|
|
109
|
-
concurrency:
|
|
110
|
-
outputDir:
|
|
105
|
+
anilistUsername: answers.anilistUsername || null,
|
|
106
|
+
anilistToken: answers.anilistToken || null,
|
|
107
|
+
watchStatuses: answers.watchStatuses || current.watchStatuses,
|
|
108
|
+
quality: answers.quality,
|
|
109
|
+
language: answers.language,
|
|
110
|
+
concurrency: answers.concurrency,
|
|
111
|
+
outputDir: answers.outputDir,
|
|
111
112
|
daemonIntervalMinutes: answers.daemonIntervalMinutes,
|
|
113
|
+
telegramChatId: answers.telegramChatId || null,
|
|
112
114
|
});
|
|
113
115
|
|
|
114
116
|
console.log(chalk.green(`\n ✔ Config saved to ${CONFIG_FILE}\n`));
|
|
@@ -126,6 +128,7 @@ program
|
|
|
126
128
|
const config = loadConfig();
|
|
127
129
|
let quality = config.quality || '1080p';
|
|
128
130
|
let language = config.language || 'sub';
|
|
131
|
+
let range = null;
|
|
129
132
|
|
|
130
133
|
if (!opts.auto) {
|
|
131
134
|
const { pickedQuality, saveAsDefault } = await inquirer.prompt([
|
|
@@ -148,9 +151,34 @@ program
|
|
|
148
151
|
updateConfig({ quality });
|
|
149
152
|
console.log(chalk.green(` ✔ Saved default quality: ${quality}\n`));
|
|
150
153
|
}
|
|
154
|
+
|
|
155
|
+
const { useRange } = await inquirer.prompt([{
|
|
156
|
+
type: 'confirm',
|
|
157
|
+
name: 'useRange',
|
|
158
|
+
message: 'Download a specific episode range?',
|
|
159
|
+
default: false,
|
|
160
|
+
}]);
|
|
161
|
+
if (useRange) {
|
|
162
|
+
const { rangeInput } = await inquirer.prompt([{
|
|
163
|
+
type: 'input',
|
|
164
|
+
name: 'rangeInput',
|
|
165
|
+
message: 'Enter range (e.g. 1-24 or 5 for single episode):',
|
|
166
|
+
validate: (v) => {
|
|
167
|
+
const clean = v.trim();
|
|
168
|
+
if (/^\d+$/.test(clean)) return true;
|
|
169
|
+
if (/^\d+-\d+$/.test(clean)) {
|
|
170
|
+
const [a, b] = clean.split('-').map(Number);
|
|
171
|
+
if (a > b) return 'Start must be less than or equal to end';
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return 'Enter a number (e.g. 5) or range (e.g. 1-24)';
|
|
175
|
+
}
|
|
176
|
+
}]);
|
|
177
|
+
range = rangeInput.trim();
|
|
178
|
+
}
|
|
151
179
|
}
|
|
152
180
|
|
|
153
|
-
await runCycle({ auto: !!opts.auto, quality, language });
|
|
181
|
+
await runCycle({ auto: !!opts.auto, quality, language, range });
|
|
154
182
|
}
|
|
155
183
|
catch (e) { console.error(chalk.red('[ERROR]'), e.message); process.exit(1); }
|
|
156
184
|
});
|
package/src/config.js
CHANGED
|
@@ -1,57 +1,37 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs
|
|
3
|
+
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const os
|
|
5
|
+
const os = require('os');
|
|
6
6
|
|
|
7
|
-
const CONFIG_DIR
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
|
|
8
8
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
9
|
|
|
10
10
|
const DEFAULTS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
11
|
+
anilistToken: null,
|
|
12
|
+
anilistUsername: null,
|
|
13
|
+
watchStatuses: ['CURRENT'],
|
|
14
|
+
manualList: [],
|
|
15
|
+
quality: '1080p',
|
|
16
|
+
language: 'sub',
|
|
17
|
+
concurrency: 2,
|
|
18
|
+
outputDir: path.join(os.homedir(), 'anime'),
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
daemonIntervalMinutes: 30,
|
|
21
|
+
cronSchedule: '0 */2 * * *',
|
|
22
|
+
skipExisting: true,
|
|
23
|
+
maxEpisodesPerRun: 50,
|
|
35
24
|
};
|
|
36
25
|
|
|
37
26
|
function ensureConfigDir() {
|
|
38
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
39
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
40
|
-
}
|
|
27
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
28
|
}
|
|
42
29
|
|
|
43
30
|
function loadConfig() {
|
|
44
31
|
ensureConfigDir();
|
|
45
|
-
if (!fs.existsSync(CONFIG_FILE)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
51
|
-
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
52
|
-
} catch {
|
|
53
|
-
return { ...DEFAULTS };
|
|
54
|
-
}
|
|
32
|
+
if (!fs.existsSync(CONFIG_FILE)) { saveConfig(DEFAULTS); return { ...DEFAULTS }; }
|
|
33
|
+
try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) }; }
|
|
34
|
+
catch { return { ...DEFAULTS }; }
|
|
55
35
|
}
|
|
56
36
|
|
|
57
37
|
function saveConfig(config) {
|
|
@@ -60,20 +40,11 @@ function saveConfig(config) {
|
|
|
60
40
|
}
|
|
61
41
|
|
|
62
42
|
function updateConfig(partial) {
|
|
63
|
-
const
|
|
64
|
-
const updated = { ...current, ...partial };
|
|
43
|
+
const updated = { ...loadConfig(), ...partial };
|
|
65
44
|
saveConfig(updated);
|
|
66
45
|
return updated;
|
|
67
46
|
}
|
|
68
47
|
|
|
69
48
|
const getConfigPath = () => CONFIG_FILE;
|
|
70
49
|
|
|
71
|
-
module.exports = {
|
|
72
|
-
CONFIG_FILE,
|
|
73
|
-
CONFIG_DIR,
|
|
74
|
-
DEFAULTS,
|
|
75
|
-
loadConfig,
|
|
76
|
-
saveConfig,
|
|
77
|
-
updateConfig,
|
|
78
|
-
getConfigPath,
|
|
79
|
-
};
|
|
50
|
+
module.exports = { CONFIG_FILE, CONFIG_DIR, DEFAULTS, loadConfig, saveConfig, updateConfig, getConfigPath };
|
package/src/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ const chalk = require('chalk');
|
|
|
12
12
|
const { CronJob } = require('cron');
|
|
13
13
|
const { loadConfig } = require('./config');
|
|
14
14
|
const { runCycle } = require('./engine');
|
|
15
|
+
const notify = require('./notify');
|
|
15
16
|
|
|
16
17
|
let running = false; // prevents overlapping runs
|
|
17
18
|
|
|
@@ -52,6 +53,9 @@ async function startDaemon() {
|
|
|
52
53
|
console.log(chalk.cyan(` Output: ${config.outputDir}`));
|
|
53
54
|
console.log(chalk.gray(` Started: ${ts()}\n`));
|
|
54
55
|
|
|
56
|
+
// Notify daemon started
|
|
57
|
+
await notify.notifyDaemonStart(config.daemonIntervalMinutes || 30);
|
|
58
|
+
|
|
55
59
|
// Run immediately on start
|
|
56
60
|
await safeRun();
|
|
57
61
|
|
|
@@ -65,8 +69,16 @@ async function startDaemon() {
|
|
|
65
69
|
console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
|
|
66
70
|
|
|
67
71
|
// Graceful shutdown
|
|
68
|
-
process.on('SIGINT', () => {
|
|
69
|
-
|
|
72
|
+
process.on('SIGINT', async () => {
|
|
73
|
+
console.log(chalk.yellow('\n Shutting down daemon...'));
|
|
74
|
+
await notify.notifyDaemonStop();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
});
|
|
77
|
+
process.on('SIGTERM', async () => {
|
|
78
|
+
console.log(chalk.yellow('\n Daemon terminated.'));
|
|
79
|
+
await notify.notifyDaemonStop();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
});
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
startDaemon().catch(e => {
|
package/src/engine.js
CHANGED
|
@@ -10,6 +10,8 @@ const { loadConfig } = require('./config');
|
|
|
10
10
|
const db = require('./db');
|
|
11
11
|
const { getUserList, pickTitle } = require('./anilist');
|
|
12
12
|
const scraper = require('./scraper');
|
|
13
|
+
const { version } = require('../package.json');
|
|
14
|
+
const notify = require('./notify');
|
|
13
15
|
|
|
14
16
|
// ── Logging ────────────────────────────────────────────────────────────────
|
|
15
17
|
|
|
@@ -129,7 +131,7 @@ async function promptAnimeSelection(watchList) {
|
|
|
129
131
|
|
|
130
132
|
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
131
133
|
|
|
132
|
-
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null) {
|
|
134
|
+
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
133
135
|
const q = runQuality || workItem.quality || config.quality || '1080p';
|
|
134
136
|
const l = runLanguage || workItem.language || config.language || 'sub';
|
|
135
137
|
|
|
@@ -137,25 +139,27 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
137
139
|
const existingRow = db.getAnimeBySiteTitle(workItem.title);
|
|
138
140
|
let siteAnime;
|
|
139
141
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
// Always do a fresh search — session IDs expire on AnimePahe
|
|
143
|
+
const siteResults = await scraper.searchAll(workItem.title);
|
|
144
|
+
if (!siteResults.length) {
|
|
145
|
+
warn(`[${workItem.title}] Not found on site — skipping.`);
|
|
146
|
+
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (existingRow?.confirmed_site_title) {
|
|
150
|
+
// We have a saved title preference — find it in fresh results
|
|
151
|
+
const savedMatch = siteResults.find(r =>
|
|
152
|
+
r.title?.toLowerCase() === existingRow.confirmed_site_title.toLowerCase()
|
|
153
|
+
) || siteResults[0];
|
|
154
|
+
siteAnime = savedMatch;
|
|
142
155
|
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
156
|
+
// Update session ID in DB
|
|
157
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
143
158
|
} else if (auto) {
|
|
144
|
-
const siteResults = await scraper.searchAll(workItem.title);
|
|
145
|
-
if (!siteResults.length) {
|
|
146
|
-
warn(`[${workItem.title}] Not found on site — skipping.`);
|
|
147
|
-
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
148
|
-
}
|
|
149
159
|
siteAnime = siteResults[0];
|
|
150
160
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
151
161
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
152
162
|
} else {
|
|
153
|
-
const siteResults = await scraper.searchAll(workItem.title);
|
|
154
|
-
if (!siteResults.length) {
|
|
155
|
-
warn(`[${workItem.title}] Not found on site — skipping.`);
|
|
156
|
-
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
163
|
const topResults = siteResults.slice(0, 5);
|
|
160
164
|
const { pick } = await inquirer.prompt([{
|
|
161
165
|
type: 'list',
|
|
@@ -212,7 +216,24 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
212
216
|
const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
|
|
213
217
|
const watched = workItem.watchedEpisodes || 0;
|
|
214
218
|
const unwatched = sortedEpisodes.slice(watched);
|
|
215
|
-
|
|
219
|
+
let toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
|
|
220
|
+
|
|
221
|
+
// Apply episode range filter if specified
|
|
222
|
+
if (range) {
|
|
223
|
+
const clean = range.trim();
|
|
224
|
+
let rangeFrom, rangeTo;
|
|
225
|
+
if (/^\d+$/.test(clean)) {
|
|
226
|
+
rangeFrom = rangeTo = parseInt(clean);
|
|
227
|
+
} else {
|
|
228
|
+
[rangeFrom, rangeTo] = clean.split('-').map(Number);
|
|
229
|
+
}
|
|
230
|
+
// Range is relative to the unwatched slice (1 = first unwatched episode)
|
|
231
|
+
toDownload = toDownload.filter((ep, idx) => {
|
|
232
|
+
const pos = idx + 1;
|
|
233
|
+
return pos >= rangeFrom && pos <= rangeTo;
|
|
234
|
+
});
|
|
235
|
+
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '–' + rangeTo : ''} (${toDownload.length} matched).`);
|
|
236
|
+
}
|
|
216
237
|
|
|
217
238
|
if (!toDownload.length) {
|
|
218
239
|
info(`[${workItem.title}] Nothing new (watched ${watched}/${sortedEpisodes.length} episodes).`);
|
|
@@ -221,12 +242,13 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
221
242
|
|
|
222
243
|
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
223
244
|
|
|
224
|
-
//
|
|
225
|
-
const cleanTitle
|
|
226
|
-
const season
|
|
227
|
-
//
|
|
228
|
-
const
|
|
229
|
-
const
|
|
245
|
+
// Folder uses AniList title — consistent with what user sees on AniList
|
|
246
|
+
const cleanTitle = buildFolderName(workItem.title);
|
|
247
|
+
const season = detectSeason(workItem.title);
|
|
248
|
+
// Filename uses site title (better casing) with Season X stripped to avoid duplication
|
|
249
|
+
const siteClean = buildFolderName(siteAnime.title || workItem.title);
|
|
250
|
+
const displayTitle = siteClean.replace(/[:\s]*season\s*\d+[:\s]*/gi, ' ').replace(/\s+/g, ' ').trim();
|
|
251
|
+
const outputDir = path.join(config.outputDir, cleanTitle);
|
|
230
252
|
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
231
253
|
|
|
232
254
|
const queue = [...toDownload];
|
|
@@ -259,12 +281,14 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
259
281
|
|
|
260
282
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
|
|
261
283
|
ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
|
|
284
|
+
await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode);
|
|
262
285
|
success = true;
|
|
263
286
|
results.downloaded++;
|
|
264
287
|
} catch (e) {
|
|
265
288
|
if (attempts >= MAX_RETRIES) {
|
|
266
289
|
err(`[${workItem.title}] EP${ep.episode} failed: ${e.message}`);
|
|
267
290
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'failed', null, e.message);
|
|
291
|
+
await notify.notifyFailed(workItem.title, ep.episode, e.message);
|
|
268
292
|
results.failed++;
|
|
269
293
|
} else {
|
|
270
294
|
warn(`[${workItem.title}] EP${ep.episode} attempt ${attempts} failed, retrying...`);
|
|
@@ -281,12 +305,12 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
281
305
|
|
|
282
306
|
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
283
307
|
|
|
284
|
-
async function runCycle({ auto = false, quality = null, language = null } = {}) {
|
|
308
|
+
async function runCycle({ auto = false, quality = null, language = null, range = null } = {}) {
|
|
285
309
|
await db.initDb();
|
|
286
310
|
const config = loadConfig();
|
|
287
311
|
|
|
288
312
|
console.log(chalk.bold.red('\n ╔══════════════════════════════╗'));
|
|
289
|
-
console.log(chalk.bold.red(
|
|
313
|
+
console.log(chalk.bold.red(` ║ ANI-AUTO v${version} — CHECK RUN ║`));
|
|
290
314
|
console.log(chalk.bold.red(' ╚══════════════════════════════╝\n'));
|
|
291
315
|
|
|
292
316
|
const watchList = await resolveWatchList(config);
|
|
@@ -323,7 +347,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
|
|
|
323
347
|
const totals = { downloaded: 0, skipped: 0, failed: 0 };
|
|
324
348
|
|
|
325
349
|
for (const item of selected) {
|
|
326
|
-
const result = await processAnime(item, config, multiBar, auto, quality, language);
|
|
350
|
+
const result = await processAnime(item, config, multiBar, auto, quality, language, range);
|
|
327
351
|
totals.downloaded += result.downloaded;
|
|
328
352
|
totals.skipped += result.skipped;
|
|
329
353
|
totals.failed += result.failed;
|
|
@@ -338,6 +362,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
|
|
|
338
362
|
console.log(chalk.bold(' ─────────────────────────────────────\n'));
|
|
339
363
|
|
|
340
364
|
db.logRun(totals);
|
|
365
|
+
await notify.notifySummary(totals);
|
|
341
366
|
}
|
|
342
367
|
|
|
343
368
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
|
|
6
|
+
// Bot token is shared — each user only needs their own chat ID
|
|
7
|
+
// Users get their chat ID by messaging @anime_auto_downloader_bot on Telegram
|
|
8
|
+
const BOT_TOKEN = '8695132702:AAEFDoSL6nkdwDOoz5wmoVkmuhaYa4tNw0o';
|
|
9
|
+
|
|
10
|
+
// ── Telegram ───────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
async function sendTelegram(text) {
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const chatId = config.telegramChatId;
|
|
15
|
+
if (!chatId) return;
|
|
16
|
+
try {
|
|
17
|
+
const fetch = require('node-fetch');
|
|
18
|
+
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
chat_id: String(chatId),
|
|
23
|
+
text,
|
|
24
|
+
parse_mode: 'HTML',
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// silently fail — never crash the download for a notification
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── KDE Desktop (notify-send) ──────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function sendDesktop(title, body, urgency = 'normal') {
|
|
35
|
+
try {
|
|
36
|
+
// KDE uses notify-send from libnotify
|
|
37
|
+
execSync(`notify-send -u ${urgency} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore' });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// silently fail if notify-send not available
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Public notification functions ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async function notifyEpisode(animeTitle, episode) {
|
|
46
|
+
const msg = `✅ <b>${animeTitle}</b>\nEpisode ${episode} downloaded`;
|
|
47
|
+
const short = `Episode ${episode} downloaded`;
|
|
48
|
+
await sendTelegram(msg);
|
|
49
|
+
sendDesktop(animeTitle, short);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function notifyFailed(animeTitle, episode, error) {
|
|
53
|
+
const msg = `❌ <b>${animeTitle}</b>\nEpisode ${episode} failed: ${error}`;
|
|
54
|
+
const short = `Episode ${episode} failed`;
|
|
55
|
+
await sendTelegram(msg);
|
|
56
|
+
sendDesktop(animeTitle, short, 'critical');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function notifySummary(totals) {
|
|
60
|
+
if (totals.downloaded === 0 && totals.failed === 0) return; // skip empty runs
|
|
61
|
+
const msg =
|
|
62
|
+
`📋 <b>ani-auto — run complete</b>\n` +
|
|
63
|
+
`✅ Downloaded: ${totals.downloaded}\n` +
|
|
64
|
+
`⏭ Skipped: ${totals.skipped}\n` +
|
|
65
|
+
`❌ Failed: ${totals.failed}`;
|
|
66
|
+
const short = `↓${totals.downloaded} ✗${totals.failed}`;
|
|
67
|
+
await sendTelegram(msg);
|
|
68
|
+
sendDesktop('ani-auto run complete', short);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function notifyDaemonStart(intervalMinutes) {
|
|
72
|
+
const msg = `🚀 <b>ani-auto daemon started</b>\nChecking every ${intervalMinutes} minutes`;
|
|
73
|
+
const short = `Checking every ${intervalMinutes} minutes`;
|
|
74
|
+
await sendTelegram(msg);
|
|
75
|
+
sendDesktop('ani-auto daemon started', short);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function notifyDaemonStop() {
|
|
79
|
+
const msg = `🛑 <b>ani-auto daemon stopped</b>`;
|
|
80
|
+
await sendTelegram(msg);
|
|
81
|
+
sendDesktop('ani-auto daemon stopped', '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
notifyEpisode,
|
|
86
|
+
notifyFailed,
|
|
87
|
+
notifySummary,
|
|
88
|
+
notifyDaemonStart,
|
|
89
|
+
notifyDaemonStop,
|
|
90
|
+
};
|