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.
- package/.qwen/settings.json +9 -0
- package/README.md +2 -0
- package/package.json +6 -3
- package/src/anilist.js +6 -24
- package/src/cli.js +45 -2
- package/src/cloudflare.js +91 -0
- package/src/config.js +6 -4
- package/src/daemon.js +12 -4
- package/src/db.js +1 -14
- package/src/engine.js +28 -8
- package/src/network.js +9 -43
- package/src/notify.js +54 -47
- package/src/scraper.js +89 -102
- package/utils.js +49 -13
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"description": "automatic anime episode downloader",
|
|
5
5
|
"bin": {
|
|
6
|
-
"ani-auto": "
|
|
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
|
|
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:
|
|
117
|
-
status:
|
|
118
|
-
year:
|
|
119
|
-
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,
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
|
28
|
-
let debounce
|
|
29
|
-
let stopped
|
|
30
|
-
let monitor
|
|
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
|
-
|
|
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', () =>
|
|
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
|
|
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
|
-
|
|
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
|
-
`✅
|
|
56
|
-
`🎞
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
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
|
-
`
|
|
78
|
+
`episode ${episode} failed\n` +
|
|
71
79
|
`<i>${error}</i>`;
|
|
72
|
-
await
|
|
73
|
-
|
|
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
|
-
`✅
|
|
82
|
-
`⏭
|
|
83
|
-
`❌
|
|
84
|
-
await
|
|
85
|
-
|
|
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
|
-
`📦
|
|
94
|
-
`📺
|
|
95
|
-
`⏱
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
`ani-auto v${version} started`,
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
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 {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
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 ===
|
|
112
|
-
links.find(l => l.resolution ===
|
|
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
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
112
|
-
DOWNLOAD_API,
|
|
148
|
+
RELEASES_API,
|
|
113
149
|
USER_AGENT,
|
|
114
150
|
sleep,
|
|
115
151
|
getTerminalWidth,
|