ani-auto 1.3.3 → 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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "ani-auto",
3
- "version": "1.3.3",
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/cli.js CHANGED
@@ -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,6 +748,11 @@ 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`));
@@ -768,8 +778,8 @@ program
768
778
  updateConfig({ daemonEnabled: true });
769
779
  console.log(chalk.green('\n Done Daemon enabled.'));
770
780
  console.log(chalk.gray(' Starting systemd service...\n'));
771
- const r = run(`systemctl --user start ${SERVICE}`);
772
- if (r.code === 0) {
781
+ const re = run(`systemctl --user start ${SERVICE}`);
782
+ if (re.code === 0) {
773
783
  console.log(chalk.green(' Daemon is now running.\n'));
774
784
  } else {
775
785
  console.log(chalk.yellow(' Could not start systemd service (is it installed?).'));
@@ -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,17 +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,
23
+ telegramChatId: null,
24
+ telegramNotify: true,
25
25
  airingBufferMinutes: 5,
26
26
  daemonEnabled: true, // set to false via `ani-auto daemon disable`
27
+ apiBaseUrl: 'https://aor-rex-anikuro-api.hf.space',
27
28
  };
28
29
 
29
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
 
@@ -89,17 +88,11 @@ async function startDaemon() {
89
88
 
90
89
  // ── Enabled gate ──────────────────────────────────────────────────────
91
90
  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);
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);
103
96
  }
104
97
 
105
98
  const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
@@ -143,7 +136,6 @@ async function startDaemon() {
143
136
  await scheduleSmartChecks(); // Refresh smart timers each interval
144
137
  }, intervalMs);
145
138
 
146
-
147
139
  console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
148
140
 
149
141
  process.on('SIGINT', async () => {
package/src/engine.js CHANGED
@@ -13,6 +13,7 @@ const scraper = require('./scraper');
13
13
  const { version } = require('../package.json');
14
14
  const notify = require('./notify');
15
15
 
16
+ // ── Logging ────────────────────────────────────────────────────────────────
16
17
 
17
18
  function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
18
19
  function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
@@ -20,6 +21,7 @@ function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${
20
21
  function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); }
21
22
  function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
22
23
 
24
+ // ── Filename helpers ───────────────────────────────────────────────────────
23
25
 
24
26
  function buildFilename(title, episodeNum, season = null, displayEpisodeNum = null) {
25
27
  const s = season ? `Season ${season} ` : '';
@@ -43,6 +45,7 @@ function detectSeason(title) {
43
45
  return m ? parseInt(m[1], 10) : null;
44
46
  }
45
47
 
48
+ // ── Watch list ─────────────────────────────────────────────────────────────
46
49
 
47
50
  async function resolveWatchList(config) {
48
51
  const items = [];
@@ -100,6 +103,7 @@ async function resolveWatchList(config) {
100
103
  });
101
104
  }
102
105
 
106
+ // ── Anime selector prompt ──────────────────────────────────────────────────
103
107
 
104
108
  async function promptAnimeSelection(watchList) {
105
109
  console.log(chalk.bold('\n Available anime:'));
@@ -130,6 +134,7 @@ async function promptAnimeSelection(watchList) {
130
134
  return indices.map(i => watchList[i]);
131
135
  }
132
136
 
137
+ // ── Per-anime download ─────────────────────────────────────────────────────
133
138
 
134
139
  async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
135
140
  // Per-anime quality: run override > DB override > workItem > global config
@@ -146,24 +151,41 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
146
151
  return { downloaded: 0, skipped: 0, failed: 0 };
147
152
  }
148
153
 
149
- 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) {
150
168
  const savedMatch = siteResults.find(r =>
151
169
  r.title?.toLowerCase() === existingRow.confirmed_site_title.toLowerCase()
152
170
  ) || siteResults[0];
153
171
  siteAnime = savedMatch;
154
172
  info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
155
173
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
156
- } else if (auto) {
174
+ }
175
+
176
+ // Priority 3: Auto mode — title matching fallback (no AniList ID, no saved match)
177
+ if (!siteAnime && auto) {
157
178
  const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
158
179
  const normTitle = normalize(workItem.title);
159
- const season = detectSeason(workItem.title);
160
180
  const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
161
- || (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
162
181
  || siteResults[0];
163
182
  siteAnime = bestMatch;
164
183
  db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
165
184
  info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
166
- } else {
185
+ }
186
+
187
+ // Priority 4: Interactive mode — user picks
188
+ if (!siteAnime) {
167
189
  const topResults = siteResults.slice(0, 5);
168
190
  const { pick } = await inquirer.prompt([{
169
191
  type: 'list',
@@ -171,7 +193,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
171
193
  message: `Site results for "${workItem.title}" — select correct match:`,
172
194
  choices: [
173
195
  ...topResults.map(r => ({
174
- 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}]`) : ''}`,
175
197
  value: r,
176
198
  })),
177
199
  { name: chalk.yellow('Skip this anime'), value: null },
@@ -270,6 +292,9 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
270
292
 
271
293
  info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
272
294
 
295
+ const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
296
+ await notify.notifyDownloadStart(cleanTitle, toDownload.length, q, posterUrl);
297
+
273
298
  const queue = [...toDownload];
274
299
  const results = { downloaded: 0, skipped: 0, failed: 0 };
275
300
  const concurrency = Math.min(config.concurrency || 2, queue.length);
@@ -295,7 +320,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
295
320
  while (!success && attempts < MAX_RETRIES) {
296
321
  attempts++;
297
322
  try {
298
- const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
323
+ const links = await scraper.getDownloadLinks(siteAnime.session, ep.session, q);
299
324
  const link = scraper.pickLink(links, q, l);
300
325
  if (!link?.mp4Url) throw new Error('No suitable download link found');
301
326
 
@@ -328,6 +353,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
328
353
  return results;
329
354
  }
330
355
 
356
+ // ── Main cycle ─────────────────────────────────────────────────────────────
331
357
 
332
358
  async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
333
359
  await db.initDb();
@@ -394,4 +420,4 @@ async function runCycle({ auto = false, quality = null, language = null, range =
394
420
 
395
421
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
396
422
 
397
- module.exports = { runCycle, resolveWatchList };
423
+ module.exports = { runCycle, resolveWatchList };
package/src/notify.js CHANGED
@@ -39,16 +39,35 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
39
39
  } catch (e) {}
40
40
  }
41
41
 
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
+ }
60
+
42
61
  async function notifyEpisode(animeTitle, episode, quality, posterUrl) {
43
62
  const caption =
44
63
  `<b>${animeTitle}</b>\n` +
45
64
  `<code>━━━━━━━━━━━━━━━</code>\n` +
46
- `✅ Episode ${episode} downloaded\n` +
47
- `🎞 Quality: ${quality || 'default'}`;
65
+ `✅ episode ${episode} downloaded\n` +
66
+ `🎞 quality: ${quality || 'default'}`;
48
67
  const p1 = posterUrl
49
68
  ? sendTelegram({ photo: posterUrl, caption })
50
69
  : sendTelegram({ text: `🎬 ${caption}` });
51
- const p2 = sendDesktop(animeTitle, `Episode ${episode} downloaded`, 'normal', posterUrl);
70
+ const p2 = sendDesktop(animeTitle, `episode ${episode} downloaded`, 'normal', posterUrl);
52
71
  await Promise.allSettled([p1, p2]);
53
72
  }
54
73
 
@@ -56,11 +75,11 @@ async function notifyFailed(animeTitle, episode, error) {
56
75
  const text =
57
76
  `❌ <b>${animeTitle}</b>\n` +
58
77
  `<code>━━━━━━━━━━━━━━━</code>\n` +
59
- `Episode ${episode} failed\n` +
78
+ `episode ${episode} failed\n` +
60
79
  `<i>${error}</i>`;
61
80
  await Promise.allSettled([
62
81
  sendTelegram({ text }),
63
- sendDesktop(animeTitle, `Episode ${episode} failed`, 'critical')
82
+ sendDesktop(animeTitle, `episode ${episode} failed`, 'critical')
64
83
  ]);
65
84
  }
66
85
 
@@ -69,12 +88,12 @@ async function notifySummary(totals) {
69
88
  const text =
70
89
  `📊 <b>ani-auto run complete</b>\n` +
71
90
  `<code>━━━━━━━━━━━━━━━</code>\n` +
72
- `✅ Downloaded: ${totals.downloaded}\n` +
73
- `⏭ Skipped: ${totals.skipped}\n` +
74
- `❌ Failed: ${totals.failed}`;
91
+ `✅ downloaded: ${totals.downloaded}\n` +
92
+ `⏭ skipped: ${totals.skipped}\n` +
93
+ `❌ failed: ${totals.failed}`;
75
94
  await Promise.allSettled([
76
95
  sendTelegram({ text }),
77
- sendDesktop('ani-auto run complete', `Downloaded: ${totals.downloaded} Failed: ${totals.failed}`)
96
+ sendDesktop('ani-auto run complete', `downloaded: ${totals.downloaded} failed: ${totals.failed}`)
78
97
  ]);
79
98
  }
80
99
 
@@ -83,12 +102,12 @@ async function notifyDaemonStart(intervalMinutes, downloadingCount) {
83
102
  const text =
84
103
  `🚀 <b>ani-auto daemon started</b>\n` +
85
104
  `<code>━━━━━━━━━━━━━━━</code>\n` +
86
- `📦 Version: v${version}\n` +
87
- `📺 Downloading: ${downloadingCount} anime\n` +
88
- `⏱ Fallback check every ${intervalMinutes} minutes`;
105
+ `📦 version: v${version}\n` +
106
+ `📺 downloading: ${downloadingCount} anime\n` +
107
+ `⏱ fallback check every ${intervalMinutes} minutes`;
89
108
  await Promise.allSettled([
90
109
  sendTelegram({ text }),
91
- sendDesktop(`ani-auto v${version} started`, `Downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
110
+ sendDesktop(`ani-auto v${version} started`, `downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
92
111
  ]);
93
112
  }
94
113
 
@@ -99,4 +118,4 @@ async function notifyDaemonStop() {
99
118
  ]);
100
119
  }
101
120
 
102
- module.exports = { notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
121
+ module.exports = { notifyDownloadStart, notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
package/src/scraper.js CHANGED
@@ -3,18 +3,7 @@
3
3
  const path = require('path');
4
4
  const fetch = require('node-fetch');
5
5
 
6
- const UTILS_PATH = process.env.KANIME_UTILS_PATH || path.resolve(__dirname, '../utils.js');
7
-
8
- let _utils = null;
9
- function utils() {
10
- if (_utils) return _utils;
11
- try {
12
- _utils = require(UTILS_PATH);
13
- } catch (e) {
14
- throw new Error(`Cannot find utils at "${UTILS_PATH}". Set KANIME_UTILS_PATH env var.\n${e.message}`);
15
- }
16
- return _utils;
17
- }
6
+ const { SEARCH_API, RELEASES_API, USER_AGENT, downloadFile } = require('../utils');
18
7
 
19
8
  async function searchAnime(title) {
20
9
  const results = await searchAll(title);
@@ -25,13 +14,39 @@ async function searchAnime(title) {
25
14
  }
26
15
 
27
16
  async function searchAll(title) {
28
- const { SEARCH_API, USER_AGENT } = utils();
29
17
  const cleaned = title.replace(/[-\u2013\u2014~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
30
18
  const trySearch = async (q) => {
31
19
  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}`);
