ani-auto 1.4.3 → 2.0.0

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.
Files changed (90) hide show
  1. package/anime/LICENSE +21 -0
  2. package/anime/api/index.js +110 -0
  3. package/anime/app.js +79 -0
  4. package/anime/controllers/animeInfoController.js +48 -0
  5. package/anime/controllers/animeListController.js +21 -0
  6. package/anime/controllers/homeController.js +42 -0
  7. package/anime/controllers/playController.js +140 -0
  8. package/anime/controllers/queueController.js +20 -0
  9. package/anime/controllers/testController.js +667 -0
  10. package/anime/index.js +139 -0
  11. package/anime/middleware/cache.js +64 -0
  12. package/anime/middleware/errorHandler.js +33 -0
  13. package/anime/middleware/rateLimiter.js +73 -0
  14. package/anime/models/animeInfoModel.js +193 -0
  15. package/anime/models/animeListModel.js +69 -0
  16. package/anime/models/homeModel.js +33 -0
  17. package/anime/models/playModel.js +528 -0
  18. package/anime/models/queueModel.js +22 -0
  19. package/anime/package.json +27 -0
  20. package/anime/pnpm-lock.yaml +1774 -0
  21. package/anime/readme.md +236 -0
  22. package/anime/routes/animeInfoRoutes.js +9 -0
  23. package/anime/routes/animeListRoutes.js +9 -0
  24. package/anime/routes/homeRoutes.js +10 -0
  25. package/anime/routes/playRoutes.js +11 -0
  26. package/anime/routes/queueRoutes.js +8 -0
  27. package/anime/routes/testRoutes.js +325 -0
  28. package/anime/routes/webhookRoutes.js +23 -0
  29. package/anime/scrapers/animepahe.js +1053 -0
  30. package/anime/test-server.js +10 -0
  31. package/anime/test.sh +275 -0
  32. package/anime/utils/browser.js +172 -0
  33. package/anime/utils/config.js +209 -0
  34. package/anime/utils/dataProcessor.js +172 -0
  35. package/anime/utils/diskCache.js +228 -0
  36. package/anime/utils/flaresolverr.js +361 -0
  37. package/anime/utils/jsParser.js +19 -0
  38. package/anime/utils/redis.js +64 -0
  39. package/anime/utils/requestManager.js +706 -0
  40. package/anime/utils/urlConverter.js +88 -0
  41. package/anime/utils/webhookNotifier.js +190 -0
  42. package/cookiereader.py +291 -0
  43. package/package.json +14 -6
  44. package/src/anilist.js +19 -2
  45. package/src/api/anilist.js +32 -0
  46. package/src/api/anime.js +181 -0
  47. package/src/api/cf-bypass.js +131 -0
  48. package/src/api/config.js +134 -0
  49. package/src/api/daemon.js +62 -0
  50. package/src/api/download.js +98 -0
  51. package/src/api/match.js +58 -0
  52. package/src/api/runs.js +15 -0
  53. package/src/api/update.js +96 -0
  54. package/src/cli.js +18 -2
  55. package/src/config.js +14 -2
  56. package/src/daemon.js +1 -1
  57. package/src/db.js +38 -11
  58. package/src/engine.js +66 -13
  59. package/src/notify.js +82 -7
  60. package/src/scraper.js +2 -2
  61. package/src/server.js +108 -0
  62. package/utils.js +21 -21
  63. package/web/index.html +13 -0
  64. package/web/package-lock.json +1420 -0
  65. package/web/package.json +24 -0
  66. package/web/src/App.vue +200 -0
  67. package/web/src/api/client.js +75 -0
  68. package/web/src/assets/styles/base.css +961 -0
  69. package/web/src/components/UpdateModal.vue +157 -0
  70. package/web/src/components/sidebar/SidebarResizable.vue +170 -0
  71. package/web/src/components/sidebar/sidebarConfig.js +24 -0
  72. package/web/src/main.js +104 -0
  73. package/web/src/router/index.js +44 -0
  74. package/web/src/stores/config.js +27 -0
  75. package/web/src/stores/download.js +107 -0
  76. package/web/src/stores/update.js +73 -0
  77. package/web/src/views/About.vue +42 -0
  78. package/web/src/views/AniListSync.vue +122 -0
  79. package/web/src/views/AnimeDetail.vue +202 -0
  80. package/web/src/views/AnimeList.vue +110 -0
  81. package/web/src/views/CFResult.vue +312 -0
  82. package/web/src/views/DaemonControl.vue +83 -0
  83. package/web/src/views/Dashboard.vue +187 -0
  84. package/web/src/views/DownloadCenter.vue +153 -0
  85. package/web/src/views/MatchFinder.vue +131 -0
  86. package/web/src/views/OAuthCallback.vue +44 -0
  87. package/web/src/views/Onboarding.vue +93 -0
  88. package/web/src/views/RunHistory.vue +122 -0
  89. package/web/src/views/Settings.vue +410 -0
  90. package/web/vite.config.js +23 -0
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const { Router } = require('express');
4
+ const fetch = require('node-fetch');
5
+ const { exec } = require('child_process');
6
+ const { version } = require('../../package.json');
7
+
8
+ const router = Router();
9
+
10
+ router.get('/check', async (req, res) => {
11
+ try {
12
+ const response = await fetch('https://registry.npmjs.org/ani-auto/latest', {
13
+ timeout: 10000,
14
+ });
15
+ const data = await response.json();
16
+ const latest = data.version;
17
+ res.json({
18
+ current: version,
19
+ latest,
20
+ updateAvailable: latest !== version,
21
+ });
22
+ } catch (e) {
23
+ res.json({
24
+ current: version,
25
+ latest: version,
26
+ updateAvailable: false,
27
+ error: e.message,
28
+ });
29
+ }
30
+ });
31
+
32
+ router.post('/install', async (req, res) => {
33
+ const broadcast = req.app.locals.broadcast;
34
+ if (!broadcast) {
35
+ return res.status(500).json({ error: 'WebSocket not available' });
36
+ }
37
+
38
+ res.json({ ok: true, message: 'Update started' });
39
+
40
+ broadcast('update:progress', { percent: 5, message: 'Checking latest version...' });
41
+
42
+ try {
43
+ const response = await fetch('https://registry.npmjs.org/ani-auto/latest', {
44
+ timeout: 10000,
45
+ });
46
+ const data = await response.json();
47
+ const latest = data.version;
48
+
49
+ if (latest === version) {
50
+ broadcast('update:done', {
51
+ version,
52
+ message: `Already on v${version}`,
53
+ });
54
+ return;
55
+ }
56
+
57
+ broadcast('update:progress', {
58
+ percent: 15,
59
+ message: `Updating v${version} → v${latest}...`,
60
+ });
61
+
62
+ const child = exec(
63
+ `npm install -g ani-auto@${latest}`,
64
+ { timeout: 120000 },
65
+ (error) => {
66
+ if (error) {
67
+ broadcast('update:error', {
68
+ error: `Install failed: ${error.message}`,
69
+ });
70
+ return;
71
+ }
72
+ broadcast('update:progress', { percent: 95, message: 'Finalizing...' });
73
+ setTimeout(() => {
74
+ broadcast('update:done', {
75
+ version: latest,
76
+ message: `Updated to v${latest}`,
77
+ });
78
+ }, 500);
79
+ }
80
+ );
81
+
82
+ child.stdout?.on('data', (chunk) => {
83
+ const text = chunk.toString();
84
+ if (text.includes('added') || text.includes('changed')) {
85
+ broadcast('update:progress', {
86
+ percent: 60,
87
+ message: 'Packages installed',
88
+ });
89
+ }
90
+ });
91
+ } catch (e) {
92
+ broadcast('update:error', { error: e.message });
93
+ }
94
+ });
95
+
96
+ module.exports = router;
package/src/cli.js CHANGED
@@ -100,13 +100,19 @@ program
100
100
 
