ani-auto 1.3.2 → 1.4.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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm install *)",
5
+ "Bash(npm cache *)"
6
+ ]
7
+ },
8
+ "$version": 3
9
+ }
package/README.md CHANGED
@@ -117,6 +117,8 @@ ani-auto daemon install
117
117
  **other daemon commands:**
118
118
 
119
119
  ```bash
120
+ ani-auto daemon enable # enable daemon service
121
+ ani-auto daemon disable # disable daemon service
120
122
  ani-auto daemon start # start the service
121
123
  ani-auto daemon stop # stop the service
122
124
  ani-auto daemon restart # restart the service
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.3.2",
4
- "description": "Automatic anime episode downloader",
3
+ "version": "1.4.1",
4
+ "description": "automatic anime episode downloader",
5
5
  "bin": {
6
- "ani-auto": "./src/cli.js"
6
+ "ani-auto": "src/cli.js"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "node src/daemon.js",
@@ -17,6 +17,9 @@
17
17
  "inquirer": "^8.2.6",
18
18
  "node-fetch": "^2.7.0",
19
19
  "ora": "^5.4.1",
20
+ "puppeteer": "^24.40.0",
21
+ "puppeteer-extra": "^3.3.6",
22
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
20
23
  "sql.js": "^1.12.0"
21
24
  }
22
25
  }
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';
@@ -731,6 +731,11 @@ program
731
731
  break;
732
732
  }
733
733
  case 'start': {
734
+ const cfgStart = loadConfig();
735
+ if (cfgStart.daemonEnabled === false) {
736
+ console.log(chalk.red('\n ✖ daemon is not enabled — run ani-auto daemon enable first\n'));
737
+ break;
738
+ }
734
739
  const r = run(`systemctl --user start ${SERVICE}`);
735
740
  if (r.code === 0) console.log(chalk.green('\n Done Daemon started.\n'));
736
741
  else console.log(chalk.red(`\n Failed to start: ${r.err}\n Try: ani-auto daemon install\n`));
@@ -743,12 +748,20 @@ program
743
748
  break;
744
749
  }
745
750
  case 'restart': {
751
+ const cfgRestart = loadConfig();
752
+ if (cfgRestart.daemonEnabled === false) {
753
+ console.log(chalk.red('\n ✖ daemon is not enabled — run ani-auto daemon enable first\n'));
754
+ break;
755
+ }
746
756
  const r = run(`systemctl --user restart ${SERVICE}`);
747
757
  if (r.code === 0) console.log(chalk.green('\n Done Daemon restarted.\n'));
748
758
  else console.log(chalk.red(`\n Failed to restart: ${r.err}\n`));
749
759
  break;
750
760
  }
751
761
  case 'status': {
762
+ const config = loadConfig();
763
+ const enabled = config.daemonEnabled !== false;
764
+ console.log(`\n Daemon downloads: ${enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
752
765
  const r = run(`systemctl --user status ${SERVICE}`);
753
766
  console.log(r.out || r.err);
754
767
  break;
@@ -758,8 +771,38 @@ program
758
771
  spawn('journalctl', ['--user', '-u', SERVICE, '-f'], { stdio: 'inherit' });
759
772
  break;
760
773
  }
774
+
775
+ // ── New: enable / disable daemon ──────────────────────────────
776
+ case 'enable': {
777
+ banner();
778
+ updateConfig({ daemonEnabled: true });
779
+ console.log(chalk.green('\n Done Daemon enabled.'));
780
+ console.log(chalk.gray(' Starting systemd service...\n'));
781
+ const re = run(`systemctl --user start ${SERVICE}`);
782
+ if (re.code === 0) {
783
+ console.log(chalk.green(' Daemon is now running.\n'));
784
+ } else {
785
+ console.log(chalk.yellow(' Could not start systemd service (is it installed?).'));
786
+ console.log(chalk.gray(` Run ${chalk.bold('ani-auto daemon install')} first, or start manually.\n`));
787
+ }
788
+ break;
789
+ }
790
+ case 'disable': {
791
+ banner();
792
+ updateConfig({ daemonEnabled: false });
793
+ console.log(chalk.yellow('\n Done Daemon disabled.'));
794
+ console.log(chalk.gray(' Stopping systemd service...\n'));
795
+ run(`systemctl --user stop ${SERVICE}`);
796
+ console.log(chalk.gray(
797
+ ' The service is stopped and will exit immediately on any future\n' +
798
+ ' startup (boot, network restore, manual start) until re-enabled.\n'
799
+ ));
800
+ console.log(` To re-enable: ${chalk.bold('ani-auto daemon enable')}\n`);
801
+ break;
802
+ }
803
+
761
804
  default:
762
- console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs\n`));
805
+ console.log(chalk.yellow(`\n Unknown action "${action}". Use: install, start, stop, restart, status, logs, enable, disable\n`));
763
806
  }
764
807
  });
765
808
 
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const puppeteer = require('puppeteer-extra');
4
+ const StealthPlugin = require('puppeteer-extra-plugin-stealth');
5
+ puppeteer.use(StealthPlugin());
6
+
7
+ const chalk = require('chalk');
8
+
9
+ let cachedCookie = null;
10
+ let cachedAt = null;
11
+ const COOKIE_TTL_MS = 30 * 60 * 1000; // 30 minutes
12
+
13
+ function log(msg) {
14
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
15
+ console.log(`${chalk.gray(ts)} ${chalk.magenta('☁')} ${msg}`);
16
+ }
17
+
18
+ async function solveCloudflare(downloadUrl) {
19
+ log('Launching browser to solve Cloudflare challenge...');
20
+ const browser = await puppeteer.launch({
21
+ headless: 'new',
22
+ args: [
23
+ '--no-sandbox',
24
+ '--disable-setuid-sandbox',
25
+ '--disable-blink-features=AutomationControlled',
26
+ ],
27
+ });
28
+ const page = await browser.newPage();
29
+
30
+ await page.setViewport({ width: 1920, height: 1080 });
31
+ await page.setUserAgent(
32
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
33
+ );
34
+
35
+ try {
36
+ // Navigate to the download URL — Cloudflare should intercept with challenge
37
+ log(`Navigating to ${new URL(downloadUrl).hostname}...`);
38
+ await page.goto(downloadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
39
+
40
+ // Poll for cf_clearance cookie — Turnstile auto-solves in ~10-30s
41
+ const challengeTimeout = 60000;
42
+ const pollInterval = 2000;
43
+ let cookie = null;
44
+ const startTime = Date.now();
45
+
46
+ while (Date.now() - startTime < challengeTimeout) {
47
+ const cookies = await page.cookies();
48
+ cookie = cookies.find(c => c.name === 'cf_clearance');
49
+ if (cookie) {
50
+ log('cf_clearance cookie found!');
51
+ break;
52
+ }
53
+ log('Waiting for Turnstile to solve...');
54
+ await new Promise(r => setTimeout(r, pollInterval));
55
+ }
56
+
57
+ if (!cookie) {
58
+ // Take a screenshot for debugging
59
+ const ssPath = '/tmp/cf_debug.png';
60
+ await page.screenshot({ path: ssPath });
61
+ log(`Screenshot saved to ${ssPath}`);
62
+ throw new Error('cf_clearance cookie not found — challenge may have failed');
63
+ }
64
+
65
+ cachedCookie = cookie.value;
66
+ cachedAt = Date.now();
67
+ log('Got cf_clearance cookie (valid ~30 min).');
68
+ return cookie.value;
69
+ } finally {
70
+ await browser.close();
71
+ }
72
+ }
73
+
74
+ async function getCachedCookie(url) {
75
+ // Check if cached cookie is still valid
76
+ if (cachedCookie && cachedAt && (Date.now() - cachedAt) < COOKIE_TTL_MS) {
77
+ log('Using cached cf_clearance cookie.');
78
+ return cachedCookie;
79
+ }
80
+ // Expired or missing — solve again
81
+ if (cachedCookie) log('Cookie expired, re-solving Cloudflare challenge...');
82
+ return solveCloudflare(url);
83
+ }
84
+
85
+ function invalidateCache() {
86
+ cachedCookie = null;
87
+ cachedAt = null;
88
+ log('Cache invalidated.');
89
+ }
90
+
91
+ module.exports = { getCachedCookie, invalidateCache, solveCloudflare };
package/src/config.js CHANGED
@@ -13,16 +13,18 @@ const DEFAULTS = {
13
13
  watchStatuses: ['CURRENT'],
14
14
  manualList: [],
15
15
  quality: '1080p',
16
- language: 'sub',
16
+ language: 'sub',
17
17
  concurrency: 2,
18
18
  outputDir: path.join(os.homedir(), 'anime'),
19
19
  maxRetries: 3,
20
20
  daemonIntervalMinutes: 120,
21
21
  skipExisting: true,
22
22
  maxEpisodesPerRun: 50,
23
- telegramChatId: null,
24
- telegramNotify: true,
25
- airingBufferMinutes: 5, // minutes after air time before checking AnimePahe
23
+ telegramChatId: null,
24
+ telegramNotify: true,
25
+ airingBufferMinutes: 5,
26
+ daemonEnabled: true, // set to false via `ani-auto daemon disable`
27
+ apiBaseUrl: 'https://aor-rex-anikuro-api.hf.space',
26
28
  };
27
29
 
28
30
  function ensureConfigDir() {
package/src/daemon.js CHANGED
@@ -13,7 +13,6 @@ let running = false;
13
13
 
14
14
  function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
15
15
 
16
- function log(msg) { console.log(`${chalk.gray(ts())} ${msg}`); }
17
16
  function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
18
17
  function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${msg}`); }
