ani-auto 1.1.0 → 1.2.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 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
- ## config options
70
+ ## configuration
52
71
 
53
72
  ```bash
54
73
  ani-auto config --set-quality 1080p
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Automatic anime episode downloader",
5
5
  "bin": {
6
6
  "ani-auto": "./src/cli.js"
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: answers.anilistUsername || null,
105
- anilistToken: answers.anilistToken || null,
106
- watchStatuses: answers.watchStatuses || current.watchStatuses,
107
- quality: answers.quality,
108
- language: answers.language,
109
- concurrency: answers.concurrency,
110
- outputDir: answers.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 = require('fs');
3
+ const fs = require('fs');
4
4
  const path = require('path');
5
- const os = require('os');
5
+ const os = require('os');
6
6
 
7
- const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
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
- // AniList
12
- anilistToken: null,
13
- anilistUsername: null,
14
-
15
- // Which AniList statuses to auto-download
16
- watchStatuses: ['CURRENT'],
17
-
18
- // Manual overrides: array of { title, quality, language, enabled }
19
- manualList: [],
20
-
21
- // Download settings
22
- quality: '1080p',
23
- language: 'sub',
24
- concurrency: 2,
25
- outputDir: path.join(os.homedir(), 'anime'),
26
- maxRetries: 3,
27
-
28
- // Scheduling
29
- daemonIntervalMinutes: 30, // how often the daemon polls
30
- cronSchedule: '0 */2 * * *', // every 2 hours (for cron mode)
31
-
32
- // Behaviour
33
- skipExisting: true, // skip episodes already in DB
34
- maxEpisodesPerRun: 50, // safety cap per check cycle
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
- saveConfig(DEFAULTS);
47
- return { ...DEFAULTS };
48
- }
49
- try {
50
- const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
51
- return { ...DEFAULTS, ...JSON.parse(raw) };
52
- } catch {
53
- return { ...DEFAULTS };
54
- }
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 current = loadConfig();
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', () => { console.log(chalk.yellow('\n Shutting down daemon...')); process.exit(0); });
69
- process.on('SIGTERM', () => { console.log(chalk.yellow('\n Daemon terminated.')); process.exit(0); });
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
@@ -11,6 +11,7 @@ const db = require('./db');
11
11
  const { getUserList, pickTitle } = require('./anilist');
12
12
  const scraper = require('./scraper');
13
13
  const { version } = require('../package.json');
14
+ const notify = require('./notify');
14
15
 
15
16
  // ── Logging ────────────────────────────────────────────────────────────────
16
17
 
@@ -130,7 +131,7 @@ async function promptAnimeSelection(watchList) {
130
131
 
131
132
  // ── Per-anime download ─────────────────────────────────────────────────────
132
133
 
133
- 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) {
134
135
  const q = runQuality || workItem.quality || config.quality || '1080p';
135
136
  const l = runLanguage || workItem.language || config.language || 'sub';
136
137
 
@@ -215,7 +216,24 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
215
216
  const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
216
217
  const watched = workItem.watchedEpisodes || 0;
217
218
  const unwatched = sortedEpisodes.slice(watched);
218
- const toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
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
+ }
219
237
 
220
238
  if (!toDownload.length) {
221
239
  info(`[${workItem.title}] Nothing new (watched ${watched}/${sortedEpisodes.length} episodes).`);
@@ -263,12 +281,14 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
263
281
 
264
282
  db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
265
283
  ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
284
+ await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode);
266
285
  success = true;
267
286
  results.downloaded++;
268
287
  } catch (e) {
269
288
  if (attempts >= MAX_RETRIES) {
270
289
  err(`[${workItem.title}] EP${ep.episode} failed: ${e.message}`);
271
290
  db.markEpisodeStatus(dbRow.id, ep.episode, 'failed', null, e.message);
291
+ await notify.notifyFailed(workItem.title, ep.episode, e.message);
272
292
  results.failed++;
273
293
  } else {
274
294
  warn(`[${workItem.title}] EP${ep.episode} attempt ${attempts} failed, retrying...`);
@@ -285,7 +305,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
285
305
 
286
306
  // ── Main cycle ─────────────────────────────────────────────────────────────
287
307
 
288
- async function runCycle({ auto = false, quality = null, language = null } = {}) {
308
+ async function runCycle({ auto = false, quality = null, language = null, range = null } = {}) {
289
309
  await db.initDb();
290
310
  const config = loadConfig();
291
311
 
@@ -327,7 +347,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
327
347
  const totals = { downloaded: 0, skipped: 0, failed: 0 };
328
348
 
329
349
  for (const item of selected) {
330
- const result = await processAnime(item, config, multiBar, auto, quality, language);
350
+ const result = await processAnime(item, config, multiBar, auto, quality, language, range);
331
351
  totals.downloaded += result.downloaded;
332
352
  totals.skipped += result.skipped;
333
353
  totals.failed += result.failed;
@@ -342,6 +362,7 @@ async function runCycle({ auto = false, quality = null, language = null } = {})
342
362
  console.log(chalk.bold(' ─────────────────────────────────────\n'));
343
363
 
344
364
  db.logRun(totals);
365
+ await notify.notifySummary(totals);
345
366
  }
346
367
 
347
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
+ };