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 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.0.4",
3
+ "version": "1.2.0",
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
@@ -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
- if (existingRow?.confirmed_site_id) {
141
- siteAnime = { session: existingRow.confirmed_site_id, title: existingRow.confirmed_site_title };
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
- 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
+ }
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
- // Use AniList title for folder — consistent with what user sees on AniList
225
- const cleanTitle = buildFolderName(workItem.title);
226
- const season = detectSeason(workItem.title);
227
- // Strip "Season X" from the display title used in filename to avoid duplication
228
- const displayTitle = cleanTitle.replace(/\s*season\s*\d+\s*/gi, '').trim();
229
- const outputDir = path.join(config.outputDir, cleanTitle);
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(' ANI-AUTO — CHECK RUN ║'));
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
+ };