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/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: 30,
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
- * daemon.js
6
- * Keeps running indefinitely, polling on a configurable interval.
7
- * Also registers a cron schedule as a secondary trigger.
8
- *
9
- * Usage: node src/daemon.js
10
- * npm start
11
- */
12
- const chalk = require('chalk');
13
- const { CronJob } = require('cron');
14
- const { loadConfig } = require('./config');
15
- const { runCycle } = require('./engine');
16
- const notify = require('./notify');
17
-
18
- let running = false; // prevents overlapping runs
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
- console.log(chalk.yellow(`[${ts()}] Previous run still in progress — skipping.`));
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
- function ts() { return new Date().toISOString().replace('T',' ').slice(0,19); }
44
+ // ── Smart scheduling ───────────────────────────────────────────────────────
36
45
 
37
- async function startDaemon() {
46
+ const smartTimers = new Map();
47
+
48
+ async function scheduleSmartChecks() {
49
+ await db.initDb();
38
50
  const config = loadConfig();
39
- const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
40
- const cronSchedule = config.cronSchedule || '0 */2 * * *';
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
- // Notify daemon started
58
- await notify.notifyDaemonStart(config.daemonIntervalMinutes || 30);
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
- // Interval-based polling
64
- setInterval(safeRun, intervalMs);
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
- // Graceful shutdown
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
- return _get('SELECT * FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
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(title, confirmedSiteId, confirmedSiteTitle) {
216
- // Try to update existing row first
217
- const existing = _get('SELECT id FROM anime WHERE LOWER(title) = LOWER(?)', [title]);
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 = ? WHERE id = ?',
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
- [title, 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']
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
- const q = runQuality || workItem.quality || config.quality || '1080p';
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
- siteAnime = siteResults[0];
159
- db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title);
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
- let toDownload = unwatched.filter(ep => !db.isEpisodeDownloaded(dbRow.id, ep.episode));
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 ? '' + rangeTo : ''} (${toDownload.length} matched).`);
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, idx) => {
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 = buildFilename(displayTitle, ep.episode, season, relativeEp);
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
- await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode);
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(` ║ ANI-AUTO v${version} — CHECK RUN ║`));
313
- console.log(chalk.bold.red(' ╚════════════════════════════════╝\n'));
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 };