ani-auto 1.3.3 → 1.4.2
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/package.json +6 -3
- package/src/cli.js +12 -2
- package/src/cloudflare.js +91 -0
- package/src/config.js +4 -3
- package/src/daemon.js +5 -13
- package/src/engine.js +81 -21
- package/src/notify.js +33 -14
- package/src/scraper.js +80 -28
- package/utils.js +49 -13
- package/CHANGELOG.md +0 -82
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ani-auto",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.2",
|
|
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/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
|
|
772
|
-
if (
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:'));
|
|
@@ -108,28 +112,63 @@ async function promptAnimeSelection(watchList) {
|
|
|
108
112
|
const status = item.anilistStatus ? chalk.gray(` [${item.anilistStatus}]`) : '';
|
|
109
113
|
console.log(` ${chalk.cyan(i + 1)}. ${item.title}${watched}${status}`);
|
|
110
114
|
});
|
|
111
|
-
console.log(` ${chalk.cyan('
|
|
115
|
+
console.log(` ${chalk.cyan('s')}. Search and select`);
|
|
112
116
|
console.log('');
|
|
113
117
|
|
|
114
118
|
const { input } = await inquirer.prompt([{
|
|
115
119
|
type: 'input',
|
|
116
120
|
name: 'input',
|
|
117
|
-
message: 'Enter numbers to download
|
|
118
|
-
default: '
|
|
119
|
-
validate: (val) => {
|
|
120
|
-
if (val.trim().toLowerCase() === 'a') return true;
|
|
121
|
-
const nums = val.split(',').map(s => parseInt(s.trim()));
|
|
122
|
-
if (nums.some(isNaN)) return 'Enter comma-separated numbers or "a"';
|
|
123
|
-
if (nums.some(n => n < 1 || n > watchList.length)) return `Numbers must be between 1 and ${watchList.length}`;
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
121
|
+
message: 'Enter numbers to download, "s" to search, or Enter for all:',
|
|
122
|
+
default: '',
|
|
126
123
|
}]);
|
|
127
124
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
const choice = input.trim().toLowerCase();
|
|
126
|
+
|
|
127
|
+
if (choice === 's') {
|
|
128
|
+
const { query } = await inquirer.prompt([{
|
|
129
|
+
type: 'input',
|
|
130
|
+
name: 'query',
|
|
131
|
+
message: 'Search anime:',
|
|
132
|
+
default: '',
|
|
133
|
+
}]);
|
|
134
|
+
|
|
135
|
+
const searchTerm = query.trim().toLowerCase();
|
|
136
|
+
const filtered = searchTerm
|
|
137
|
+
? watchList.filter(item => item.title.toLowerCase().includes(searchTerm))
|
|
138
|
+
: watchList;
|
|
139
|
+
|
|
140
|
+
if (!filtered.length) {
|
|
141
|
+
console.log(chalk.yellow(' No matching anime found.\n'));
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(chalk.bold(`\n Found ${filtered.length} match(es):`));
|
|
146
|
+
|
|
147
|
+
const { picked } = await inquirer.prompt([{
|
|
148
|
+
type: 'checkbox',
|
|
149
|
+
name: 'picked',
|
|
150
|
+
message: 'Select anime to download:',
|
|
151
|
+
choices: filtered.map(item => {
|
|
152
|
+
const watched = item.watchedEpisodes ? ` (watched: ${item.watchedEpisodes})` : '';
|
|
153
|
+
const status = item.anilistStatus ? ` [${item.anilistStatus}]` : '';
|
|
154
|
+
return {
|
|
155
|
+
name: `${item.title}${watched}${status}`,
|
|
156
|
+
value: item,
|
|
157
|
+
checked: true,
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
pageSize: 15,
|
|
161
|
+
}]);
|
|
162
|
+
|
|
163
|
+
return picked.length ? picked : [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!choice || choice === 'a') return watchList;
|
|
167
|
+
const indices = choice.split(',').map(s => parseInt(s.trim()) - 1);
|
|
168
|
+
return indices.map(i => watchList[i]).filter(Boolean);
|
|
131
169
|
}
|
|
132
170
|
|
|
171
|
+
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
133
172
|
|
|
134
173
|
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
135
174
|
// Per-anime quality: run override > DB override > workItem > global config
|
|
@@ -146,24 +185,41 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
146
185
|
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
147
186
|
}
|
|
148
187
|
|
|
149
|
-
|
|
188
|
+
// Priority 1: Match by AniList ID (exact, most reliable)
|
|
189
|
+
if (workItem.anilistId) {
|
|
190
|
+
const idMatch = siteResults.find(r => r.anilistId === String(workItem.anilistId));
|
|
191
|
+
if (idMatch) {
|
|
192
|
+
siteAnime = idMatch;
|
|
193
|
+
info(`[${workItem.title}] Matched by AniList ID: ${chalk.bold(siteAnime.title)} (anilist:${siteAnime.anilistId})`);
|
|
194
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
195
|
+
} else {
|
|
196
|
+
warn(`[${workItem.title}] AniList ID ${workItem.anilistId} not found in search results — falling back to title match.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Priority 2: Saved site match
|
|
201
|
+
if (!siteAnime && existingRow?.confirmed_site_title) {
|
|
150
202
|
const savedMatch = siteResults.find(r =>
|
|
151
203
|
r.title?.toLowerCase() === existingRow.confirmed_site_title.toLowerCase()
|
|
152
204
|
) || siteResults[0];
|
|
153
205
|
siteAnime = savedMatch;
|
|
154
206
|
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
155
207
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
156
|
-
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Priority 3: Auto mode — title matching fallback (no AniList ID, no saved match)
|
|
211
|
+
if (!siteAnime && auto) {
|
|
157
212
|
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
158
213
|
const normTitle = normalize(workItem.title);
|
|
159
|
-
const season = detectSeason(workItem.title);
|
|
160
214
|
const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
|
|
161
|
-
|| (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
|
|
162
215
|
|| siteResults[0];
|
|
163
216
|
siteAnime = bestMatch;
|
|
164
217
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
165
218
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
166
|
-
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Priority 4: Interactive mode — user picks
|
|
222
|
+
if (!siteAnime) {
|
|
167
223
|
const topResults = siteResults.slice(0, 5);
|
|
168
224
|
const { pick } = await inquirer.prompt([{
|
|
169
225
|
type: 'list',
|
|
@@ -171,7 +227,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
171
227
|
message: `Site results for "${workItem.title}" — select correct match:`,
|
|
172
228
|
choices: [
|
|
173
229
|
...topResults.map(r => ({
|
|
174
|
-
name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}`,
|
|
230
|
+
name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}${r.anilistId ? chalk.gray(` [AL:${r.anilistId}]`) : ''}`,
|
|
175
231
|
value: r,
|
|
176
232
|
})),
|
|
177
233
|
{ name: chalk.yellow('Skip this anime'), value: null },
|
|
@@ -270,6 +326,9 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
270
326
|
|
|
271
327
|
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
272
328
|
|
|
329
|
+
const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
|
|
330
|
+
await notify.notifyDownloadStart(cleanTitle, toDownload.length, q, posterUrl);
|
|
331
|
+
|
|
273
332
|
const queue = [...toDownload];
|
|
274
333
|
const results = { downloaded: 0, skipped: 0, failed: 0 };
|
|
275
334
|
const concurrency = Math.min(config.concurrency || 2, queue.length);
|
|
@@ -295,7 +354,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
295
354
|
while (!success && attempts < MAX_RETRIES) {
|
|
296
355
|
attempts++;
|
|
297
356
|
try {
|
|
298
|
-
const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
|
|
357
|
+
const links = await scraper.getDownloadLinks(siteAnime.session, ep.session, q);
|
|
299
358
|
const link = scraper.pickLink(links, q, l);
|
|
300
359
|
if (!link?.mp4Url) throw new Error('No suitable download link found');
|
|
301
360
|
|
|
@@ -328,6 +387,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
328
387
|
return results;
|
|
329
388
|
}
|
|
330
389
|
|
|
390
|
+
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
331
391
|
|
|
332
392
|
async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
|
|
333
393
|
await db.initDb();
|
|
@@ -394,4 +454,4 @@ async function runCycle({ auto = false, quality = null, language = null, range =
|
|
|
394
454
|
|
|
395
455
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
396
456
|
|
|
397
|
-
module.exports = { runCycle, resolveWatchList };
|
|
457
|
+
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
|
-
`✅
|
|
47
|
-
`🎞
|
|
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, `
|
|
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
|
-
`
|
|
78
|
+
`episode ${episode} failed\n` +
|
|
60
79
|
`<i>${error}</i>`;
|
|
61
80
|
await Promise.allSettled([
|
|
62
81
|
sendTelegram({ text }),
|
|
63
|
-
sendDesktop(animeTitle, `
|
|
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
|
-
`✅
|
|
73
|
-
`⏭
|
|
74
|
-
`❌
|
|
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', `
|
|
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
|
-
`📦
|
|
87
|
-
`📺
|
|
88
|
-
`⏱
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
50
|
-
const { DOWNLOAD_API, USER_AGENT } = utils();
|
|
91
|
+
async function getDownloadLinks(animeId, session, quality = null) {
|
|
51
92
|
const res = await fetch(
|
|
52
|
-
`${
|
|
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
|
-
|
|
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 ===
|
|
64
|
-
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) ||
|
|
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
|
|
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,
|
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
|