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/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
@@ -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 = ? WHERE id = ?',
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
- 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,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
- 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
+ });
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 ? '' + rangeTo : ''} (${toDownload.length} matched).`);
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, idx) => {
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 = buildFilename(displayTitle, ep.episode, season, relativeEp);
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
- 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);
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(` ║ ANI-AUTO v${version} — CHECK RUN ║`));
322
- 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'));
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 };