ani-auto 1.3.1 → 1.3.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,82 @@
1
+ # Changelog
2
+
3
+ ## [1.3.3]
4
+ ### added
5
+ - daemon can be fully enabled or disabled — blocks all startup on boot and network restore when disabled
6
+
7
+ ---
8
+
9
+ ## [1.3.1]
10
+ ### fixed
11
+ - stability patch
12
+
13
+ ### added
14
+ - poster images included in telegram and desktop notifications
15
+ - daemon and notification toggle
16
+
17
+ ---
18
+
19
+ ## [1.3.0]
20
+ ### added
21
+ - toggle to enable/disable the daemon without uninstalling it
22
+
23
+ ---
24
+
25
+ ## [1.2.4]
26
+ ### fixed
27
+ - removed node.js warnings on startup
28
+
29
+ ---
30
+
31
+ ## [1.2.3]
32
+ ### added
33
+ - check for updates and install latest version in one step
34
+
35
+ ---
36
+
37
+ ## [1.2.2]
38
+ ### added
39
+ - anilist watch list status selection on each download run (current, planning, paused, repeating)
40
+
41
+ ---
42
+
43
+ ## [1.2.1]
44
+ ### fixed
45
+ - banner design fix
46
+
47
+ ---
48
+
49
+ ## [1.2.0]
50
+ ### added
51
+ - desktop notifications (kde/linux)
52
+ - telegram notifications — episode downloaded, failed, run summary, daemon start/stop
53
+ - poster images in notifications
54
+
55
+ ---
56
+
57
+ ## [1.1.0]
58
+ ### added
59
+ - quality prompt on each download run
60
+ - pause downloads per anime while keeping history
61
+ - skips anime that already exist in your anilist list when adding manually
62
+
63
+ ### fixed
64
+ - session handling fix
65
+
66
+ ---
67
+
68
+ ## [1.0.4]
69
+ ### fixed
70
+ - error name fix
71
+
72
+ ---
73
+
74
+ ## [1.0.2]
75
+ ### fixed
76
+ - daemon bug fix
77
+ - major bug fix
78
+
79
+ ---
80
+
81
+ ## [1.0.0]
82
+ - initial release
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  # ani-auto
12
12
 
13
- automatically downloads new anime episodes based on your anilist watching list.
13
+ automatically downloads new anime episodes. works with your AniList watching list or a manual list — no account required.
14
14
 
15
15
  ---
16
16
 
@@ -26,28 +26,42 @@ npm install -g ani-auto
26
26
 
27
27
  ```bash
28
28
  ani-auto setup
29
- ani-auto check
29
+ ani-auto download
30
30
  ```
31
31
 
32
+ > **Tip:** connecting your AniList account lets the daemon know exactly when episodes air and download them automatically. without AniList, you can still add anime manually and the daemon will check every 2 hours.
33
+
32
34
  ---
33
35
 
34
36
  ## features
35
37
 
36
38
  **1. anilist sync**
37
- stays in sync with your watching list — only downloads episodes you haven't seen yet.
39
+ stays in sync with your watching list — only downloads episodes you haven't seen yet. supports multiple statuses (watching, paused, planning, repeating).
38
40
 
39
- **2. quality control**
40
- choose your preferred quality on each run or set a default.
41
+ **2. manual list**
42
+ no AniList account? add any anime manually with `ani-auto add "title"` and it will be tracked and downloaded just like AniList entries.
41
43
 
42
- **3. background daemon**
43
- set it and forget it checks for new episodes automatically on a schedule.
44
+ **3. quality control**
45
+ choose your preferred quality on each run or set a default. set a different quality per anime with `ani-auto quality`.
44
46
 
45
- **4. pause & resume**
46
- disable any anime at any time without losing your download history.
47
+ **4. smart daemon**
48
+ connects to your AniList to know exactly when each episode airs and downloads at the right time. falls back to checking every 2 hours for manual list entries or when AniList is not connected.
49
+
50
+ **5. offline recovery**
51
+ if your device loses internet, the daemon detects it instantly and resumes the moment connection is restored.
52
+
53
+ **6. episode range**
54
+ download a specific range of episodes in one run — useful for catching up on a series.
47
55
 