101
101
  if (answers.anilistToken) {
102
102
  const spinner = ora('Verifying AniList token...').start();
103
- try { const viewer = await getViewer(answers.anilistToken); spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`); }
104
- catch { spinner.warn('Could not verify token - saved anyway, check it manually.'); }
103
+ try {
104
+ const viewer = await getViewer(answers.anilistToken);
105
+ spinner.succeed(`Logged in as ${chalk.bold(viewer.name)}`);
106
+ answers.anilistAvatar = viewer.avatar?.medium || null;
107
+ } catch {
108
+ spinner.warn('Could not verify token - saved anyway, check it manually.');
109
+ }
105
110
  }
106
111
 
107
112
  updateConfig({
108
113
  anilistUsername: answers.anilistUsername || null,
109
114
  anilistToken: answers.anilistToken || null,
115
+ anilistAvatar: answers.anilistAvatar || null,
110
116
  watchStatuses: answers.watchStatuses || current.watchStatuses,
111
117
  quality: answers.quality,
112
118
  concurrency: answers.concurrency,
@@ -806,6 +812,16 @@ program
806
812
  }
807
813
  });
808
814
 
815
+ // ── web ───────────────────────────────────────────────────────────────────
816
+
817
+ program
818
+ .command('web')
819
+ .description('Start the web UI server on port 2640')
820
+ .action(() => {
821
+ const { startServer } = require('./server');
822
+ startServer();
823
+ });
824
+
809
825
  // ── main ──────────────────────────────────────────────────────────────────
810
826
 
811
827
  program
package/src/config.js CHANGED
@@ -23,18 +23,30 @@ const DEFAULTS = {
23
23
  telegramChatId: null,
24
24
  telegramNotify: true,
25
25
  airingBufferMinutes: 5,
26
- daemonEnabled: true, // set to false via `ani-auto daemon disable`
26
+ daemonEnabled: true,
27
+ autoUpdateCheck: true,
27
28
  apiBaseUrl: 'https://aor-rex-anikuro-api.hf.space',
29
+ anilistClientId: '37183',
30
+ anilistClientSecret: null,
31
+ animepaheCfClearance: null,
32
+ animepaheUserAgent: null,
28
33
  };
29
34
 
30
35
  function ensureConfigDir() {
31
36
  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
32
37
  }
33
38
 
39
+ const SENTINEL = '[set]';
40
+ const MASKABLE_KEYS = new Set(['anilistToken', 'anilistClientSecret', 'animepaheCfClearance', 'animepaheUserAgent']);
41
+
34
42
  function loadConfig() {
35
43
  ensureConfigDir();
36
44
  if (!fs.existsSync(CONFIG_FILE)) { saveConfig(DEFAULTS); return { ...DEFAULTS }; }
37
- try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) }; }
45
+ try {
46
+ const cfg = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) };
47
+ for (const k of MASKABLE_KEYS) { if (cfg[k] === SENTINEL) cfg[k] = null; }
48
+ return cfg;
49
+ }
38
50
  catch { return { ...DEFAULTS }; }
39
51
  }
40
52
 
package/src/daemon.js CHANGED
@@ -22,7 +22,7 @@ async function safeRun() {
22
22
  return;
23
23
  }
24
24
 
25
- // Check internet before running
25
+ // Check internet before running
26
26
  const online = await checkInternet();
27
27
  if (!online) {
28
28
  warn('No internet connection — waiting for network...');
package/src/db.js CHANGED
@@ -117,17 +117,35 @@ function _run(sql, params = []) {
117
117
  }
118
118
 
119
119
 
120
- function upsertAnime({ title, siteId, anilistId, source, quality, language }) {
121
- _run(`
122
- INSERT INTO anime (title, site_id, anilist_id, source, quality, language)
123
- VALUES (?, ?, ?, ?, ?, ?)
124
- ON CONFLICT(site_id) DO UPDATE SET
125
- title = excluded.title,
126
- anilist_id = COALESCE(excluded.anilist_id, anime.anilist_id),
127
- quality = COALESCE(excluded.quality, anime.quality),
128
- language = COALESCE(excluded.language, anime.language)
129
- `, [title, siteId || title, anilistId || null, source || 'anilist', quality || null, language || null]);
130
- return _get('SELECT * FROM anime WHERE site_id = ?', [siteId || title]);
120
+ function upsertAnime({ title, siteId, anilistId, source, quality, language, poster_url }) {
121
+ const finalSiteId = siteId || title;
122
+ const finalAnilistId = anilistId || null;
123
+
124
+ const existing = finalAnilistId
125
+ ? (_get('SELECT id FROM anime WHERE anilist_id = ?', [finalAnilistId]) ||
126
+ _get('SELECT id FROM anime WHERE site_id = ?', [finalSiteId]))
127
+ : _get('SELECT id FROM anime WHERE site_id = ?', [finalSiteId]);
128
+
129
+ if (existing) {
130
+ _run(`
131
+ UPDATE anime SET
132
+ title = ?,
133
+ site_id = ?,
134
+ anilist_id = COALESCE(?, anime.anilist_id),
135
+ source = ?,
136
+ quality = COALESCE(?, anime.quality),
137
+ language = COALESCE(?, anime.language),
138
+ poster_url = COALESCE(?, anime.poster_url)
139
+ WHERE id = ?
140
+ `, [title, finalSiteId, finalAnilistId, source || 'anilist', quality || null, language || null, poster_url || null, existing.id]);
141
+ } else {
142
+ _run(`
143
+ INSERT INTO anime (title, site_id, anilist_id, source, quality, language, poster_url)
144
+ VALUES (?, ?, ?, ?, ?, ?, ?)
145
+ `, [title, finalSiteId, finalAnilistId, source || 'anilist', quality || null, language || null, poster_url || null]);
146
+ }
147
+ return _get('SELECT * FROM anime WHERE site_id = ?', [finalSiteId]) ||
148
+ _get('SELECT * FROM anime WHERE anilist_id = ?', [finalAnilistId]);
131
149
  }
132
150
 
133
151
  function getAnime(siteId) {
@@ -263,9 +281,18 @@ function updateNextAiring(animeDbId, airingAt) {
263
281
  _run('UPDATE anime SET next_airing_at = ? WHERE id = ?', [airingAt, animeDbId]);
264
282
  }
265
283
 
284
+ function execSql(sql, params = []) {
285
+ _run(sql, params);
286
+ }
287
+
288
+ function saveDb() {
289
+ _save();
290
+ }
291
+
266
292
  module.exports = {
267
293
  initDb,
268
294
  getDb: getDbInstance,
295
+ execSql, saveDb,
269
296
  upsertAnime, getAnime, getAnimeBySiteTitle, saveConfirmedSiteMatch, getAllEnabledAnime, setAnimeEnabled, setAnimeQuality, removeAnime,
270
297
  isEpisodeDownloaded, getEpisodeRow, markEpisodeStatus, getEpisodeStats, getFailedEpisodes, getAllEpisodes, updateNextAiring,
271
298
  logRun, getRecentRuns,
package/src/engine.js CHANGED
@@ -15,11 +15,14 @@ const notify = require('./notify');
15
15
 
16
16
  // ── Logging ────────────────────────────────────────────────────────────────
17
17
 
18
- function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
19
- function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
20
- function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('')} ${msg}`); }
21
- function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('')} ${msg}`); }
18
+ let currentBus = null;
19
+
20
+ function info(msg) { const m = stripAnsi(msg); console.log(`${chalk.gray(ts())} ${chalk.cyan('')} ${msg}`); if (currentBus) currentBus.emit('broadcast', 'log', { level: 'info', message: m, timestamp: ts() }); }
21
+ function ok(msg) { const m = stripAnsi(msg); console.log(`${chalk.gray(ts())} ${chalk.green('')} ${msg}`); if (currentBus) currentBus.emit('broadcast', 'log', { level: 'ok', message: m, timestamp: ts() }); }
22
+ function warn(msg) { const m = stripAnsi(msg); console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${msg}`); if (currentBus) currentBus.emit('broadcast', 'log', { level: 'warn', message: m, timestamp: ts() }); }
23
+ function err(msg) { const m = stripAnsi(msg); console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); if (currentBus) currentBus.emit('broadcast', 'log', { level: 'err', message: m, timestamp: ts() }); }
22
24
  function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