20
+ if (!res.ok) throw new Error(`Search API returned ${res.status}`);
33
21
  const data = await res.json();
34
- return data.results || [];
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;
35
50
  };
36
51
  let results = await trySearch(cleaned);
37
52
  if (!results.length && cleaned !== title.toLowerCase()) results = await trySearch(title);
@@ -39,36 +54,73 @@ async function searchAll(title) {
39
54
  }
40
55
 
41
56
  async function getEpisodes(animeId) {
42
- const { INFO_API, USER_AGENT } = utils();
43
- const res = await fetch(`${INFO_API}?id=${animeId}`, { headers: { 'User-Agent': USER_AGENT } });
44
- if (!res.ok) throw new Error(`Failed to fetch episode list for ${animeId}`);
45
- const data = await res.json();
46
- 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);
47
89
  }
48
90
 
49
- async function getDownloadLinks(animeId, episodeId) {
50
- const { DOWNLOAD_API, USER_AGENT } = utils();
91
+ async function getDownloadLinks(animeId, session, quality = null) {
51
92
  const res = await fetch(
52
- `${DOWNLOAD_API}?animeId=${animeId}&episodeId=${episodeId}`,
93
+ `${RELEASES_API}/${encodeURIComponent(animeId)}/${encodeURIComponent(session)}`,
53
94
  { headers: { 'User-Agent': USER_AGENT } }
54
95
  );
55
96
  if (!res.ok) return [];
56
97
  const data = await res.json();
57
- 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
+ }));
58
108
  }
59
109
 
60
110
  function pickLink(links, quality, language) {
61
111
  if (!links.length) return null;
112
+ const wantDub = language === 'dub';
113
+ const targetRes = quality.replace('p', ''); // "1080p" → "1080"
62
114
  return (
63
- links.find(l => l.resolution === quality && l.audio === language) ||
64
- 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) ||
65
118
  links[0]
66
119
  );
67
120
  }
68
121
 
69
122
  async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
70
- const { downloadFile } = utils();
71
123
  await downloadFile(url, filename, outputDir, multiBar, episodeNum);
72
124
  }
73
125
 
74
- module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
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,
package/CHANGELOG.md DELETED
@@ -1,82 +0,0 @@
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