48
- **5. telegram notifications**
56
+ **7. telegram notifications**
49
57
  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`.
50
58
 
59
+ **8. pause & resume**
60
+ disable any anime at any time without losing your download history.
61
+
62
+ **9. retry failed episodes**
63
+ re-attempt all failed downloads in one command with `ani-auto retry`.
64
+
51
65
  ---
52
66
 
53
67
  ## commands
@@ -55,22 +69,22 @@ get notified on your phone when episodes download, fail, or the daemon starts. m
55
69
  ```
56
70
  Usage: ani-auto [options] [command]
57
71
 
58
- Automatic anime episode downloader — AniList-aware
59
-
60
72
  Options:
61
73
  -V, --version output the version number
62
74
  -h, --help display help for command
63
75
 
64
76
  Commands:
65
77
  setup Interactive first-time configuration wizard
66
- check [options] Run one download cycle immediately
78
+ download Run one download cycle immediately
67
79
  list Show all tracked anime and their episode download status
68
- add [options] <title> Search AniList and add an anime to the manual download list
69
- remove [title] Remove an anime from the manual list (interactive if no title given)
80
+ add [options] <title> Search AniList and add an anime to the manual list
81
+ remove [title] Remove an anime from the manual list
70
82
  enable Re-enable downloads for an anime
71
83
  disable Pause downloads for an anime (keeps history)
84
+ quality [title] Set per-anime quality override
85
+ retry Re-attempt all failed episode downloads
86
+ status [options] Show recent run log or per-anime episode breakdown
72
87
  notify [action] Enable or disable Telegram notifications (on, off)
73
- status [options] Show recent run log
74
88
  config [options] View, update, or open config file
75
89
  update Check for updates and install the latest version
76
90
  daemon [action] Manage the background daemon (install, start, stop, restart, status, logs)
@@ -85,7 +99,8 @@ Commands:
85
99
  ani-auto config --set-quality 1080p
86
100
  ani-auto config --set-concurrency 3
87
101
  ani-auto config --set-output ~/videos/anime
88
- ani-auto config --set-interval 60
102
+ ani-auto config --set-interval 120
103
+ ani-auto config --set-airing-buffer 5
89
104
  ani-auto config --open
90
105
  ```
91
106
 
@@ -102,6 +117,8 @@ ani-auto daemon install
102
117
  **other daemon commands:**
103
118
 
104
119
  ```bash
120
+ ani-auto daemon enable # enable daemon service
121
+ ani-auto daemon disable # disable daemon service
105
122
  ani-auto daemon start # start the service
106
123
  ani-auto daemon stop # stop the service
107
124
  ani-auto daemon restart # restart the service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Automatic anime episode downloader",