25
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
23
26
 
24
27
  // ── Filename helpers ───────────────────────────────────────────────────────
25
28
 
@@ -69,6 +72,7 @@ async function resolveWatchList(config) {
69
72
  knownEpisodes: entry.totalEpisodes,
70
73
  animeStatus: entry.animeStatus,
71
74
  anilistStatus: entry.status,
75
+ posterUrl: entry.posterUrl || null,
72
76
  nextAiringAt: entry.nextAiring?.airingAt || null,
73
77
  });
74
78
  }
@@ -90,6 +94,7 @@ async function resolveWatchList(config) {
90
94
  knownEpisodes: null,
91
95
  animeStatus: null,
92
96
  anilistStatus: null,
97
+ posterUrl: null,
93
98
  nextAiringAt: null,
94
99
  });
95
100
  }
@@ -170,7 +175,7 @@ async function promptAnimeSelection(watchList) {
170
175
 
171
176
  // ── Per-anime download ─────────────────────────────────────────────────────
172
177
 
173
- async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
178
+ async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null, eventBus = null) {
174
179
  // Per-anime quality: run override > DB override > workItem > global config
175
180
  const dbAnimeRow = db.getAnimeBySiteTitle(workItem.title);
176
181
  const q = runQuality || dbAnimeRow?.override_quality || workItem.quality || config.quality || '1080p';
@@ -191,7 +196,8 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
191
196
  if (idMatch) {
192
197
  siteAnime = idMatch;
193
198
  info(`[${workItem.title}] Matched by AniList ID: ${chalk.bold(siteAnime.title)} (anilist:${siteAnime.anilistId})`);
194
- db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
199
+ const poster = workItem.posterUrl || siteAnime.poster || null;
200
+ db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, poster);
195
201
  } else {
196
202
  warn(`[${workItem.title}] AniList ID ${workItem.anilistId} not found in search results — falling back to title match.`);
197
203
  }
