ani-auto 1.3.0 → 1.3.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/README.md +42 -50
- package/package.json +2 -3
- package/src/cli.js +331 -183
- package/src/config.js +5 -3
- package/src/daemon.js +90 -30
- package/src/db.js +39 -7
- package/src/engine.js +44 -23
- package/src/network.js +88 -0
- package/src/notify.js +70 -47
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
|
@@ -57,6 +57,9 @@ function migrate(db) {
|
|
|
57
57
|
confirmed_site_id TEXT,
|
|
58
58
|
confirmed_site_title TEXT,
|
|
59
59
|
anilist_title TEXT,
|
|
60
|
+
poster_url TEXT,
|
|
61
|
+
override_quality TEXT,
|
|
62
|
+
next_airing_at INTEGER,
|
|
60
63
|
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
61
64
|
)
|
|
62
65
|
`);
|
|
@@ -76,6 +79,9 @@ function migrate(db) {
|
|
|
76
79
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_id TEXT'); } catch(e) {}
|
|
77
80
|
try { db.run('ALTER TABLE anime ADD COLUMN confirmed_site_title TEXT'); } catch(e) {}
|
|
78
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) {}
|
|
79
85
|
|
|
80
86
|
db.run(`
|
|
81
87
|
CREATE TABLE IF NOT EXISTS run_log (
|
|
@@ -223,7 +229,7 @@ function getAnimeBySiteTitle(title) {
|
|
|
223
229
|
|| _get('SELECT * FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
|
|
224
230
|
}
|
|
225
231
|
|
|
226
|
-
function saveConfirmedSiteMatch(anilistTitle, confirmedSiteId, confirmedSiteTitle) {
|
|
232
|
+
function saveConfirmedSiteMatch(anilistTitle, confirmedSiteId, confirmedSiteTitle, posterUrl = null) {
|
|
227
233
|
// Look up by anilist_title, AniList title matching title col, or site title matching title col
|
|
228
234
|
const existing =
|
|
229
235
|
_get('SELECT id FROM anime WHERE LOWER(anilist_title) = LOWER(?)', [anilistTitle]) ||
|
|
@@ -232,22 +238,48 @@ function saveConfirmedSiteMatch(anilistTitle, confirmedSiteId, confirmedSiteTitl
|
|
|
232
238
|
|
|
233
239
|
if (existing) {
|
|
234
240
|
_run(
|
|
235
|
-
'UPDATE anime SET confirmed_site_id = ?, confirmed_site_title = ?, anilist_title =
|
|
236
|
-
[confirmedSiteId, confirmedSiteTitle, anilistTitle, 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]
|
|
237
243
|
);
|
|
238
244
|
} else {
|
|
239
245
|
_run(
|
|
240
|
-
'INSERT OR IGNORE INTO anime (title, anilist_title, site_id, confirmed_site_id, confirmed_site_title, source) VALUES (?, ?, ?, ?, ?, ?)',
|
|
241
|
-
[anilistTitle, anilistTitle, confirmedSiteId, confirmedSiteId, confirmedSiteTitle, 'anilist']
|
|
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']
|
|
242
248
|
);
|
|
243
249
|
}
|
|
244
250
|
_save();
|
|
245
251
|
}
|
|
246
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
|
+
|
|
247
279
|
module.exports = {
|
|
248
280
|
initDb,
|
|
249
281
|
getDb: getDbInstance,
|
|
250
|
-
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, removeAnime,
|
|
251
|
-
isEpisodeDownloaded, getEpisodeRow, markEpisodeStatus, getEpisodeStats,
|
|
282
|
+
upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, setAnimeQuality, removeAnime,
|
|
283
|
+
isEpisodeDownloaded, getEpisodeRow, markEpisodeStatus, getEpisodeStats, getFailedEpisodes, getAllEpisodes, updateNextAiring,
|
|
252
284
|
logRun, getRecentRuns,
|
|
253
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,19 +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
|
-
// Try to find best match — prefer exact title match, then season match, then first result
|
|
159
162
|
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
160
163
|
const normTitle = normalize(workItem.title);
|
|
161
164
|
const season = detectSeason(workItem.title);
|
|
162
|
-
|
|
163
165
|
const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
|
|
164
166
|
|| (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
|
|
165
167
|
|| siteResults[0];
|
|
166
|
-
|
|
167
168
|
siteAnime = bestMatch;
|
|
168
|
-
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
169
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
169
170
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
170
171
|
} else {
|
|
171
172
|
const topResults = siteResults.slice(0, 5);
|
|
@@ -188,7 +189,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
siteAnime = pick;
|
|
191
|
-
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
|
|
192
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
192
193
|
ok(`[${workItem.title}] Site match saved: ${chalk.bold(siteAnime.title)}`);
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -201,6 +202,11 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
201
202
|
language: workItem.language || null,
|
|
202
203
|
});
|
|
203
204
|
|
|
205
|
+
// Save next airing time for smart scheduling
|
|
206
|
+
if (workItem.nextAiringAt && dbRow?.id) {
|
|
207
|
+
db.updateNextAiring(dbRow.id, workItem.nextAiringAt);
|
|
208
|
+
}
|
|
209
|
+
|
|
204
210
|
const enabledCheck = db.getAnimeBySiteTitle(workItem.title);
|
|
205
211
|
if (dbRow.enabled === 0 || enabledCheck?.enabled === 0) {
|
|
206
212
|
info(`[${workItem.title}] Disabled — skipping.`);
|
|
@@ -223,7 +229,29 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
223
229
|
const sortedEpisodes = [...episodes].sort((a, b) => a.episode - b.episode);
|
|
224
230
|
const watched = workItem.watchedEpisodes || 0;
|
|
225
231
|
const unwatched = sortedEpisodes.slice(watched);
|
|
226
|
-
|
|
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
|
+
});
|
|
227
255
|
|
|
228
256
|
if (range) {
|
|
229
257
|
const clean = range.trim();
|
|
@@ -237,7 +265,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
237
265
|
const pos = idx + 1;
|
|
238
266
|
return pos >= rangeFrom && pos <= rangeTo;
|
|
239
267
|
});
|
|
240
|
-
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '
|
|
268
|
+
info(`[${workItem.title}] Range filter: episodes ${rangeFrom}${rangeTo !== rangeFrom ? '-' + rangeTo : ''} (${toDownload.length} matched).`);
|
|
241
269
|
}
|
|
242
270
|
|
|
243
271
|
if (!toDownload.length) {
|
|
@@ -247,13 +275,6 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
247
275
|
|
|
248
276
|
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
249
277
|
|
|
250
|
-
const cleanTitle = buildFolderName(workItem.title);
|
|
251
|
-
const season = detectSeason(workItem.title);
|
|
252
|
-
const siteClean = buildFolderName(siteAnime.title || workItem.title);
|
|
253
|
-
const displayTitle = siteClean.replace(/[:\s]*season\s*\d+[:\s]*/gi, ' ').replace(/\s+/g, ' ').trim();
|
|
254
|
-
const outputDir = path.join(config.outputDir, cleanTitle);
|
|
255
|
-
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
256
|
-
|
|
257
278
|
const queue = [...toDownload];
|
|
258
279
|
const results = { downloaded: 0, skipped: 0, failed: 0 };
|
|
259
280
|
const concurrency = Math.min(config.concurrency || 2, queue.length);
|
|
@@ -261,7 +282,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
261
282
|
|
|
262
283
|
// Pre-compute relative episode numbers before queueing
|
|
263
284
|
const epRelativeMap = new Map();
|
|
264
|
-
toDownload.forEach((ep
|
|
285
|
+
toDownload.forEach((ep) => {
|
|
265
286
|
const relEp = season
|
|
266
287
|
? sortedEpisodes.findIndex(e => e.episode === ep.episode) + 1
|
|
267
288
|
: ep.episode;
|
|
@@ -284,12 +305,13 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
284
305
|
if (!link?.mp4Url) throw new Error('No suitable download link found');
|
|
285
306
|
|
|
286
307
|
const relativeEp = epRelativeMap.get(ep.episode) || ep.episode;
|
|
287
|
-
const filename
|
|
308
|
+
const filename = buildFilename(displayTitle, ep.episode, season, relativeEp);
|
|
288
309
|
await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
|
|
289
310
|
|
|
290
311
|
db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
|
|
291
312
|
ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
|
|
292
|
-
|
|
313
|
+
const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
|
|
314
|
+
await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode, q, posterUrl);
|
|
293
315
|
success = true;
|
|
294
316
|
results.downloaded++;
|
|
295
317
|
} catch (e) {
|
|
@@ -317,11 +339,10 @@ async function runCycle({ auto = false, quality = null, language = null, range =
|
|
|
317
339
|
await db.initDb();
|
|
318
340
|
const config = loadConfig();
|
|
319
341
|
|
|
320
|
-
console.log(chalk.bold.red('\n
|
|
321
|
-
console.log(chalk.bold.red(` ║
|
|
322
|
-
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'));
|
|
323
345
|
|
|
324
|
-
// Use run-level statuses if provided, otherwise fall back to config
|
|
325
346
|
const effectiveConfig = statuses
|
|
326
347
|
? { ...config, watchStatuses: statuses }
|
|
327
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 };
|