19
18
 
@@ -85,8 +84,18 @@ async function scheduleSmartChecks() {
85
84
  // ── Startup ────────────────────────────────────────────────────────────────
86
85
 
87
86
  async function startDaemon() {
88
- const config = loadConfig();
89
- const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
87
+ const config = loadConfig();
88
+
89
+ // ── Enabled gate ──────────────────────────────────────────────────────
90
+ if (config.daemonEnabled === false) {
91
+ process.stderr.write(chalk.red(`\n ✖ [${ts()}] error: daemon is disabled\n`));
92
+ process.stderr.write(chalk.gray(` startup blocked — daemon was disabled via 'ani-auto daemon disable'\n`));
93
+ process.stderr.write(chalk.gray(` this applies to all startups: boot, network restore, and manual start\n`));
94
+ process.stderr.write(`\n to re-enable: ${chalk.bold.cyan('ani-auto daemon enable')}\n\n`);
95
+ process.exit(1);
96
+ }
97
+
98
+ const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
90
99
 
91
100
  console.log(chalk.bold.red(`
92
101
  █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
@@ -127,7 +136,6 @@ async function startDaemon() {
127
136
  await scheduleSmartChecks(); // Refresh smart timers each interval
128
137
  }, intervalMs);
129
138
 
130
-
131
139
  console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
132
140
 
133
141
  process.on('SIGINT', async () => {
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
@@ -151,24 +151,41 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
151
151
  return { downloaded: 0, skipped: 0, failed: 0 };
152
152
  }
153
153
 
154
- if (existingRow?.confirmed_site_title) {
154
+ // Priority 1: Match by AniList ID (exact, most reliable)
155
+ if (workItem.anilistId) {
156
+ const idMatch = siteResults.find(r => r.anilistId === String(workItem.anilistId));
157
+ if (idMatch) {
158
+ siteAnime = idMatch;
159
+ info(`[${workItem.title}] Matched by AniList ID: ${chalk.bold(siteAnime.title)} (anilist:${siteAnime.anilistId})`);
160
+ db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
161
+ } else {
162
+ warn(`[${workItem.title}] AniList ID ${workItem.anilistId} not found in search results — falling back to title match.`);
163
+ }
164
+ }
165
+
166
+ // Priority 2: Saved site match
167
+ if (!siteAnime && existingRow?.confirmed_site_title) {
155
168
  const savedMatch = siteResults.find(r =>
156
169
  r.title?.toLowerCase() === existingRow.confirmed_site_title.toLowerCase()
157
170
  ) || siteResults[0];
158
171
  siteAnime = savedMatch;
159
172
  info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
160
173
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
161
- } else if (auto) {
174
+ }
175
+
176
+ // Priority 3: Auto mode — title matching fallback (no AniList ID, no saved match)
177
+ if (!siteAnime && auto) {
162
178
  const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
163
179
  const normTitle = normalize(workItem.title);
164
- const season = detectSeason(workItem.title);
165
180
  const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
166
- || (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
167
181
  || siteResults[0];
168
182
  siteAnime = bestMatch;
169
183
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
170
184
  info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
171
- } else {
185
+ }
186
+
187
+ // Priority 4: Interactive mode — user picks
188
+ if (!siteAnime) {
172
189
  const topResults = siteResults.slice(0, 5);
173
190
  const { pick } = await inquirer.prompt([{
174
191
  type: 'list',
@@ -176,7 +193,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
176
193
  message: `Site results for "${workItem.title}" — select correct match:`,
177
194
  choices: [
178
195
  ...topResults.map(r => ({
179
- name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}`,
196
+ name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}${r.anilistId ? chalk.gray(` [AL:${r.anilistId}]`) : ''}`,
180
197
  value: r,
181
198
  })),
182
199
  { name: chalk.yellow('Skip this anime'), value: null },
@@ -275,6 +292,9 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
275
292
 
276
293
  info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
277
294
 
295
+ const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
296
+ await notify.notifyDownloadStart(cleanTitle, toDownload.length, q, posterUrl);
297
+
278
298
  const queue = [...toDownload];
279
299
  const results = { downloaded: 0, skipped: 0, failed: 0 };
280
300
  const concurrency = Math.min(config.concurrency || 2, queue.length);
@@ -300,7 +320,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
300
320
  while (!success && attempts < MAX_RETRIES) {
301
321
  attempts++;
302
322
  try {
303
- const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
323
+ const links = await scraper.getDownloadLinks(siteAnime.session, ep.session, q);
304
324
  const link = scraper.pickLink(links, q, l);
305
325
  if (!link?.mp4Url) throw new Error('No suitable download link found');
306
326
 
@@ -400,4 +420,4 @@ async function runCycle({ auto = false, quality = null, language = null, range =
400
420
 
401
421
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
402
422
 
403
- module.exports = { runCycle, resolveWatchList };
423
+ module.exports = { runCycle, resolveWatchList };
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,35 +35,52 @@ 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 ──────────────────────────────────────────
42
+ async function notifyDownloadStart(animeTitle, episodeCount, quality, posterUrl) {
43
+ const caption =
44
+ `<b>${animeTitle}</b>\n` +
45
+ `<code>━━━━━━━━━━━━━━━</code>\n` +
46
+ `▶ download started\n` +
47
+ `📥 episodes: ${episodeCount}\n` +
48
+ `🎞 quality: ${quality || 'default'}`;
49
+ const p1 = posterUrl
50
+ ? sendTelegram({ photo: posterUrl, caption })
51
+ : sendTelegram({ text: caption });
52
+ const p2 = sendDesktop(
53
+ animeTitle,
54
+ `downloading ${episodeCount} episode${episodeCount !== 1 ? 's' : ''} · ${quality || 'default'}`,
55
+ 'normal',
56
+ posterUrl
57
+ );
58
+ await Promise.allSettled([p1, p2]);
59
+ }
50
60
 
51
61
  async function notifyEpisode(animeTitle, episode, quality, posterUrl) {
52
62
  const caption =
53
63
  `<b>${animeTitle}</b>\n` +
54
64
  `<code>━━━━━━━━━━━━━━━</code>\n` +
55
- `✅ Episode ${episode} downloaded\n` +
56
- `🎞 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);
65
+ `✅ episode ${episode} downloaded\n` +
66
+ `🎞 quality: ${quality || 'default'}`;
67
+ const p1 = posterUrl
68
+ ? sendTelegram({ photo: posterUrl, caption })
69
+ : sendTelegram({ text: `🎬 ${caption}` });
70
+ const p2 = sendDesktop(animeTitle, `episode ${episode} downloaded`, 'normal', posterUrl);
71
+ await Promise.allSettled([p1, p2]);
64
72
  }
65
73
 
66
74
  async function notifyFailed(animeTitle, episode, error) {
67
75
  const text =
68
76
  `❌ <b>${animeTitle}</b>\n` +
69
77
  `<code>━━━━━━━━━━━━━━━</code>\n` +
70
- `Episode ${episode} failed\n` +
78
+ `episode ${episode} failed\n` +
71
79
  `<i>${error}</i>`;
72
- await sendTelegram({ text });
73
- await sendDesktop(animeTitle, `Episode ${episode} failed`, 'critical');
80
+ await Promise.allSettled([
81
+ sendTelegram({ text }),
82
+ sendDesktop(animeTitle, `episode ${episode} failed`, 'critical')
83
+ ]);
74
84
  }
75
85
 
76
86
  async function notifySummary(totals) {
@@ -78,11 +88,13 @@ async function notifySummary(totals) {
78
88
  const text =
79
89
  `📊 <b>ani-auto run complete</b>\n` +
80
90
  `<code>━━━━━━━━━━━━━━━</code>\n` +
81
- `✅ Downloaded: ${totals.downloaded}\n` +
82
- `⏭ Skipped: ${totals.skipped}\n` +
83
- `❌ Failed: ${totals.failed}`;
84
- await sendTelegram({ text });
85
- await sendDesktop('ani-auto run complete', `Downloaded: ${totals.downloaded} Failed: ${totals.failed}`);
91
+ `✅ downloaded: ${totals.downloaded}\n` +
92
+ `⏭ skipped: ${totals.skipped}\n` +
93
+ `❌ failed: ${totals.failed}`;
94
+ await Promise.allSettled([
95
+ sendTelegram({ text }),
96
+ sendDesktop('ani-auto run complete', `downloaded: ${totals.downloaded} failed: ${totals.failed}`)
97
+ ]);
86
98
  }
87
99
 
88
100
  async function notifyDaemonStart(intervalMinutes, downloadingCount) {
@@ -90,25 +102,20 @@ async function notifyDaemonStart(intervalMinutes, downloadingCount) {
90
102
  const text =
91
103
  `🚀 <b>ani-auto daemon started</b>\n` +
92
104
  `<code>━━━━━━━━━━━━━━━</code>\n` +
93
- `📦 Version: v${version}\n` +
94
- `📺 Downloading: ${downloadingCount} anime\n` +
95
- `⏱ 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
- );
105
+ `📦 version: v${version}\n` +
106
+ `📺 downloading: ${downloadingCount} anime\n` +
107
+ `⏱ fallback check every ${intervalMinutes} minutes`;
108
+ await Promise.allSettled([
109
+ sendTelegram({ text }),
110
+ sendDesktop(`ani-auto v${version} started`, `downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
111
+ ]);
101
112
  }
102
113
 
103
114
  async function notifyDaemonStop() {
104
- await sendTelegram({ text: '🛑 <b>ani-auto daemon stopped</b>' });
105
- await sendDesktop('ani-auto daemon stopped', '');
115
+ await Promise.allSettled([
116
+ sendTelegram({ text: '🛑 <b>ani-auto daemon stopped</b>' }),
117
+ sendDesktop('ani-auto daemon stopped', '')
118
+ ]);
106
119
  }
107
120
 
108
- module.exports = {
109
- notifyEpisode,
110
- notifyFailed,
111
- notifySummary,
112
- notifyDaemonStart,
113
- notifyDaemonStop,
114
- };
121
+ module.exports = { notifyDownloadStart, notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
package/src/scraper.js CHANGED
@@ -1,49 +1,10 @@
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');
22
-
23
- let _utils = null;
24
- function utils() {
25
- if (_utils) return _utils;
26
- try {
27
- _utils = require(UTILS_PATH);
28
- } 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
- );
34
- }
35
- return _utils;
36
- }
37
-
38
- // ── Public surface ────────────────────────────────────────────────────────
6
+ const { SEARCH_API, RELEASES_API, USER_AGENT, downloadFile } = require('../utils');
39
7
 
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
8
  async function searchAnime(title) {
48
9
  const results = await searchAll(title);
49
10
  if (!results.length) return null;
@@ -52,88 +13,114 @@ async function searchAnime(title) {
52
13
  return exact || results[0];
53
14
  }
54
15
 
55
- /** Returns all search results (up to API limit) for user confirmation. */
56
16
  async function searchAll(title) {
57
- 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 || [];
17
+ const cleaned = title.replace(/[-\u2013\u2014~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
18
+ const trySearch = async (q) => {
19
+ const res = await fetch(`${SEARCH_API}?q=${encodeURIComponent(q)}`, { headers: { 'User-Agent': USER_AGENT } });
20
+ if (!res.ok) throw new Error(`Search API returned ${res.status}`);
21
+ const data = await res.json();
22
+ const items = Array.isArray(data)
23
+ ? data
24
+ : (Array.isArray(data?.data) ? data.data : []);
25
+
26
+ const results = items.map(item => ({
27
+ ...item,
28
+ session: item.session || item.id,
29
+ title: item.title,
30
+ year: item.year,
31
+ episodes: item.episodes,
32
+ poster: item.poster,
33
+ anilistId: null, // populated below
34
+ })).filter(item => item.session);
35
+
36
+ // Fetch anilistId for each result in parallel (detail endpoint has ids.anilist)
37
+ await Promise.allSettled(results.map(async (r) => {
38
+ try {
39
+ const detailRes = await fetch(`${RELEASES_API}/${encodeURIComponent(r.session)}`, {
40
+ headers: { 'User-Agent': USER_AGENT }
41
+ });
42
+ if (detailRes.ok) {
43
+ const detail = await detailRes.json();
44
+ r.anilistId = detail.ids?.anilist ? String(detail.ids.anilist) : null;
45
+ }
46
+ } catch {}
47
+ }));
48
+
49
+ return results;
50
+ };
51
+ let results = await trySearch(cleaned);
52
+ if (!results.length && cleaned !== title.toLowerCase()) results = await trySearch(title);
53
+ return results;
65
54
  }
66
55
 
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
56
  async function getEpisodes(animeId) {
74
- const { INFO_API, USER_AGENT } = utils();
75
- const res = await fetch(`${INFO_API}?id=${animeId}`, { headers: { 'User-Agent': USER_AGENT } });
76
- if (!res.ok) throw new Error(`Failed to fetch episode list for ${animeId}`);
77
- const data = await res.json();
78
- return data.results?.episodes || [];
57
+ const allEpisodes = [];
58
+ let page = 1;
59
+ let lastPage = 1;
60
+
61
+ do {
62
+ const url = page === 1
63
+ ? `${RELEASES_API}/${encodeURIComponent(animeId)}/releases`
64
+ : `${RELEASES_API}/${encodeURIComponent(animeId)}/releases?page=${page}`;
65
+ const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
66
+ if (!res.ok) throw new Error(`Releases API returned ${res.status} for ${animeId}`);
67
+ const data = await res.json();
68
+ lastPage = data.paginationInfo?.lastPage || 1;
69
+ const items = data.data || [];
70
+
71
+ for (const item of items) {
72
+ const num = Number(item.episode);
73
+ if (!isNaN(num)) {
74
+ allEpisodes.push({
75
+ episode: num,
76
+ session: item.session,
77
+ disc: item.disc || null,
78
+ audio: item.audio || null,
79
+ snapshot: item.snapshot || null,
80
+ filler: item.filler || null,
81
+ duration: item.duration || null,
82
+ });
83
+ }
84
+ }
85
+ page++;
86
+ } while (page <= lastPage);
87
+
88
+ return allEpisodes.sort((a, b) => a.episode - b.episode);
79
89
  }
80
90
 
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
- async function getDownloadLinks(animeId, episodeId) {
89
- const { DOWNLOAD_API, USER_AGENT } = utils();
91
+ async function getDownloadLinks(animeId, session, quality = null) {
90
92
  const res = await fetch(
91
- `${DOWNLOAD_API}?animeId=${animeId}&episodeId=${episodeId}`,
93
+ `${RELEASES_API}/${encodeURIComponent(animeId)}/${encodeURIComponent(session)}`,
92
94
  { headers: { 'User-Agent': USER_AGENT } }
93
95
  );
94
96
  if (!res.ok) return [];
95
97
  const data = await res.json();
96
- return data.results?.downloadLinks || [];
98
+
99
+ const sources = data.sources || [];
100
+ if (!sources.length) return [];
101
+
102
+ return sources.map(src => ({
103
+ mp4Url: src.download || null,
104
+ resolution: src.resolution || null,
105
+ isDub: src.isDub || false,
106
+ fanSub: src.fanSub || null,
107
+ }));
97
108
  }
98
109
 
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
110
  function pickLink(links, quality, language) {
109
111
  if (!links.length) return null;
112
+ const wantDub = language === 'dub';
113
+ const targetRes = quality.replace('p', ''); // "1080p" → "1080"
110
114
  return (
111
- links.find(l => l.resolution === quality && l.audio === language) ||
112
- links.find(l => l.resolution === quality) ||
115
+ links.find(l => l.resolution === targetRes && l.isDub === wantDub) ||
116
+ links.find(l => l.resolution === targetRes) ||
117
+ links.find(l => l.isDub === wantDub) ||
113
118
  links[0]
114
119
  );
115
120
  }
116
121
 
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
122
  async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
128
- const { downloadFile } = utils();
129
123
  await downloadFile(url, filename, outputDir, multiBar, episodeNum);
130
124
  }
131
125
 
132
- module.exports = {
133
- searchAnime,
134
- searchAll,
135
- getEpisodes,
136
- getDownloadLinks,
137
- pickLink,
138
- downloadEpisode,
139
- };
126
+ module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
package/utils.js CHANGED
@@ -4,10 +4,21 @@ const os = require('os');
4
4
  const chalk = require('chalk');
5
5
 
6
6
  // --- Configuration ---
7
- const API_DOMAIN = 'https://arcane-nx-cipher-pol.hf.space/api';
8
- const SEARCH_API = `${API_DOMAIN}/anime/search`;
9
- const INFO_API = `${API_DOMAIN}/anime/info`;
10
- const DOWNLOAD_API = `${API_DOMAIN}/anime/download`;
7
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+
10
+ function loadConfig() {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ const config = loadConfig();
19
+ const API_DOMAIN = config.apiBaseUrl || 'https://aor-rex-anikuro-api.hf.space';
20
+ const SEARCH_API = `${API_DOMAIN}/api/anime/search`;
21
+ const RELEASES_API = `${API_DOMAIN}/api/anime`;
11
22
  const USER_AGENT = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36';
12
23
 
13
24
  // --- Helpers ---
@@ -43,14 +54,35 @@ async function downloadFile(url, filename, folder, multiBar, episode) {
43
54
  }) : null;
44
55
 
45
56
  try {
46
- const headers = {
57
+ // Strategy: try direct with Referer first; if 403, fall back to cookie extraction
58
+ let response = await _fetchWithCookie(url, {
47
59
  'User-Agent': USER_AGENT,
48
- 'Referer': 'https://kwik.cx/'
49
- };
50
-
51
- if (fileExists) headers['Range'] = `bytes=${downloadedBytes}-`;
52
-
53
- const response = await fetch(url, { headers });
60
+ 'Referer': 'https://kwik.cx/',
61
+ 'Origin': 'https://kwik.cx',
62
+ }, fileExists ? downloadedBytes : null);
63
+
64
+ if (response.status === 403) {
65
+ // Cloudflare Turnstile solve with Puppeteer, retry with cookie
66
+ if (progressBar) progressBar.update(0, { episode, speed: 'solving CF...', value_formatted: '0 B', total_formatted: 'Unknown' });
67
+ const { getCachedCookie, invalidateCache } = require('./src/cloudflare');
68
+ const cookie = await getCachedCookie(url);
69
+ response = await _fetchWithCookie(url, {
70
+ 'User-Agent': USER_AGENT,
71
+ 'Referer': 'https://kwik.cx/',
72
+ 'Cookie': `cf_clearance=${cookie}`,
73
+ }, fileExists ? downloadedBytes : null);
74
+
75
+ // If still 403 after cookie, invalidate and retry once
76
+ if (response.status === 403) {
77
+ invalidateCache();
78
+ const newCookie = await getCachedCookie(url);
79
+ response = await _fetchWithCookie(url, {
80
+ 'User-Agent': USER_AGENT,
81
+ 'Referer': 'https://kwik.cx/',
82
+ 'Cookie': `cf_clearance=${newCookie}`,
83
+ }, fileExists ? downloadedBytes : null);
84
+ }
85
+ }
54
86
 
55
87
  if (response.status === 416) {
56
88
  if (progressBar) { progressBar.update(100, { episode, value_formatted: 'Complete', total_formatted: 'Complete', speed: '0 B/s' }); progressBar.stop(); }
@@ -106,10 +138,14 @@ async function downloadFile(url, filename, folder, multiBar, episode) {
106
138
  }
107
139
  }
108
140
 
141
+ async function _fetchWithCookie(url, headers, resumeFrom) {
142
+ if (resumeFrom) headers['Range'] = `bytes=${resumeFrom}-`;
143
+ return fetch(url, { headers });
144
+ }
145
+
109
146
  module.exports = {
110
147
  SEARCH_API,
111
- INFO_API,
112
- DOWNLOAD_API,
148
+ RELEASES_API,
113
149
  USER_AGENT,
114
150
  sleep,
115
151
  getTerminalWidth,