ani-auto 1.2.5 → 1.3.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/README.md +10 -34
- package/package.json +2 -3
- package/src/cli.js +417 -143
- package/src/config.js +5 -3
- package/src/daemon.js +90 -30
- package/src/db.js +57 -11
- package/src/engine.js +51 -21
- package/src/network.js +88 -0
- package/src/notify.js +70 -46
package/src/config.js
CHANGED
|
@@ -13,14 +13,16 @@ 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
|
-
daemonIntervalMinutes:
|
|
21
|
-
cronSchedule: '0 */2 * * *',
|
|
20
|
+
daemonIntervalMinutes: 120,
|
|
22
21
|
skipExisting: true,
|
|
23
22
|
maxEpisodesPerRun: 50,
|
|
23
|
+
telegramChatId: null,
|
|
24
|
+
telegramNotify: true,
|
|
25
|
+
airingBufferMinutes: 5, // minutes after air time before checking AnimePahe
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
function ensureConfigDir() {
|
package/src/daemon.js
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
process.removeAllListeners('warning');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
4
|
+
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const { loadConfig } = require('./config');
|
|
7
|
+
const { runCycle } = require('./engine');
|
|
8
|
+
const { waitForInternet, checkInternet } = require('./network');
|
|
9
|
+
const notify = require('./notify');
|
|
10
|
+
const db = require('./db');
|
|
11
|
+
|
|
12
|
+
let running = false;
|
|
13
|
+
|
|
14
|
+
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
|
|
15
|
+
|
|
16
|
+
function log(msg) { console.log(`${chalk.gray(ts())} ${msg}`); }
|
|
17
|
+
function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
|
|
18
|
+
function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${msg}`); }
|
|
19
19
|
|
|
20
20
|
async function safeRun() {
|
|
21
21
|
if (running) {
|
|
22
|
-
|
|
22
|
+
warn('Previous run still in progress — skipping.');
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// Check internet before running
|
|
27
|
+
const online = await checkInternet();
|
|
28
|
+
if (!online) {
|
|
29
|
+
warn('No internet connection — waiting for network...');
|
|
30
|
+
await waitForInternet();
|
|
31
|
+
info('Internet restored — starting cycle.');
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
running = true;
|
|
26
35
|
try {
|
|
27
36
|
await runCycle({ auto: true });
|
|
@@ -32,12 +41,52 @@ async function safeRun() {
|
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
// ── Smart scheduling ───────────────────────────────────────────────────────
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
const smartTimers = new Map();
|
|
47
|
+
|
|
48
|
+
async function scheduleSmartChecks() {
|
|
49
|
+
await db.initDb();
|
|
38
50
|
const config = loadConfig();
|
|
39
|
-
const
|
|
40
|
-
|
|
51
|
+
const now = Math.floor(Date.now() / 1000);
|
|
52
|
+
|
|
53
|
+
// Clear existing smart timers
|
|
54
|
+
for (const [, timer] of smartTimers) clearTimeout(timer);
|
|
55
|
+
smartTimers.clear();
|
|
56
|
+
|
|
57
|
+
// Get all anime with a next airing time
|
|
58
|
+
const allAnime = db.getAllEnabledAnime ? db.getAllEnabledAnime() : [];
|
|
59
|
+
const BUFFER = (config.airingBufferMinutes || 5) * 60;
|
|
60
|
+
|
|
61
|
+
for (const anime of allAnime) {
|
|
62
|
+
if (!anime.next_airing_at) continue;
|
|
63
|
+
const targetTime = anime.next_airing_at + BUFFER;
|
|
64
|
+
const delay = (targetTime - now) * 1000;
|
|
65
|
+
|
|
66
|
+
if (delay <= 0) {
|
|
67
|
+
// Already past air time — check immediately on next safeRun
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const title = anime.anilist_title || anime.title;
|
|
72
|
+
info(`Smart schedule: ${chalk.bold(title)} in ${Math.round(delay / 60000)} minutes`);
|
|
73
|
+
|
|
74
|
+
const timer = setTimeout(async () => {
|
|
75
|
+
info(`Smart trigger: checking ${chalk.bold(title)} (episode aired)`);
|
|
76
|
+
await safeRun();
|
|
77
|
+
// Reschedule after run to pick up next episode's air time
|
|
78
|
+
setTimeout(scheduleSmartChecks, 60000);
|
|
79
|
+
}, delay);
|
|
80
|
+
|
|
81
|
+
smartTimers.set(anime.id, timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Startup ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function startDaemon() {
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
|
|
41
90
|
|
|
42
91
|
console.log(chalk.bold.red(`
|
|
43
92
|
█████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
|
|
@@ -50,27 +99,38 @@ async function startDaemon() {
|
|
|
50
99
|
`));
|
|
51
100
|
|
|
52
101
|
console.log(chalk.cyan(` Interval: every ${config.daemonIntervalMinutes || 30} minutes`));
|
|
53
|
-
console.log(chalk.cyan(` Cron: ${cronSchedule}`));
|
|
54
102
|
console.log(chalk.cyan(` Output: ${config.outputDir}`));
|
|
55
103
|
console.log(chalk.gray(` Started: ${ts()}\n`));
|
|
56
104
|
|
|
57
|
-
//
|
|
58
|
-
await
|
|
105
|
+
// Startup internet check
|
|
106
|
+
const online = await checkInternet();
|
|
107
|
+
if (!online) {
|
|
108
|
+
warn('No internet at startup — waiting for connection...');
|
|
109
|
+
await waitForInternet();
|
|
110
|
+
info('Internet available — starting.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Get tracking count for notification
|
|
114
|
+
await db.initDb();
|
|
115
|
+
const allTracked = db.getAllEnabledAnime ? db.getAllEnabledAnime() : [];
|
|
116
|
+
await notify.notifyDaemonStart(config.daemonIntervalMinutes || 120, allTracked.length);
|
|
59
117
|
|
|
60
118
|
// Run immediately on start
|
|
61
119
|
await safeRun();
|
|
62
120
|
|
|
63
|
-
//
|
|
64
|
-
|
|
121
|
+
// Set up smart scheduling after first run (DB now has airing data)
|
|
122
|
+
await scheduleSmartChecks();
|
|
123
|
+
|
|
124
|
+
// Interval-based fallback polling
|
|
125
|
+
setInterval(async () => {
|
|
126
|
+
await safeRun();
|
|
127
|
+
await scheduleSmartChecks(); // Refresh smart timers each interval
|
|
128
|
+
}, intervalMs);
|
|
65
129
|
|
|
66
|
-
// Cron-based trigger (secondary — e.g. aligned to midnight/air times)
|
|
67
|
-
const job = new CronJob(cronSchedule, safeRun, null, true, 'UTC');
|
|
68
|
-
job.start();
|
|
69
130
|
|
|
70
131
|
console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
|
|
71
132
|
|
|
72
|
-
|
|
73
|
-
process.on('SIGINT', async () => {
|
|
133
|
+
process.on('SIGINT', async () => {
|
|
74
134
|
console.log(chalk.yellow('\n Shutting down daemon...'));
|
|
75
135
|
await notify.notifyDaemonStop();
|
|
76
136
|
process.exit(0);
|
package/src/db.js
CHANGED
|
@@ -56,6 +56,10 @@ function migrate(db) {
|
|
|
56
56
|
language TEXT,
|
|
57
57
|
confirmed_site_id TEXT,
|
|
58
58
|
confirmed_site_title TEXT,
|
|
59
|
+
anilist_title TEXT,
|
|
60
|
+
poster_url TEXT,
|
|
61
|
+
override_quality TEXT,
|
|
62
|
+
next_airing_at INTEGER,
|
|
59
63
|
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
64
|
)
|
|
61
65
|
`);
|
|
@@ -74,6 +78,10 @@ function migrate(db) {
|
|
|
74
78
|
// Add confirmed_site columns if upgrading from older DB
|
|
75
79
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
76
80
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
|
|
81
|
+
try { db.run('ALTER TABLE anime ADD COLUMN anilist_title TEXT'); } catch(e) {}
|
|
82
|
+
try { db.run('ALTER TABLE anime ADD COLUMN poster_url TEXT'); } catch(e) {}
|
|
83
|
+
try { db.run('ALTER TABLE anime ADD COLUMN override_quality TEXT'); } catch(e) {}
|
|
84
|
+
try { db.run('ALTER TABLE anime ADD COLUMN next_airing_at INTEGER'); } catch(e) {}
|
|
77
85
|
|
|
78
86
|
db.run(`
|
|
79
87
|
CREATE TABLE IF NOT EXISTS run_log (
|
|
@@ -168,6 +176,13 @@ function isEpisodeDownloaded(animeDbId, episodeNum) {
|
|
|
168
176
|
);
|
|
169
177
|
}
|
|
170
178
|
|
|
179
|
+
function getEpisodeRow(animeDbId, episodeNum) {
|
|
180
|
+
return _get(
|
|
181
|
+
'SELECT * FROM episodes WHERE anime_id = ? AND episode_num = ?',
|
|
182
|
+
[animeDbId, episodeNum]
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
171
186
|
function markEpisodeStatus(animeDbId, episodeNum, status, filename = null, error = null) {
|
|
172
187
|
_run(`
|
|
173
188
|
INSERT INTO episodes (anime_id, episode_num, status, filename, downloaded_at, error)
|
|
@@ -209,31 +224,62 @@ function getDbInstance() {
|
|
|
209
224
|
}
|
|
210
225
|
|
|
211
226
|
function getAnimeBySiteTitle(title) {
|
|
212
|
-
|
|
227
|
+
// Check by anilist_title first, then fall back to title column
|
|
228
|
+
return _get('SELECT * FROM anime WHERE LOWER(anilist_title) = LOWER(?)', [title])
|
|
229
|
+
|| _get('SELECT * FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
|
|
213
230
|
}
|
|
214
231
|
|
|
215
|
-
function saveConfirmedSiteMatch(
|
|
216
|
-
//
|
|
217
|
-
const existing =
|
|
232
|
+
function saveConfirmedSiteMatch(anilistTitle, confirmedSiteId, confirmedSiteTitle, posterUrl = null) {
|
|
233
|
+
// Look up by anilist_title, AniList title matching title col, or site title matching title col
|
|
234
|
+
const existing =
|
|
235
|
+
_get('SELECT id FROM anime WHERE LOWER(anilist_title) = LOWER(?)', [anilistTitle]) ||
|
|
236
|
+
_get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [anilistTitle]) ||
|
|
237
|
+
_get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [confirmedSiteTitle]);
|
|
238
|
+
|
|
218
239
|
if (existing) {
|
|
219
240
|
_run(
|
|
220
|
-
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title =
|
|
221
|
-
[confirmedSiteId, confirmedSiteTitle, existing.id]
|
|
241
|
+
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title = ?, anilist_title = ?, poster_url = COALESCE(?, poster_url) WHERE id = ?',
|
|
242
|
+
[confirmedSiteId, confirmedSiteTitle, anilistTitle, posterUrl, existing.id]
|
|
222
243
|
);
|
|
223
244
|
} else {
|
|
224
|
-
// No row yet — insert one using confirmedSiteId as the site_id
|
|
225
245
|
_run(
|
|
226
|
-
'INSERT OR IGNORE INTO anime (title, site_id, confirmed_site_id, confirmed_site_title, source) VALUES (?, ?, ?, ?, ?)',
|
|
227
|
-
[
|
|
246
|
+
'INSERT OR IGNORE INTO anime (title, anilist_title, site_id, confirmed_site_id, confirmed_site_title, poster_url, source) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
247
|
+
[anilistTitle, anilistTitle, confirmedSiteId, confirmedSiteId, confirmedSiteTitle, posterUrl, 'anilist']
|
|
228
248
|
);
|
|
229
249
|
}
|
|
230
250
|
_save();
|
|
231
251
|
}
|
|
232
252
|
|
|
253
|
+
function setAnimeQuality(title, quality) {
|
|
254
|
+
const row = _get('SELECT id FROM anime WHERE LOWER(anilist_title) = LOWER(?) OR LOWER(title) = LOWER(?)', [title, title]);
|
|
255
|
+
if (!row) return false;
|
|
256
|
+
_run('UPDATE anime SET override_quality = ? WHERE id = ?', [quality || null, row.id]);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getFailedEpisodes() {
|
|
261
|
+
return _all(`
|
|
262
|
+
SELECT e.*, a.title, a.anilist_title, a.site_id, a.confirmed_site_id, a.confirmed_site_title,
|
|
263
|
+
a.override_quality, a.quality, a.language, a.source, a.poster_url
|
|
264
|
+
FROM episodes e
|
|
265
|
+
JOIN anime a ON e.anime_id = a.id
|
|
266
|
+
WHERE e.status = 'failed'
|
|
267
|
+
ORDER BY a.title, e.episode_num
|
|
268
|
+
`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function getAllEpisodes(animeDbId) {
|
|
272
|
+
return _all('SELECT * FROM episodes WHERE anime_id = ? ORDER BY episode_num', [animeDbId]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function updateNextAiring(animeDbId, airingAt) {
|
|
276
|
+
_run('UPDATE anime SET next_airing_at = ? WHERE id = ?', [airingAt, animeDbId]);
|
|
277
|
+
}
|
|
278
|
+
|
|
233
279
|
module.exports = {
|
|
234
280
|
initDb,
|
|
235
281
|
getDb: getDbInstance,
|
|
236
|
-
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, removeAnime,
|
|
237
|
-
isEpisodeDownloaded, markEpisodeStatus, getEpisodeStats,
|
|
282
|
+
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, setAnimeQuality, removeAnime,
|
|
283
|
+
isEpisodeDownloaded, getEpisodeRow, markEpisodeStatus, getEpisodeStats, getFailedEpisodes, getAllEpisodes, updateNextAiring,
|
|
238
284
|
logRun, getRecentRuns,
|
|
239
285
|
};
|
package/src/engine.js
CHANGED
|
@@ -69,6 +69,7 @@ async function resolveWatchList(config) {
|
|
|
69
69
|
knownEpisodes: entry.totalEpisodes,
|
|
70
70
|
animeStatus: entry.animeStatus,
|
|
71
71
|
anilistStatus: entry.status,
|
|
72
|
+
nextAiringAt: entry.nextAiring?.airingAt || null,
|
|
72
73
|
});
|
|
73
74
|
}
|
|
74
75
|
info(`AniList returned ${chalk.bold(entries.length)} entries.`);
|
|
@@ -89,6 +90,7 @@ async function resolveWatchList(config) {
|
|
|
89
90
|
knownEpisodes: null,
|
|
90
91
|
animeStatus: null,
|
|
91
92
|
anilistStatus: null,
|
|
93
|
+
nextAiringAt: null,
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -135,7 +137,9 @@ async function promptAnimeSelection(watchList) {
|
|
|
135
137
|
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
136
138
|
|
|
137
139
|
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
138
|
-
|
|
140
|
+
// Per-anime quality: run override > DB override > workItem > global config
|
|
141
|
+
const dbAnimeRow = db.getAnimeBySiteTitle(workItem.title);
|
|
142
|
+
const q = runQuality || dbAnimeRow?.override_quality || workItem.quality || config.quality || '1080p';
|
|
139
143
|
const l = runLanguage || workItem.language || config.language || 'sub';
|
|
140
144
|
|
|
141
145
|
const existingRow = db.getAnimeBySiteTitle(workItem.title);
|
|
@@ -153,10 +157,16 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
153
157
|
) || siteResults[0];
|
|
154
158
|
siteAnime = savedMatch;
|
|
155
159
|
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
156
|
-
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
160
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
157
161
|
} else if (auto) {
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
163
|
+
const normTitle = normalize(workItem.title);
|
|
164
|
+
const season = detectSeason(workItem.title);
|
|
165
|
+
const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
|
|
166
|
+
|| (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
|
|
167
|
+
|| siteResults[0];
|
|
168
|
+
siteAnime = bestMatch;
|
|
169
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
160
170
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
161
171
|
} else {
|
|
162
172
|
const topResults = siteResults.slice(0, 5);
|
|
@@ -179,7 +189,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
siteAnime = pick;
|
|
182
|
-
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
192
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
183
193
|
ok(`[${workItem.title}] Site match saved: ${chalk.bold(siteAnime.title)}`);
|
|
184
194
|
}
|
|
185
195
|
|
|
@@ -192,6 +202,11 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
192
202
|
language: workItem.language || null,
|
|
193
203
|
});
|
|
194
204
|
|
|
205
|
+
// Save next airing time for smart scheduling
|
|
206
|
+
if (workItem.nextAiringAt && dbRow?.id) {
|
|
207
|
+
db.updateNextAiring(dbRow.id, workItem.nextAiringAt);
|
|
208
|
+
}
|
|
209
|
+
|
|
195
210
|
const enabledCheck = db.getAnimeBySiteTitle(workItem.title);
|
|
196
211
|
if (dbRow.enabled === 0 || enabledCheck?.enabled === 0) {
|
|
197
212
|
info(`[${workItem.title}] Disabled — skipping.`);
|
|
@@ -214,7 +229,29 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
214
229
|
const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
|
|
215
230
|
const watched = workItem.watchedEpisodes || 0;
|
|
216
231
|
const unwatched = sortedEpisodes.slice(watched);
|
|
217
|
-
|
|
232
|
+
|
|
233
|
+
// Build folder name early so file existence check can use it
|
|
234
|
+
const cleanTitle = buildFolderName(workItem.title);
|
|
235
|
+
const season = detectSeason(workItem.title);
|
|
236
|
+
const siteClean = buildFolderName(siteAnime.title || workItem.title);
|
|
237
|
+
const displayTitle = siteClean.replace(/[:\s]*season\s*\d+[:\s]*/gi, ' ').replace(/\s+/g, ' ').trim();
|
|
238
|
+
const outputDir = path.join(config.outputDir, cleanTitle);
|
|
239
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
240
|
+
|
|
241
|
+
let toDownload = unwatched.filter(ep => {
|
|
242
|
+
if (!db.isEpisodeDownloaded(dbRow.id, ep.episode)) return true;
|
|
243
|
+
// Double-check the file actually exists on disk — re-download if missing
|
|
244
|
+
const epRow = db.getEpisodeRow(dbRow.id, ep.episode);
|
|
245
|
+
if (epRow?.filename) {
|
|
246
|
+
const filePath = path.join(outputDir, epRow.filename);
|
|
247
|
+
if (!fs.existsSync(filePath)) {
|
|
248
|
+
warn(`[${workItem.title}] EP${ep.episode} marked done but file missing — queuing re-download.`);
|
|
249
|
+
db.markEpisodeStatus(dbRow.id, ep.episode, 'pending');
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
});
|
|
218
255
|
|
|
219
256
|
if (range) {
|
|
220
257
|
const clean = range.trim();
|
|
@@ -228,7 +265,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
228
265
|
const pos = idx + 1;
|
|
229
266
|
return pos >= rangeFrom && pos <= rangeTo;
|
|
230
267
|
});
|
|
231
|
-
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '
|
|
268
|
+
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '-' + rangeTo : ''} (${toDownload.length} matched).`);
|
|
232
269
|
}
|
|
233
270
|
|
|
234
271
|
if (!toDownload.length) {
|
|
@@ -238,13 +275,6 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
238
275
|
|
|
239
276
|
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
240
277
|
|
|
241
|
-
const cleanTitle = buildFolderName(workItem.title);
|
|
242
|
-
const season = detectSeason(workItem.title);
|
|
243
|
-
const siteClean = buildFolderName(siteAnime.title || workItem.title);
|
|
244
|
-
const displayTitle = siteClean.replace(/[:\s]*season\s*\d+[:\s]*/gi, ' ').replace(/\s+/g, ' ').trim();
|
|
245
|
-
const outputDir = path.join(config.outputDir, cleanTitle);
|
|
246
|
-
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
247
|
-
|
|
248
278
|
const queue = [...toDownload];
|
|
249
279
|
const results = { downloaded: 0, skipped: 0, failed: 0 };
|
|
250
280
|
const concurrency = Math.min(config.concurrency || 2, queue.length);
|
|
@@ -252,7 +282,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
252
282
|
|
|
253
283
|
// Pre-compute relative episode numbers before queueing
|
|
254
284
|
const epRelativeMap = new Map();
|
|
255
|
-
toDownload.forEach((ep
|
|
285
|
+
toDownload.forEach((ep) => {
|
|
256
286
|
const relEp = season
|
|
257
287
|
? sortedEpisodes.findIndex(e => e.episode === ep.episode) + 1
|
|
258
288
|
: ep.episode;
|
|
@@ -275,12 +305,13 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
275
305
|
if (!link?.mp4Url) throw new Error('No suitable download link found');
|
|
276
306
|
|
|
277
307
|
const relativeEp = epRelativeMap.get(ep.episode) || ep.episode;
|
|
278
|
-
const filename
|
|
308
|
+
const filename = buildFilename(displayTitle, ep.episode, season, relativeEp);
|
|
279
309
|
await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
|
|
280
310
|
|
|
281
311
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
|
|
282
312
|
ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
|
|
283
|
-
|
|
313
|
+
const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
|
|
314
|
+
await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode, q, posterUrl);
|
|
284
315
|
success = true;
|
|
285
316
|
results.downloaded++;
|
|
286
317
|
} catch (e) {
|
|
@@ -308,11 +339,10 @@ async function runCycle({ auto = false, quality = null, language = null, range =
|
|
|
308
339
|
await db.initDb();
|
|
309
340
|
const config = loadConfig();
|
|
310
341
|
|
|
311
|
-
console.log(chalk.bold.red('\n
|
|
312
|
-
console.log(chalk.bold.red(` ║
|
|
313
|
-
console.log(chalk.bold.red('
|
|
342
|
+
console.log(chalk.bold.red('\n ╔══════════════════════════════════╗'));
|
|
343
|
+
console.log(chalk.bold.red(` ║ ANI-AUTO v${version} — DOWNLOAD RUN ║`));
|
|
344
|
+
console.log(chalk.bold.red(' ╚══════════════════════════════════╝\n'));
|
|
314
345
|
|
|
315
|
-
// Use run-level statuses if provided, otherwise fall back to config
|
|
316
346
|
const effectiveConfig = statuses
|
|
317
347
|
? { ...config, watchStatuses: statuses }
|
|
318
348
|
: config;
|
package/src/network.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
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
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const dns = require('dns');
|
|
11
|
+
|
|
12
|
+
// Verify actual internet (not just interface up)
|
|
13
|
+
function checkInternet() {
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
dns.lookup('graphql.anilist.co', err => resolve(!err));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
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
|
+
function watchNetwork({ onOnline, onOffline } = {}) {
|
|
27
|
+
let online = true;
|
|
28
|
+
let debounce = null;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let monitor = null;
|
|
31
|
+
|
|
32
|
+
const handleChange = async () => {
|
|
33
|
+
if (stopped) return;
|
|
34
|
+
clearTimeout(debounce);
|
|
35
|
+
// Small debounce — routes can flap during reconnect
|
|
36
|
+
debounce = setTimeout(async () => {
|
|
37
|
+
const nowOnline = await checkInternet();
|
|
38
|
+
if (nowOnline && !online) {
|
|
39
|
+
online = true;
|
|
40
|
+
onOnline?.();
|
|
41
|
+
} else if (!nowOnline && online) {
|
|
42
|
+
online = false;
|
|
43
|
+
onOffline?.();
|
|
44
|
+
}
|
|
45
|
+
}, 2000);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Try `ip monitor route` first (Linux)
|
|
49
|
+
try {
|
|
50
|
+
monitor = spawn('ip', ['monitor', 'route'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
|
+
monitor.stdout.on('data', handleChange);
|
|
52
|
+
monitor.on('error', () => startFallback());
|
|
53
|
+
} 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
|
+
pollTimer = setInterval(handleChange, 10000);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return function stop() {
|
|
65
|
+
stopped = true;
|
|
66
|
+
clearTimeout(debounce);
|
|
67
|
+
clearInterval(pollTimer);
|
|
68
|
+
monitor?.kill();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wait until internet is available.
|
|
74
|
+
* Resolves immediately if already online.
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
function waitForInternet() {
|
|
78
|
+
return new Promise(resolve => {
|
|
79
|
+
checkInternet().then(online => {
|
|
80
|
+
if (online) return resolve();
|
|
81
|
+
const stop = watchNetwork({
|
|
82
|
+
onOnline: () => { stop(); resolve(); }
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { watchNetwork, waitForInternet, checkInternet };
|