@@ -326,7 +332,16 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
326
332
 
327
333
  info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
328
334
 
329
- const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
335
+ if (eventBus) {
336
+ eventBus.emit('broadcast', 'anime:start', {
337
+ animeId: dbRow?.id,
338
+ title: workItem.title,
339
+ episodeCount: toDownload.length,
340
+ quality: q,
341
+ });
342
+ }
343
+
344
+ const posterUrl = workItem.posterUrl || null;
330
345
  await notify.notifyDownloadStart(cleanTitle, toDownload.length, q, posterUrl);
331
346
 
332
347
  const queue = [...toDownload];
@@ -354,17 +369,26 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
354
369
  while (!success && attempts < MAX_RETRIES) {
355
370
  attempts++;
356
371
  try {
372
+ if (eventBus) {
373
+ eventBus.emit('broadcast', 'episode:start', {
374
+ animeId: dbRow?.id,
375
+ title: cleanTitle,
376
+ episode: ep.episode,
377
+ quality: q,
378
+ });
379
+ }
380
+
357
381
  const links = await scraper.getDownloadLinks(siteAnime.session, ep.session, q);
358
382
  const link = scraper.pickLink(links, q, l);
359
383
  if (!link?.mp4Url) throw new Error('No suitable download link found');
360
384
 
361
385
  const relativeEp = epRelativeMap.get(ep.episode) || ep.episode;
362
386
  const filename = buildFilename(displayTitle, ep.episode, season, relativeEp);
363
- await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode);
387
+ await scraper.downloadEpisode(link.mp4Url, filename, outputDir, multiBar, ep.episode, eventBus, dbRow?.id, cleanTitle);
364
388
 
365
389
  db.markEpisodeStatus(dbRow.id, ep.episode, 'done', filename);
366
390
  ok(`[${cleanTitle}] EP${ep.episode} → ${filename}`);
367
- const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
391
+ const posterUrl = workItem.posterUrl || null;
368
392
  await notify.notifyEpisode(cleanTitle, relativeEp || ep.episode, q, posterUrl);
369
393
  success = true;
370
394
  results.downloaded++;
@@ -373,6 +397,15 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
373
397
  err(`[${workItem.title}] EP${ep.episode} failed: ${e.message}`);
374
398
  db.markEpisodeStatus(dbRow.id, ep.episode, 'failed', null, e.message);
375
399
  await notify.notifyFailed(workItem.title, ep.episode, e.message);
400
+ if (eventBus) {
401
+ eventBus.emit('broadcast', 'episode:failed', {
402
+ animeId: dbRow?.id,
403
+ title: cleanTitle,
404
+ episode: ep.episode,
405
+ error: e.message,
406
+ willRetry: false,
407
+ });
408
+ }
376
409
  results.failed++;
377
410
  } else {
378
411
  warn(`[${workItem.title}] EP${ep.episode} attempt ${attempts} failed, retrying...`);
@@ -384,14 +417,24 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
384
417
  });
385
418
 
386
419
  await Promise.all(workers);
420
+
421
+ if (eventBus) {
422
+ eventBus.emit('broadcast', 'anime:complete', {
423
+ animeId: dbRow?.id,
424
+ title: workItem.title,
425
+ ...results,
426
+ });
427
+ }
428
+
387
429
  return results;
388
430
  }
389
431
 
390
432
  // ── Main cycle ─────────────────────────────────────────────────────────────
391
433
 
392
- async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
434
+ async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null, eventBus = null } = {}) {
393
435
  await db.initDb();
394
436
  const config = loadConfig();
437
+ currentBus = eventBus;
395
438
 
396
439
  console.log(chalk.bold.red('\n ╔══════════════════════════════════╗'));
397
440
  console.log(chalk.bold.red(` ║ ANI-AUTO v${version} — DOWNLOAD RUN ║`));
@@ -402,9 +445,11 @@ async function runCycle({ auto = false, quality = null, language = null, range =
402
445
  : config;
403
446
  const watchList = await resolveWatchList(effectiveConfig);
404
447
 
405
- if (!watchList.length) {
448
+ if (!watchList.length) {
406
449
  warn('Watch list is empty. Add entries via AniList or `ani-auto add`.');
407
450
  db.logRun({ downloaded: 0, skipped: 0, failed: 0, notes: 'empty watch list' });
451
+ if (eventBus) eventBus.emit('broadcast', 'run:complete', { downloaded: 0, skipped: 0, failed: 0 });
452
+ currentBus = null;
408
453
  return;
409
454
  }
410
455
 
@@ -416,6 +461,8 @@ async function runCycle({ auto = false, quality = null, language = null, range =
416
461
  selected = await promptAnimeSelection(watchList);
417
462
  if (!selected.length) {
418
463
  warn('No anime selected — exiting.');
464
+ if (eventBus) eventBus.emit('broadcast', 'run:complete', { downloaded: 0, skipped: 0, failed: 0 });
465
+ currentBus = null;
419
466
  return;
420
467
  }
421
468
  }
@@ -434,7 +481,7 @@ async function runCycle({ auto = false, quality = null, language = null, range =
434
481
  const totals = { downloaded: 0, skipped: 0, failed: 0 };
435
482
 
436
483
  for (const item of selected) {
437
- const result = await processAnime(item, config, multiBar, auto, quality, language, range);
484
+ const result = await processAnime(item, config, multiBar, auto, quality, language, range, eventBus);
438
485
  totals.downloaded += result.downloaded;
439
486
  totals.skipped += result.skipped;
440
487
  totals.failed += result.failed;
@@ -450,8 +497,14 @@ async function runCycle({ auto = false, quality = null, language = null, range =
450
497
 
451
498
  db.logRun(totals);
452
499
  await notify.notifySummary(totals);
500
+
501
+ if (eventBus) {
502
+ eventBus.emit('broadcast', 'run:complete', totals);
503
+ }
504
+ currentBus = null;
505
+ return totals;
453
506
  }
454
507
 
455
508
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
456
509
 
457
- module.exports = { runCycle, resolveWatchList };
510
+ module.exports = { runCycle, resolveWatchList, processAnime };
package/src/notify.js CHANGED
@@ -3,10 +3,53 @@
3
3
  const { execSync } = require('child_process');
4
4
  const fetch = require('node-fetch');
5
5
  const { loadConfig } = require('./config');
6
- const { proxifyPosterUrl } = require('../utils');
7
6
 
8
7
  const BOT_TOKEN = '8695132702:AAEFDoSL6nkdwDOoz5wmoVkmuhaYa4tNw0o';
9
8
 
9
+ async function fetchPosterBuffer(photoUrl) {
10
+ if (!photoUrl) return null;
11
+ const res = await fetch(photoUrl, { timeout: 10000 });
12
+ if (!res.ok) {
13
+ throw new Error(`Poster fetch failed: HTTP ${res.status}`);
14
+ }
15
+ const contentType = res.headers.get('content-type') || 'image/jpeg';
16
+ const buffer = await res.buffer();
17
+ if (!buffer || !buffer.length) {
18
+ throw new Error('Poster fetch returned empty body');
19
+ }
20
+ return { buffer, contentType };
21
+ }
22
+
23
+ function buildMultipartBody(fields, fileField) {
24
+ const boundary = `----ani-auto-${Date.now().toString(16)}`;
25
+ const chunks = [];
26
+
27
+ for (const [key, value] of Object.entries(fields)) {
28
+ chunks.push(Buffer.from(
29
+ `--${boundary}\r\n` +
30
+ `Content-Disposition: form-data; name="${key}"\r\n\r\n` +
31
+ `${value}\r\n`
32
+ ));
33
+ }
34
+
35
+ if (fileField) {
36
+ chunks.push(Buffer.from(
37
+ `--${boundary}\r\n` +
38
+ `Content-Disposition: form-data; name="${fileField.name}"; filename="${fileField.filename}"\r\n` +
39
+ `Content-Type: ${fileField.contentType}\r\n\r\n`
40
+ ));
41
+ chunks.push(fileField.buffer);
42
+ chunks.push(Buffer.from('\r\n'));
43
+ }
44
+
45
+ chunks.push(Buffer.from(`--${boundary}--\r\n`));
46
+
47
+ return {
48
+ body: Buffer.concat(chunks),
49
+ boundary,
50
+ };
51
+ }
52
+
10
53
  async function sendTelegram(payload) {
11
54
  const config = loadConfig();
12
55
  const chatId = config.telegramChatId;
@@ -14,11 +57,44 @@ async function sendTelegram(payload) {
14
57
  try {
15
58
  const normalizedPayload = { ...payload };
16
59
  if (normalizedPayload.photo) {
17
- normalizedPayload.photo = proxifyPosterUrl(normalizedPayload.photo);
60
+ const poster = await fetchPosterBuffer(normalizedPayload.photo);
61
+ const { body, boundary } = buildMultipartBody(
62
+ {
63
+ chat_id: String(chatId),
64
+ parse_mode: 'HTML',
65
+ caption: normalizedPayload.caption || '',
66
+ },
67
+ {
68
+ name: 'photo',
69
+ filename: 'poster.jpg',
70
+ contentType: poster.contentType,
71
+ buffer: poster.buffer,
72
+ }
73
+ );
74
+ const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendPhoto`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
77
+ body,
78
+ timeout: 15000,
79
+ });
80
+ const text = await res.text();
81
+ if (!res.ok) {
82
+ throw new Error(`Telegram API ${res.status}: ${text.slice(0, 200)}`);
83
+ }
84
+ if (text) {
85
+ try {
86
+ const data = JSON.parse(text);
87
+ if (data.ok === false) {
88
+ throw new Error(`Telegram API rejected message: ${data.description || 'unknown error'}`);
89
+ }
90
+ } catch (err) {
91
+ if (err.message.startsWith('Telegram API rejected message:')) throw err;
92
+ }
93
+ }
94
+ return;
18
95
  }
19
96
 
20
- const endpoint = normalizedPayload.photo ? 'sendPhoto' : 'sendMessage';
21
- const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/${endpoint}`, {
97
+ const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
22
98
  method: 'POST',
23
99
  headers: { 'Content-Type': 'application/json' },
24
100
  body: JSON.stringify({ chat_id: String(chatId), parse_mode: 'HTML', ...normalizedPayload }),
@@ -46,14 +122,13 @@ async function sendTelegram(payload) {
46
122
  async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
47
123
  try {
48
124
  let icon = '';
49
- const imageUrl = proxifyPosterUrl(posterUrl);
50
- if (imageUrl) {
125
+ if (posterUrl) {
51
126
  const os = require('os');
52
127
  const path = require('path');
53
128
  const fs = require('fs');
54
129
  const tmp = path.join(os.tmpdir(), 'ani-auto-poster.jpg');
55
130
  try {
56
- const res = await fetch(imageUrl);
131
+ const res = await fetch(posterUrl);
57
132
  fs.writeFileSync(tmp, await res.buffer());
58
133
  icon = `-i "${tmp}"`;
59
134
  } catch (e) {}
package/src/scraper.js CHANGED
@@ -119,8 +119,8 @@ function pickLink(links, quality, language) {
119
119
  );
120
120
  }
121
121
 
122
- async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
123
- await downloadFile(url, filename, outputDir, multiBar, episodeNum);
122
+ async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum, eventBus = null, animeId = null, animeTitle = null) {
123
+ await downloadFile(url, filename, outputDir, multiBar, episodeNum, eventBus, animeId, animeTitle);
124
124
  }
125
125
 
126
126
  module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
package/src/server.js ADDED
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const http = require('http');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { WebSocketServer } = require('ws');
8
+ const cors = require('cors');
9
+
10
+ const configRouter = require('./api/config');
11
+ const anilistRouter = require('./api/anilist');
12
+ const animeRouter = require('./api/anime');
13
+ const matchRouter = require('./api/match');
14
+ const downloadRouter = require('./api/download');
15
+ const runsRouter = require('./api/runs');
16
+ const daemonRouter = require('./api/daemon');
17
+ const updateRouter = require('./api/update');
18
+
19
+ const EventEmitter = require('events');
20
+ const downloadBus = new EventEmitter();
21
+ downloadBus.setMaxListeners(100);
22
+
23
+ const PORT = process.env.PORT || 2640;
24
+ const CLIENT_DIR = path.join(__dirname, '..', 'web', 'dist');
25
+
26
+ const app = express();
27
+ app.use(cors());
28
+ app.use(express.json());
29
+
30
+ app.use('/api/config', configRouter);
31
+ app.use('/api/anilist', anilistRouter);
32
+ app.use('/api/anime', animeRouter);
33
+ app.use('/api/match', matchRouter);
34
+ app.use('/api/runs', runsRouter);
35
+ app.use('/api/daemon', daemonRouter);
36
+ app.use('/api/download', downloadRouter(downloadBus));
37
+ app.use('/api/update', updateRouter);
38
+
39
+ const cfBypassRouter = require('./api/cf-bypass');
40
+ app.use('/api', cfBypassRouter);
41
+
42
+ const paheRouter = require('../anime/app');
43
+ const PaheConfig = require('../anime/utils/config');
44
+ const savedConfig = require('./config').loadConfig();
45
+ if (savedConfig.animepaheCfClearance) {
46
+ PaheConfig.setCfClearance(savedConfig.animepaheCfClearance);
47
+ }
48
+ if (savedConfig.animepaheUserAgent) {
49
+ PaheConfig.userAgent = savedConfig.animepaheUserAgent;
50
+ }
51
+ app.use('/api/pahe', paheRouter);
52
+
53
+ app.use(express.static(CLIENT_DIR));
54
+ app.use((req, res) => {
55
+ if (req.path.startsWith('/api')) return res.status(404).json({ error: 'not found' });
56
+ res.sendFile(path.join(CLIENT_DIR, 'index.html'), (err) => {
57
+ if (err) res.status(404).json({ error: 'not found' });
58
+ });
59
+ });
60
+
61
+ const server = http.createServer(app);
62
+ const wss = new WebSocketServer({ server, path: '/ws' });
63
+
64
+ wss.on('connection', (ws) => {
65
+ ws.on('message', (data) => {
66
+ try {
67
+ const msg = JSON.parse(data);
68
+ if (msg.type === 'download:start') {
69
+ downloadBus.emit('ws:start', msg, ws);
70
+ }
71
+ if (msg.type === 'download:cancel') {
72
+ downloadBus.emit('ws:cancel');
73
+ }
74
+ if (msg.type === 'download:pause') {
75
+ downloadBus.emit('ws:pause');
76
+ }
77
+ if (msg.type === 'download:resume') {
78
+ downloadBus.emit('ws:resume');
79
+ }
80
+ } catch (e) {}
81
+ });
82
+ });
83
+
84
+ function broadcast(event, data) {
85
+ const payload = JSON.stringify({ type: event, ...data });
86
+ wss.clients.forEach((client) => {
87
+ if (client.readyState === 1) client.send(payload);
88
+ });
89
+ }
90
+
91
+ app.locals.broadcast = broadcast;
92
+
93
+ downloadBus.on('broadcast', (event, data) => broadcast(event, data));
94
+
95
+ function startServer() {
96
+ server.listen(PORT, () => {
97
+ console.log(`ani-auto web ui running at http://localhost:${PORT}`);
98
+ });
99
+ }
100
+
101
+ process.on('SIGINT', () => process.exit(0));
102
+ process.on('SIGTERM', () => process.exit(0));
103
+
104
+ module.exports = { startServer, downloadBus, broadcast, app };
105
+
106
+ if (require.main === module) {
107
+ startServer();
108
+ }