5
5
  "bin": {
6
6
  "ani-auto": "./src/cli.js"
package/src/anilist.js CHANGED
@@ -4,8 +4,6 @@ const fetch = require('node-fetch');
4
4
 
5
5
  const ANILIST_GQL = 'https://graphql.anilist.co';
6
6
 
7
- // ── Queries ────────────────────────────────────────────────────────────────
8
-
9
7
  const MEDIA_LIST_QUERY = `
10
8
  query ($userName: String, $status: MediaListStatus) {
11
9
  MediaListCollection(userName: $userName, type: ANIME, status: $status) {
@@ -45,30 +43,23 @@ query ($search: String) {
45
43
  }
46
44
  }`;
47
45
 
48
- // ── Helpers ────────────────────────────────────────────────────────────────
49
-
50
46
  async function gqlRequest(query, variables = {}, token = null) {
51
47
  const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
52
48
  if (token) headers['Authorization'] = `Bearer ${token}`;
53
-
54
49
  const res = await fetch(ANILIST_GQL, {
55
50
  method: 'POST',
56
51
  headers,
57
52
  body: JSON.stringify({ query, variables }),
58
53
  });
59
-
60
54
  if (!res.ok) {
61
55
  const text = await res.text();
62
56
  throw new Error(`AniList API error ${res.status}: ${text}`);
63
57
  }
64
-
65
58
  const json = await res.json();
66
59
  if (json.errors) throw new Error(`AniList GQL error: ${json.errors[0].message}`);
67
60
  return json.data;
68
61
  }
69
62
 
70
- // ── Public API ─────────────────────────────────────────────────────────────
71
-
72
63
  async function getViewer(token) {
73
64
  const data = await gqlRequest(VIEWER_QUERY, {}, token);
74
65
  return data.Viewer;
@@ -77,7 +68,7 @@ async function getViewer(token) {
77
68
  async function getUserList(userName, statuses, token = null) {
78
69
  const results = [];
79
70
  for (const status of statuses) {
80
- const data = await gqlRequest(MEDIA_LIST_QUERY, { userName, status }, token);
71
+ const data = await gqlRequest(MEDIA_LIST_QUERY, { userName, status }, token);
81
72
  const lists = data?.MediaListCollection?.lists || [];
82
73
  for (const list of lists) {
83
74
  for (const entry of list.entries) {
@@ -100,10 +91,6 @@ async function getUserList(userName, statuses, token = null) {
100
91
  return results;
101
92
  }
102
93
 
103
- /**
104
- * Search AniList for anime by title.
105
- * Returns up to 8 results with id, titles, episodes, status, year.
106
- */
107
94
  async function searchAnime(query, token = null) {
108
95
  const data = await gqlRequest(SEARCH_QUERY, { search: query }, token);
109
96
  return (data?.Page?.media || []).map(m => ({
@@ -113,10 +100,10 @@ async function searchAnime(query, token = null) {
113
100
  english: m.title.english,
114
101
  native: m.title.native,
115
102
  },
116
- episodes: m.episodes,
117
- status: m.status,
118
- year: m.seasonYear,
119
- format: m.format,
103
+ episodes: m.episodes,
104
+ status: m.status,
105
+ year: m.seasonYear,
106
+ format: m.format,
120
107
  }));
121
108
  }
122
109
 
@@ -124,9 +111,4 @@ function pickTitle(titles) {
124
111
  return titles.english || titles.romaji || titles.native;
125
112
  }
126
113
 
127
- module.exports = {
128
- getViewer,
129
- getUserList,
130
- searchAnime,
131
- pickTitle,
132
- };
114
+ module.exports = { getViewer, getUserList, searchAnime, pickTitle };
package/src/cli.js CHANGED
@@ -681,7 +681,7 @@ program
681
681
 
682
682
  program
683
683
  .command('daemon [action]')
684
- .description('Manage the background daemon (install, start, stop, restart, status, logs)')
684
+ .description('Manage the background daemon (install, start, stop, restart, status, logs, enable, disable)')
685
685
  .action(async (action) => {
686
686
  const { spawnSync, spawn } = require('child_process');
687
687
  const SERVICE = 'ani-auto';
@@ -749,6 +749,9 @@ program
749
749
  break;
750
750
  }
751
751
  case 'status': {
752
+ const config = loadConfig();
753
+ const enabled = config.daemonEnabled !== false;
754
+ console.log(`\n Daemon downloads: ${enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
752
755
  const r = run(`systemctl --user status ${SERVICE}`);
753
756
  console.log(r.out || r.err);
754
757
  break;
@@ -758,8 +761,38 @@ program
758
761
  spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
759
762
  break;
760
763
  }
764
+
765
+ // ── New: enable / disable daemon ──────────────────────────────
766
+ case 'enable': {
767
+ banner();
768
+ updateConfig({ daemonEnabled: true });
769
+ console.log(chalk.green('\n Done Daemon enabled.'));
770
+ console.log(chalk.gray(' Starting systemd service...\n'));
771
+ const r = run(`systemctl --user start ${SERVICE}`);
772
+ if (r.code === 0) {
773
+ console.log(chalk.green(' Daemon is now running.\n'));
774
+ } else {
775
+ console.log(chalk.yellow(' Could not start systemd service (is it installed?).'));
776
+ console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon install')} first, or start manually.\n`));
777
+ }
778
+ break;
779
+ }
780
+ case 'disable': {
781
+ banner();
782
+ updateConfig({ daemonEnabled: false });
783
+ console.log(chalk.yellow('\n Done Daemon disabled.'));
784
+ console.log(chalk.gray(' Stopping systemd service...\n'));
785
+ run(`systemctl --user stop ${SERVICE}`);
786
+ console.log(chalk.gray(
787
+ ' The service is stopped and will exit immediately on any future\n' +
788
+ ' startup (boot, network restore, manual start) until re-enabled.\n'
789
+ ));
790
+ console.log(` To re-enable: ${chalk.bold('ani-auto daemon enable')}\n`);
791
+ break;
792
+ }
793
+
761
794
  default:
762
- console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
795
+ console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs, enable, disable\n`));
763
796
  }
764
797
  });
765
798
 
package/src/config.js CHANGED
@@ -22,7 +22,8 @@ const DEFAULTS = {
22
22
  maxEpisodesPerRun: 50,
23
23
  telegramChatId: null,
24
24
  telegramNotify: true,
25
- airingBufferMinutes: 5, // minutes after air time before checking AnimePahe
25
+ airingBufferMinutes: 5,
26
+ daemonEnabled: true, // set to false via `ani-auto daemon disable`
26
27
  };
27
28
 
28
29
  function ensureConfigDir() {
package/src/daemon.js CHANGED
@@ -85,8 +85,24 @@ async function scheduleSmartChecks() {
85
85
  // ── Startup ────────────────────────────────────────────────────────────────
86
86
 
87
87
  async function startDaemon() {
88
- const config = loadConfig();
89
- const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
88
+ const config = loadConfig();
89
+
90
+ // ── Enabled gate ──────────────────────────────────────────────────────
91
+ if (config.daemonEnabled === false) {
92
+ console.log(chalk.yellow(`
93
+ ┌─────────────────────────────────────────────────┐
94
+ │ ani-auto daemon is DISABLED │
95
+ │ │
96
+ │ The daemon will not run until you enable it. │
97
+ │ To re-enable, run: │
98
+ │ │
99
+ │ ani-auto daemon enable │
100
+ └─────────────────────────────────────────────────┘
101
+ `));
102
+ process.exit(0);
103
+ }
104
+
105
+ const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
90
106
 
91
107
  console.log(chalk.bold.red(`
92
108
  █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
package/src/db.js CHANGED
@@ -1,10 +1,5 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * db.js — SQLite state layer using sql.js (pure WASM, no native compilation).
5
- * Drop-in replacement for the better-sqlite3 version.
6
- * Compatible with Node v25+ without any C++ compilation.
7
- */
8
3
 
9
4
  const fs = require('fs');
10
5
  const path = require('path');
@@ -15,9 +10,6 @@ const DB_PATH = path.join(DB_DIR, 'state.db');
15
10
 
16
11
  let _db = null;
17
12
 
18
- /**
19
- * Async init — call once at startup before any DB operations.
20
- */
21
13
  async function initDb() {
22
14
  if (_db) return _db;
23
15
 
@@ -75,8 +67,7 @@ function migrate(db) {
75
67
  UNIQUE(anime_id, episode_num)
76
68
  )
77
69
  `);
78
- // Add confirmed_site columns if upgrading from older DB
79
- try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
70
+ try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
80
71
  try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
81
72
  try { db.run('ALTER TABLE anime ADD COLUMN anilist_title TEXT'); } catch(e) {}
82
73
  try { db.run('ALTER TABLE anime ADD COLUMN poster_url TEXT'); } catch(e) {}
@@ -95,7 +86,6 @@ function migrate(db) {
95
86
  `);
96
87
  }
97
88
 
98
- // ── Helpers ────────────────────────────────────────────────────────────────
99
89
 
100
90
  function _assertReady() {
101
91
  if (!_db) throw new Error('DB not initialised — call initDb() first at startup.');
@@ -126,7 +116,6 @@ function _run(sql, params = []) {
126
116
  _save();
127
117
  }
128
118
 
129
- // ── Anime ──────────────────────────────────────────────────────────────────
130
119
 
131
120
  function upsertAnime({ title, siteId, anilistId, source, quality, language }) {
132
121
  _run(`
@@ -167,7 +156,6 @@ function removeAnime(siteId) {
167
156
  _run('DELETE FROM anime WHERE id = ?', [row.id]);
168
157
  }
169
158
 
170
- // ── Episodes ───────────────────────────────────────────────────────────────
171
159
 
172
160
  function isEpisodeDownloaded(animeDbId, episodeNum) {
173
161
  return !!_get(
@@ -207,7 +195,6 @@ function getEpisodeStats(animeDbId) {
207
195
  };
208
196
  }
209
197
 
210
- // ── Run log ────────────────────────────────────────────────────────────────
211
198
 
212
199
  function logRun({ downloaded, skipped, failed, notes }) {
213
200
  _run('INSERT INTO run_log (downloaded, skipped, failed, notes) VALUES (?, ?, ?, ?)',
package/src/engine.js CHANGED
@@ -13,7 +13,6 @@ const scraper = require('./scraper');
13
13
  const { version } = require('../package.json');
14
14
  const notify = require('./notify');
15
15
 
16
- // ── Logging ────────────────────────────────────────────────────────────────
17
16
 
18
17
  function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
19
18
  function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
@@ -21,7 +20,6 @@ function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${
21
20
  function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); }
22
21
  function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
23
22
 
24
- // ── Filename helpers ───────────────────────────────────────────────────────
25
23
 
26
24
  function buildFilename(title, episodeNum, season = null, displayEpisodeNum = null) {
27
25
  const s = season ? `Season ${season} ` : '';
@@ -45,7 +43,6 @@ function detectSeason(title) {
45
43
  return m ? parseInt(m[1], 10) : null;
46
44
  }
47
45
 
48
- // ── Watch list ─────────────────────────────────────────────────────────────
49
46
 
50
47
  async function resolveWatchList(config) {
51
48
  const items = [];
@@ -103,7 +100,6 @@ async function resolveWatchList(config) {
103
100
  });
104
101
  }
105
102
 
106
- // ── Anime selector prompt ──────────────────────────────────────────────────
107
103
 
108
104
  async function promptAnimeSelection(watchList) {
109
105
  console.log(chalk.bold('\n Available anime:'));
@@ -134,7 +130,6 @@ async function promptAnimeSelection(watchList) {
134
130
  return indices.map(i => watchList[i]);
135
131
  }
136
132
 
137
- // ── Per-anime download ─────────────────────────────────────────────────────
138
133
 
139
134
  async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
140
135
  // Per-anime quality: run override > DB override > workItem > global config
@@ -333,7 +328,6 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
333
328
  return results;
334
329
  }
335
330
 
336
- // ── Main cycle ─────────────────────────────────────────────────────────────
337
331
 
338
332
  async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
339
333
  await db.initDb();
package/src/network.js CHANGED
@@ -1,63 +1,36 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * network.js — Real-time network monitor using `ip monitor route`
5
- * Detects internet connectivity changes instantly via Linux routing events.
6
- * Falls back to polling if `ip` command is unavailable.
7
- */
8
-
9
3
  const { spawn } = require('child_process');
10
4
  const dns = require('dns');
11
5
 
12
- // Verify actual internet (not just interface up)
13
6
  function checkInternet() {
14
7
  return new Promise(resolve => {
15
8
  dns.lookup('graphql.anilist.co', err => resolve(!err));
16
9
  });
17
10
  }
18
11
 
19
- /**
20
- * Start monitoring network connectivity.
21
- * @param {object} opts
22
- * @param {function} opts.onOnline — called when internet is restored
23
- * @param {function} opts.onOffline — called when internet drops
24
- * @returns {function} stop — call to stop monitoring
25
- */
26
12
  function watchNetwork({ onOnline, onOffline } = {}) {
27
- let online = true;
28
- let debounce = null;
29
- let stopped = false;
30
- let monitor = null;
13
+ let online = true;
14
+ let debounce = null;
15
+ let stopped = false;
16
+ let monitor = null;
17
+ let pollTimer = null;
31
18
 
32
19
  const handleChange = async () => {
33
20
  if (stopped) return;
34
21
  clearTimeout(debounce);
35
- // Small debounce — routes can flap during reconnect
36
22
  debounce = setTimeout(async () => {
37
23
  const nowOnline = await checkInternet();
38
- if (nowOnline && !online) {
39
- online = true;
40
- onOnline?.();
41
- } else if (!nowOnline && online) {
42
- online = false;
43
- onOffline?.();
44
- }
24
+ if (nowOnline && !online) { online = true; onOnline?.(); }
25
+ else if (!nowOnline && online) { online = false; onOffline?.(); }
45
26
  }, 2000);
46
27
  };
47
28
 
48
- // Try `ip monitor route` first (Linux)
49
29
  try {
50
30
  monitor = spawn('ip', ['monitor', 'route'], { stdio: ['ignore', 'pipe', 'ignore'] });
51
31
  monitor.stdout.on('data', handleChange);
52
- monitor.on('error', () => startFallback());
32
+ monitor.on('error', () => { monitor = null; pollTimer = setInterval(handleChange, 10000); });
53
33
  } catch (e) {
54
- startFallback();
55
- }
56
-
57
- // Fallback: poll every 10 seconds if ip monitor unavailable
58
- let pollTimer = null;
59
- function startFallback() {
60
- monitor = null;
61
34
  pollTimer = setInterval(handleChange, 10000);
62
35
  }
63
36
 
@@ -69,18 +42,11 @@ function watchNetwork({ onOnline, onOffline } = {}) {
69
42
  };
70
43
  }
71
44
 
72
- /**
73
- * Wait until internet is available.
74
- * Resolves immediately if already online.
75
- * @returns {Promise<void>}
76
- */
77
45
  function waitForInternet() {
78
46
  return new Promise(resolve => {
79
47
  checkInternet().then(online => {
80
48
  if (online) return resolve();
81
- const stop = watchNetwork({
82
- onOnline: () => { stop(); resolve(); }
83
- });
49
+ const stop = watchNetwork({ onOnline: () => { stop(); resolve(); } });
84
50
  });
85
51
  });
86
52
  }
package/src/notify.js CHANGED
@@ -1,32 +1,26 @@
1
1
  'use strict';
2
2
 
3
3
  const { execSync } = require('child_process');
4
+ const fetch = require('node-fetch');
4
5
  const { loadConfig } = require('./config');
5
6
 
6
7
  const BOT_TOKEN = '8695132702:AAEFDoSL6nkdwDOoz5wmoVkmuhaYa4tNw0o';
7
8
 
8
- // ── Telegram helpers ───────────────────────────────────────────────────────
9
-
10
9
  async function sendTelegram(payload) {
11
10
  const config = loadConfig();
12
11
  const chatId = config.telegramChatId;
13
12
  if (!chatId || config.telegramNotify === false) return;
14
13
  try {
15
- const fetch = require('node-fetch');
16
- const isPhoto = !!payload.photo;
17
- const endpoint = isPhoto ? 'sendPhoto' : 'sendMessage';
14
+ const endpoint = payload.photo ? 'sendPhoto' : 'sendMessage';
18
15
  await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/${endpoint}`, {
19
16
  method: 'POST',
20
17
  headers: { 'Content-Type': 'application/json' },
21
18
  body: JSON.stringify({ chat_id: String(chatId), parse_mode: 'HTML', ...payload }),
19
+ timeout: 5000,
22
20
  });
23
- } catch (e) {
24
- // silently fail
25
- }
21
+ } catch (e) {}
26
22
  }
27
23
 
28
- // ── KDE desktop ────────────────────────────────────────────────────────────
29
-
30
24
  async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
31
25
  try {
32
26
  let icon = '';
@@ -34,7 +28,6 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
34
28
  const os = require('os');
35
29
  const path = require('path');
36
30
  const fs = require('fs');
37
- const fetch = require('node-fetch');
38
31
  const tmp = path.join(os.tmpdir(), 'ani-auto-poster.jpg');
39
32
  try {
40
33
  const res = await fetch(posterUrl);
@@ -42,25 +35,21 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
42
35
  icon = `-i "${tmp}"`;
43
36
  } catch (e) {}
44
37
  }
45
- execSync(`notify-send -u ${urgency} ${icon} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore' });
38
+ execSync(`notify-send -u ${urgency} ${icon} -a "ani-auto" "${title}" "${body}"`, { stdio: 'ignore', timeout: 5000 });
46
39
  } catch (e) {}
47
40
  }
48
41
 
49
- // ── Public notification functions ──────────────────────────────────────────
50
-
51
42
  async function notifyEpisode(animeTitle, episode, quality, posterUrl) {
52
43
  const caption =
53
44
  `<b>${animeTitle}</b>\n` +
54
45
  `<code>━━━━━━━━━━━━━━━</code>\n` +
55
46
  `✅ Episode ${episode} downloaded\n` +
56
47
  `🎞 Quality: ${quality || 'default'}`;
57
-
58
- if (posterUrl) {
59
- await sendTelegram({ photo: posterUrl, caption });
60
- } else {
61
- await sendTelegram({ text: `🎬 ${caption}` });
62
- }
63
- await sendDesktop(animeTitle, `Episode ${episode} downloaded`, 'normal', posterUrl);
48
+ const p1 = posterUrl
49
+ ? sendTelegram({ photo: posterUrl, caption })
50
+ : sendTelegram({ text: `🎬 ${caption}` });
51
+ const p2 = sendDesktop(animeTitle, `Episode ${episode} downloaded`, 'normal', posterUrl);
52
+ await Promise.allSettled([p1, p2]);
64
53
  }
65
54
 
66
55
  async function notifyFailed(animeTitle, episode, error) {
@@ -69,8 +58,10 @@ async function notifyFailed(animeTitle, episode, error) {
69
58
  `<code>━━━━━━━━━━━━━━━</code>\n` +
70
59
  `Episode ${episode} failed\n` +
71
60
  `<i>${error}</i>`;
72
- await sendTelegram({ text });
73
- await sendDesktop(animeTitle, `Episode ${episode} failed`, 'critical');
61
+ await Promise.allSettled([
62
+ sendTelegram({ text }),
63
+ sendDesktop(animeTitle, `Episode ${episode} failed`, 'critical')
64
+ ]);
74
65
  }
75
66
 
76
67
  async function notifySummary(totals) {
@@ -81,8 +72,10 @@ async function notifySummary(totals) {
81
72
  `✅ Downloaded: ${totals.downloaded}\n` +
82
73
  `⏭ Skipped: ${totals.skipped}\n` +
83
74
  `❌ Failed: ${totals.failed}`;
84
- await sendTelegram({ text });
85
- await sendDesktop('ani-auto run complete', `Downloaded: ${totals.downloaded} Failed: ${totals.failed}`);
75
+ await Promise.allSettled([
76
+ sendTelegram({ text }),
77
+ sendDesktop('ani-auto run complete', `Downloaded: ${totals.downloaded} Failed: ${totals.failed}`)
78
+ ]);
86
79
  }
87
80
 
88
81
  async function notifyDaemonStart(intervalMinutes, downloadingCount) {
@@ -93,22 +86,17 @@ async function notifyDaemonStart(intervalMinutes, downloadingCount) {
93
86
  `📦 Version: v${version}\n` +
94
87
  `📺 Downloading: ${downloadingCount} anime\n` +
95
88
  `⏱ Fallback check every ${intervalMinutes} minutes`;
96
- await sendTelegram({ text });
97
- await sendDesktop(
98
- `ani-auto v${version} started`,
99
- `Downloading ${downloadingCount} anime · every ${intervalMinutes} min`
100
- );
89
+ await Promise.allSettled([
90
+ sendTelegram({ text }),
91
+ sendDesktop(`ani-auto v${version} started`, `Downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
92
+ ]);
101
93
  }
102
94
 
103
95
  async function notifyDaemonStop() {
104
- await sendTelegram({ text: '🛑 <b>ani-auto daemon stopped</b>' });
105
- await sendDesktop('ani-auto daemon stopped', '');
96
+ await Promise.allSettled([
97
+ sendTelegram({ text: '🛑 <b>ani-auto daemon stopped</b>' }),
98
+ sendDesktop('ani-auto daemon stopped', '')
99
+ ]);
106
100
  }
107
101
 
108
- module.exports = {
109
- notifyEpisode,
110
- notifyFailed,
111
- notifySummary,
112
- notifyDaemonStart,
113
- notifyDaemonStop,
114
- };
102
+ module.exports = { notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
package/src/scraper.js CHANGED
@@ -1,24 +1,9 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * scraper.js
5
- *
6
- * Thin adapter over the k-anime-cli utils.
7
- * This is the ONLY file that touches the sister CLI project.
8
- *
9
- * Expected peer dependency layout:
10
- * ../k-anime/utils.js ← the existing CLI's utils
11
- *
12
- * If you keep ani-auto as a separate repo, set KANIME_UTILS_PATH
13
- * in your environment or config to point to utils.js.
14
- */
15
-
16
- const path = require('path');
3
+ const path = require('path');
17
4
  const fetch = require('node-fetch');
18
5
 
19
- // ── Resolve the sibling CLI utils ─────────────────────────────────────────
20
- const UTILS_PATH = process.env.KANIME_UTILS_PATH
21
- || path.resolve(__dirname, '../utils.js');
6
+ const UTILS_PATH = process.env.KANIME_UTILS_PATH || path.resolve(__dirname, '../utils.js');
22
7
 
23
8
  let _utils = null;
24
9
  function utils() {
@@ -26,24 +11,11 @@ function utils() {
26
11
  try {
27
12
  _utils = require(UTILS_PATH);
28
13
  } catch (e) {
29
- throw new Error(
30
- `Cannot find k-anime utils at "${UTILS_PATH}".\n` +
31
- `Set KANIME_UTILS_PATH env var to the correct path.\n` +
32
- `Original error: ${e.message}`
33
- );
14
+ throw new Error(`Cannot find utils at "${UTILS_PATH}". Set KANIME_UTILS_PATH env var.\n${e.message}`);
34
15
  }
35
16
  return _utils;
36
17
  }
37
18
 
38
- // ── Public surface ────────────────────────────────────────────────────────
39
-
40
- /**
41
- * Search the site for an anime by title.
42
- * Returns the best match or null.
43
- *
44
- * @param {string} title
45
- * @returns {Promise<SiteAnime|null>}
46
- */
47
19
  async function searchAnime(title) {
48
20
  const results = await searchAll(title);
49
21
  if (!results.length) return null;
@@ -52,24 +24,20 @@ async function searchAnime(title) {
52
24
  return exact || results[0];
53
25
  }
54
26
 
55
- /** Returns all search results (up to API limit) for user confirmation. */
56
27
  async function searchAll(title) {
57
28
  const { SEARCH_API, USER_AGENT } = utils();
58
- // Strip special characters that confuse the search API
59
- const cleaned = title.replace(/[-–—~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim();
60
- const url = `${SEARCH_API}?q=${encodeURIComponent(cleaned)}`;
61
- const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
62
- if (!res.ok) return [];
63
- const data = await res.json();
64
- return data.results || [];
29
+ const cleaned = title.replace(/[-\u2013\u2014~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
30
+ const trySearch = async (q) => {
31
+ const res = await fetch(`${SEARCH_API}?q=${encodeURIComponent(q)}`, { headers: { 'User-Agent': USER_AGENT } });
32
+ if (!res.ok) throw new Error(`API returned ${res.status}`);
33
+ const data = await res.json();
34
+ return data.results || [];
35
+ };
36
+ let results = await trySearch(cleaned);
37
+ if (!results.length && cleaned !== title.toLowerCase()) results = await trySearch(title);
38
+ return results;
65
39
  }
66
40
 
67
- /**
68
- * Fetch episode list for a given site anime session id.
69
- *
70
- * @param {string} animeId (session field from searchAnime result)
71
- * @returns {Promise<SiteEpisode[]>}
72
- */
73
41
  async function getEpisodes(animeId) {
74
42
  const { INFO_API, USER_AGENT } = utils();
75
43
  const res = await fetch(`${INFO_API}?id=${animeId}`, { headers: { 'User-Agent': USER_AGENT } });
@@ -78,13 +46,6 @@ async function getEpisodes(animeId) {
78
46
  return data.results?.episodes || [];
79
47
  }
80
48
 
81
- /**
82
- * Fetch download links for a single episode.
83
- *
84
- * @param {string} animeId
85
- * @param {string} episodeId (session field from episode object)
86
- * @returns {Promise<DownloadLink[]>}
87
- */
88
49
  async function getDownloadLinks(animeId, episodeId) {
89
50
  const { DOWNLOAD_API, USER_AGENT } = utils();
90
51
  const res = await fetch(
@@ -96,15 +57,6 @@ async function getDownloadLinks(animeId, episodeId) {
96
57
  return data.results?.downloadLinks || [];
97
58
  }
98
59
 
99
- /**
100
- * Pick the best download link given quality + language preferences.
101
- * Falls back gracefully: exact → quality-only → first available.
102
- *
103
- * @param {DownloadLink[]} links
104
- * @param {string} quality e.g. '1080p'
105
- * @param {string} language e.g. 'sub'
106
- * @returns {DownloadLink|null}
107
- */
108
60
  function pickLink(links, quality, language) {
109
61
  if (!links.length) return null;
110
62
  return (
@@ -114,26 +66,9 @@ function pickLink(links, quality, language) {
114
66
  );
115
67
  }
116
68
 
117
- /**
118
- * Download a single episode file.
119
- * Delegates entirely to the existing CLI's downloadFile().
120
- *
121
- * @param {string} url
122
- * @param {string} filename
123
- * @param {string} outputDir
124
- * @param {*} multiBar cliProgress.MultiBar instance (or null for silent)
125
- * @param {number} episodeNum
126
- */
127
69
  async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
128
70
  const { downloadFile } = utils();
129
71
  await downloadFile(url, filename, outputDir, multiBar, episodeNum);
130
72
  }
131
73
 
132
- module.exports = {
133
- searchAnime,
134
- searchAll,
135
- getEpisodes,
136
- getDownloadLinks,
137
- pickLink,
138
- downloadEpisode,
139
- };
74
+ module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };