forked from MrMonkey42/stremio-addon-debrid-search
-
-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathserver.js
More file actions
3640 lines (3120 loc) · 163 KB
/
Copy pathserver.js
File metadata and controls
3640 lines (3120 loc) · 163 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { overrideConsole } from './lib/util/logger.js';
import { memoryMonitor } from './lib/util/memory-monitor.js';
import serverless from './serverless.js';
import requestIp from 'request-ip';
import rateLimit from 'express-rate-limit';
import swStats from 'swagger-stats';
import cluster from 'cluster';
import addonInterface from "./addon.js";
import streamProvider from './lib/stream-provider.js';
import * as sqliteCache from './lib/util/cache-store.js';
import * as sqliteHashCache from './lib/util/hash-cache-store.js';
import http from 'http';
import https from 'https';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import Usenet from './lib/usenet.js';
import { resolveHttpStreamUrl } from './lib/http-streams.js';
import { resolveUHDMoviesUrl } from './lib/uhdmovies.js';
import { encodeUrlForStreaming } from './lib/http-streams/utils/encoding.js';
import searchCoordinator from './lib/util/search-coordinator.js';
import * as scraperPerformance from './lib/util/scraper-performance.js';
import personalFilesCache from './lib/util/personal-files-cache.js';
import Newznab from './lib/newznab.js';
import SABnzbd from './lib/sabnzbd.js';
import crypto from 'crypto';
import { obfuscateSensitive } from './lib/common/torrent-utils.js';
import { getManifest } from './lib/util/manifest.js';
import landingTemplate from './lib/util/landingTemplate.js';
import fetch from 'node-fetch';
import debridProxyManager from './lib/util/debrid-proxy.js';
import { spawn } from 'child_process';
const execFileAsync = promisify(execFile);
async function resolveHttpStreamUrlInSubprocess(url) {
if (!url) return null;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[
'--input-type=module',
'-e',
"globalThis.File = class File {}; const mod = await import('./lib/http-streams/resolvers/http-resolver.js'); const resolved = await mod.resolveHttpStreamUrl(process.argv[1]); console.log(JSON.stringify({ resolved })); process.exit(0);",
url
],
{
cwd: process.cwd(),
env: {
...process.env,
HTTP_RESOLVE_SUBPROCESS: '1',
DEBRID_HTTP_PROXY: '',
DEBRID_PER_SERVICE_PROXIES: '',
DEBRID_PROXY_SERVICES: '*:false'
},
timeout: parseInt(process.env.SHORTLINK_SUBPROCESS_TIMEOUT_MS || '180000', 10),
maxBuffer: 10 * 1024 * 1024
}
);
const jsonLine = String(stdout || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.reverse()
.find(line => line.startsWith('{') && line.endsWith('}'));
const parsed = JSON.parse(jsonLine || '{}');
const resolved = typeof parsed?.resolved === 'string' && parsed.resolved ? parsed.resolved : null;
if (resolved) {
console.log(`[HTTP-RESOLVER] Subprocess shortlink resolve succeeded on attempt ${attempt}`);
return resolved;
}
const stdoutTail = String(stdout || '').split(/\r?\n/).slice(-10).join('\n');
const stderrTail = String(stderr || '').split(/\r?\n/).slice(-10).join('\n');
console.log(`[HTTP-RESOLVER] Subprocess shortlink resolve returned no URL on attempt ${attempt}. Stdout tail:\n${stdoutTail}`);
if (stderrTail) {
console.log(`[HTTP-RESOLVER] Subprocess shortlink stderr tail:\n${stderrTail}`);
}
} catch (error) {
console.error(`[HTTP-RESOLVER] Subprocess shortlink resolve failed on attempt ${attempt}: ${error.message}`);
}
if (attempt < 2) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
return null;
}
// Bot detection and anti-scraping utilities
const BOT_USER_AGENTS = [
/bot/i,
/crawler/i,
/spider/i,
/slurp/i,
/teoma/i,
/heritrix/i,
/setoozbot/i,
/discobot/i,
/purebot/i,
/yacybot/i,
/acoonbot/i,
/findlink/i,
/linkedinbot/i,
/embedly/i,
/quora link preview/i,
/ahrefsbot/i,
/siteexplorer/i,
/majestic12/i,
/oozbot/i,
/netcraft/i,
/trendiction/i,
/dbot/i,
/seznambot/i,
/ec2linkfinder/i,
/gslfbot/i,
/aihitbot/i,
/intelium_bot/i,
/facebookexternalhit/i,
/yeti/i,
/retrevo/i,
/silk/i,
/ltbot/i,
/pinterest/i,
/telegrambot/i,
/tumblr/i,
/redditbot/i,
/slackbot/i,
/whatsapp/i,
/discordbot/i,
/go-http-client/i,
/python-requests/i,
/axios/i,
/node-fetch/i,
/php-curl/i,
/java/i,
/okhttp/i,
/curl/i,
/wget/i
];
// Track suspicious IPs with detailed pattern analysis (in-memory for now, could be extended to Redis/DB)
const suspiciousIPs = new Map(); // ip -> { count, firstSeen, isBlocked, requestHistory, lastRequestTime }
const BLOCK_THRESHOLD = 20; // Increased threshold to reduce false positives (was 15)
const UNBLOCK_AFTER = 5 * 60 * 1000; // Reduced to 5 minutes (was 10 minutes) to reduce IP blocking duration
const REQUEST_WINDOW = 5 * 60 * 1000; // 5 minutes for pattern analysis
const FAST_REQUEST_THRESHOLD = 1000; // 1 second - for detecting rapid requests
const SUSPICIOUS_IPS_MAX_SIZE = 10000; // Prevent unbounded growth of IP tracking map
// Periodic cleanup of stale IP tracking entries to prevent memory leak / CPU waste
setInterval(() => {
const now = Date.now();
let cleaned = 0;
for (const [ip, record] of suspiciousIPs.entries()) {
// Remove entries that haven't been seen in the request window and aren't blocked
if (!record.isBlocked && (now - record.lastRequestTime > REQUEST_WINDOW)) {
suspiciousIPs.delete(ip);
cleaned++;
}
}
// Emergency eviction if still too large
if (suspiciousIPs.size > SUSPICIOUS_IPS_MAX_SIZE) {
const excess = suspiciousIPs.size - SUSPICIOUS_IPS_MAX_SIZE;
const keys = suspiciousIPs.keys();
for (let i = 0; i < excess; i++) {
const key = keys.next().value;
if (key) suspiciousIPs.delete(key);
}
cleaned += excess;
}
if (cleaned > 0) {
console.log(`[BOT-DETECTION] Cleaned ${cleaned} stale IP entries (remaining: ${suspiciousIPs.size})`);
}
}, 60000); // Every 60 seconds
// Bot detection middleware with pattern analysis
function botDetectionMiddleware(req, res, next) {
// Allow resolver endpoints to bypass bot detection (needed for HLS segment spam)
if (req.path.startsWith('/resolve/httpstreaming')) {
return next();
}
const clientIp = req.clientIp || requestIp.getClientIp(req);
req.clientIp = clientIp;
const userAgent = req.get('User-Agent') || '';
const acceptHeader = req.get('Accept') || '';
const acceptEncoding = req.get('Accept-Encoding') || '';
const currentTime = Date.now();
let suspiciousScore = 0;
const reasons = [];
// Removed User-Agent bot pattern check as it was blocking too many legitimate requests
// Some legitimate clients have User-Agent strings that match bot patterns
// Removed missing User-Agent check as it was blocking too many legitimate requests
// Many browsers and clients don't always send User-Agent or send empty values
// Removed Accept header check as it was flagging legitimate browser requests
// Many browsers and clients send generic Accept headers on resolver endpoints
// Removed Accept-Language check as it was blocking too many legitimate requests
// Many browsers and clients don't always send Accept-Language header
// Make Stremio User-Agent check less strict - allow other valid clients
// Removed the strict check for Stremio in resolver endpoints to be less restrictive
// Request pattern analysis - made more lenient
let ipRecord = suspiciousIPs.get(clientIp) || {
count: 0,
firstSeen: currentTime,
isBlocked: false,
requestHistory: [],
lastRequestTime: 0 // Set to 0 so first request isn't flagged as rapid
};
// Add current request to history
ipRecord.requestHistory.push({
time: currentTime,
path: req.path,
method: req.method
});
// Clean old requests from history (older than 5 minutes) and cap array size
ipRecord.requestHistory = ipRecord.requestHistory.filter(
req => currentTime - req.time < REQUEST_WINDOW
);
// Hard cap to prevent runaway memory/CPU in request history scanning
if (ipRecord.requestHistory.length > 200) {
ipRecord.requestHistory = ipRecord.requestHistory.slice(-200);
}
// Check for high frequency requests - made more lenient
const recentRequests = ipRecord.requestHistory.filter(
r => currentTime - r.time < 10000 // last 10 seconds
);
if (recentRequests.length > 50) { // Increased threshold from 25 (was too restrictive)
suspiciousScore += 2;
reasons.push(`High frequency: ${recentRequests.length} requests in 10s`);
} else if (recentRequests.length > 30) { // Increased threshold from 15 (was too restrictive)
suspiciousScore += 1;
reasons.push(`Moderate frequency: ${recentRequests.length} requests in 10s`);
}
// Check for rapid successive requests - made more lenient (allow 200ms instead of 100ms)
const timeSinceLastRequest = currentTime - ipRecord.lastRequestTime;
if (timeSinceLastRequest < 20) { // Only flag if <20ms (was <50ms) - increased to be more permissive
suspiciousScore += 1;
reasons.push(`Very rapid requests: ${timeSinceLastRequest}ms interval`);
}
// Check for pattern of accessing many different resolver endpoints quickly - made much more lenient
const resolverRequests = ipRecord.requestHistory.filter(r => r.path.startsWith('/resolve/'));
const uniqueResolverPaths = new Set(resolverRequests.map(r => r.path)).size;
if (resolverRequests.length > 60 && uniqueResolverPaths > 30) { // Increased significantly (was 30 and 15)
suspiciousScore += 2;
reasons.push(`Multiple resolver endpoints accessed: ${uniqueResolverPaths} unique paths`);
}
// Check for sequential or patterned access - made much more lenient
if (resolverRequests.length > 20) { // Increased from 10 (was too sensitive)
// Only check if there's a high number of resolver requests
const pathPatterns = resolverRequests.map(r => r.path);
// Count how many paths are similar (indicating systematic scraping)
let similarCount = 0;
for (let i = 0; i < pathPatterns.length - 1; i++) {
if (pathPatterns[i] !== pathPatterns[i + 1]) {
// Check if paths are very similar (potential scraping pattern)
const currentPath = pathPatterns[i];
const nextPath = pathPatterns[i + 1];
// If the paths are in the same endpoint family but different params, it could be scraping
if (currentPath.split('/')[1] === nextPath.split('/')[1]) {
similarCount++;
}
}
}
if (similarCount > 15) { // Increased from 8 (was too sensitive)
suspiciousScore += 1;
reasons.push(`Patterned access: ${similarCount} similar endpoints`);
}
}
// Add current score to IP record
ipRecord.count += suspiciousScore;
ipRecord.lastRequestTime = currentTime;
// Block if score exceeds threshold (increased to reduce false positives)
if (ipRecord.count >= BLOCK_THRESHOLD && !ipRecord.isBlocked) {
ipRecord.isBlocked = true;
console.log(`[BOT-DETECTION] Blocking IP ${clientIp} for suspicious activity: ${reasons.join(', ')}`);
// Set timeout to unblock after period
setTimeout(() => {
if (suspiciousIPs.has(clientIp)) {
suspiciousIPs.delete(clientIp);
}
}, UNBLOCK_AFTER);
}
// If IP is blocked, reject the request
if (ipRecord.isBlocked) {
console.log(`[BOT-DETECTION] Rejecting request from blocked IP ${clientIp}`);
return res.status(429).json({
success: false,
message: 'Request blocked due to suspicious activity. Please try again later.'
});
}
// Update IP record
suspiciousIPs.set(clientIp, ipRecord);
if (suspiciousScore > 0) {
console.log(`[BOT-DETECTION] Suspicious request from ${clientIp}: score=${suspiciousScore}, reasons=[${reasons.join(', ')}], UA="${userAgent}"`);
}
next();
}
// Ensure data directory exists before other imports
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
console.log(`[SERVER] Creating data directory: ${dataDir}`);
fs.mkdirSync(dataDir, { recursive: true });
console.log(`[SERVER] Created data directory: ${dataDir}`);
} else {
console.log(`[SERVER] Data directory already exists: ${dataDir}`);
}
// Cache backend selection (sqlite default, postgres optional)
const cacheBackend = (process.env.CACHE_BACKEND || 'sqlite').toLowerCase();
const cacheBackendLabel = cacheBackend === 'postgres' ? 'Postgres' : 'SQLite';
console.log(`[CACHE] Using ${cacheBackendLabel} for caching`);
// Override console to respect LOG_LEVEL environment variable
overrideConsole();
// CRITICAL: Global error handlers to prevent memory leaks from unhandled errors (worker processes)
process.on('unhandledRejection', (reason, promise) => {
console.error('[WORKER-CRITICAL] Unhandled Promise Rejection:', reason?.message || reason);
// Don't log the full error object to avoid retaining large response bodies in memory
});
process.on('uncaughtException', (error) => {
console.error('[WORKER-CRITICAL] Uncaught Exception:', error?.message || error);
// Don't log the full error object to avoid retaining large response bodies in memory
});
// Import compression if available, otherwise provide a no-op middleware
let compression = null;
let compressionFilter = null;
try {
const compressionModule = await import('compression');
compression = compressionModule.default;
compressionFilter = compressionModule.filter;
} catch (e) {
console.warn('Compression middleware not available, using no-op middleware');
compression = () => (req, res, next) => next(); // No-op if compression not available
compressionFilter = () => true;
}
// Function to check memory usage and clear caches if needed
function checkMemoryUsage() {
const memoryUsage = process.memoryUsage();
const rssInMB = memoryUsage.rss / 1024 / 1024;
const heapUsedInMB = memoryUsage.heapUsed / 1024 / 1024;
// If we're using more than 700MB RSS or 400MB heap, log a warning and consider cleanup
if (rssInMB > 700 || heapUsedInMB > 400) {
console.warn(`[MEMORY] High memory usage - RSS: ${rssInMB.toFixed(2)}MB, Heap: ${heapUsedInMB.toFixed(2)}MB`);
return true; // Indicate high memory usage
}
return false; // Memory usage is OK
}
// MEMORY LEAK FIX: Add size limits and proper cleanup for URL caches
// Using in-memory cache with SQLite for persistence
const RESOLVED_URL_CACHE = new Map();
const RESOLVED_URL_CACHE_MAX_SIZE = 500; // Reduced from 2000 to prevent memory issues
const CACHE_TIMERS = new Map(); // Track setTimeout IDs for proper cleanup
const PENDING_RESOLVES = new Map();
const PENDING_RESOLVES_MAX_SIZE = 100; // Reduced from 1000 to prevent memory issues
// Helper function to evict oldest cache entry (LRU-style FIFO eviction)
function evictOldestCacheEntry() {
if (RESOLVED_URL_CACHE.size >= RESOLVED_URL_CACHE_MAX_SIZE) {
const firstKey = RESOLVED_URL_CACHE.keys().next().value;
RESOLVED_URL_CACHE.delete(firstKey);
// Clear associated timer to prevent memory leak
const timerId = CACHE_TIMERS.get(firstKey);
if (timerId) {
clearTimeout(timerId);
CACHE_TIMERS.delete(firstKey);
}
console.log(`[CACHE] Evicted oldest entry (cache size: ${RESOLVED_URL_CACHE.size})`);
}
}
// Helper function to set cache with proper timer tracking
async function setCacheWithTimer(cacheKey, value, ttlMs) {
// Evict old entries if needed
evictOldestCacheEntry();
// Clear existing timer if re-caching
const existingTimer = CACHE_TIMERS.get(cacheKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set cache value in local memory
RESOLVED_URL_CACHE.set(cacheKey, value);
// Set new timer and track it
const timerId = setTimeout(() => {
RESOLVED_URL_CACHE.delete(cacheKey);
CACHE_TIMERS.delete(cacheKey);
}, ttlMs);
CACHE_TIMERS.set(cacheKey, timerId);
}
// Helper function to get cached value from local cache
async function getCacheValue(cacheKey) {
if (RESOLVED_URL_CACHE.has(cacheKey)) {
const value = RESOLVED_URL_CACHE.get(cacheKey);
console.log(`[CACHE] Cache hit for key: ${cacheKey.substring(0, 8)}...`);
return value;
}
return null;
}
function normalizeClientIp(rawIp) {
if (!rawIp) return '';
const ip = String(rawIp).trim();
if (!ip) return '';
return ip.split(',')[0].trim();
}
function buildResolverScopeKey(debridProvider, debridApiKey, decodedUrl, clientIp) {
const urlHash = crypto.createHash('md5').update(decodedUrl).digest('hex');
const tokenHash = crypto.createHash('sha256').update(String(debridApiKey || '')).digest('hex').slice(0, 16);
const normalizedIp = normalizeClientIp(clientIp) || 'no-ip';
const ipHash = crypto.createHash('sha256').update(normalizedIp).digest('hex').slice(0, 16);
return `${debridProvider}:${urlHash}:${tokenHash}:${ipHash}`;
}
const app = express();
// Cache client IP on the request once to avoid repeated lookups in downstream middleware
app.use((req, res, next) => {
if (!req.clientIp) {
req.clientIp = requestIp.getClientIp(req);
}
next();
});
app.get('/', (req, res) => {
res.redirect('/configure');
});
app.get('/configure', async (req, res) => {
const manifest = getManifest({}, true);
res.send(await landingTemplate(manifest, {}));
});
// HTTP Streams Health Status — cached in memory so last results survive restarts
// (files in data/ persist on disk; memory cache is a fast-path fallback)
const healthStatusDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'data');
const healthStatusCache = { md: null, json: null };
function readHealthFile(filename) {
const filePath = path.join(healthStatusDir, filename);
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
// Update in-memory cache whenever we successfully read from disk
const key = filename.endsWith('.json') ? 'json' : 'md';
healthStatusCache[key] = content;
return content;
}
} catch { /* fall through to cache */ }
// Return cached version if disk read fails
const key = filename.endsWith('.json') ? 'json' : 'md';
return healthStatusCache[key];
}
// Pre-load cache from disk on startup (survives reboot as long as data/ persists)
try {
readHealthFile('http-streams-status.md');
readHealthFile('http-streams-status.json');
} catch { /* ignore — first run */ }
// HTTP Streams Health Check Dashboard
app.get('/health', (req, res) => {
try {
const dashboardPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'scripts', 'health-dashboard.html');
const html = fs.readFileSync(dashboardPath, 'utf8');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (err) {
res.status(500).json({ error: 'Dashboard not found', message: err.message });
}
});
// HTTP Streams Status (Markdown)
app.get('/http-streams-status', (req, res) => {
const content = readHealthFile('http-streams-status.md');
if (!content) {
return res.status(404).json({ error: 'Health check has not run yet' });
}
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.send(content);
});
// HTTP Streams Status (JSON)
app.get('/http-streams-status.json', (req, res) => {
const content = readHealthFile('http-streams-status.json');
if (!content) {
return res.status(404).json({ error: 'Health check has not run yet' });
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(content);
});
app.get('/manifest-no-catalogs.json', (req, res) => {
const manifest = getManifest({}, true);
res.json(manifest);
});
// Track active Usenet streams: nzoId -> { lastAccess, streamCount, config, videoFilePath, usenetConfig }
const ACTIVE_USENET_STREAMS = new Map();
/**
* Stream error video from Python server (proxy through Node)
* TVs and some video players don't follow 302 redirects, so we proxy instead
* @param {string} errorText - The error message to display
* @param {object} res - Express response object
* @param {string} fileServerUrl - Python file server URL
*/
async function redirectToErrorVideo(errorText, res, fileServerUrl) {
console.log(`[ERROR-VIDEO] Streaming error video: "${errorText}"`);
try {
const axios = (await import('axios')).default;
// URL-encode the error message
const encodedMessage = encodeURIComponent(errorText);
// Construct error video URL on Python server
const errorUrl = `${fileServerUrl.replace(/\/$/, '')}/error?message=${encodedMessage}`;
console.log(`[ERROR-VIDEO] Fetching from: ${errorUrl}`);
// Fetch the error video from Python server
const response = await axios({
method: 'GET',
url: errorUrl,
responseType: 'stream',
timeout: 30000
});
// Copy headers from Python server
res.status(200);
res.set('Content-Type', response.headers['content-type'] || 'video/mp4');
if (response.headers['content-length']) {
res.set('Content-Length', response.headers['content-length']);
}
res.set('Accept-Ranges', 'bytes');
res.set('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
// Pipe the video stream to the client
// Note: pipe() automatically ends the response when the source stream ends
response.data.pipe(res);
// Log when streaming completes
response.data.on('end', () => {
console.log(`[ERROR-VIDEO] ✓ Finished streaming error video`);
});
// Handle errors during streaming
response.data.on('error', (err) => {
console.error(`[ERROR-VIDEO] Stream error: ${err.message}`);
// CRITICAL: Destroy the stream on error to prevent memory leak
if (response.data && typeof response.data.destroy === 'function') {
response.data.destroy();
}
if (!res.headersSent) {
res.status(500).end();
}
});
// CRITICAL: Clean up stream when client disconnects
res.on('close', () => {
if (response.data && typeof response.data.destroy === 'function') {
response.data.destroy();
}
});
} catch (error) {
console.error(`[ERROR-VIDEO] Failed to fetch error video: ${error.message}`);
if (!res.headersSent) {
res.status(500).send(`Error: ${errorText}`);
}
}
}
// Note: Proxy requests removed - we now use direct 302 redirects to Python file server
// This eliminates proxy overhead and allows proper client disconnect detection
// Store Usenet configs globally (so auto-clean works even without active streams)
const USENET_CONFIGS = new Map(); // fileServerUrl -> config
// Track pending Usenet submissions to prevent race conditions
const PENDING_USENET_SUBMISSIONS = new Map(); // title -> Promise
// Cleanup interval for inactive streams (check every 2 minutes)
const STREAM_CLEANUP_INTERVAL = 2 * 60 * 1000;
// Delete downloads after 10 minutes of inactivity
// This is aggressive to save bandwidth and disk space
// If user was just paused/buffering, they can restart the stream
const STREAM_INACTIVE_TIMEOUT = 10 * 60 * 1000; // 10 minutes of inactivity
// Performance: Set up connection pooling and reuse
app.set('trust proxy', true); // Trust proxy headers if behind reverse proxy
app.set('etag', false); // Disable etag generation for static performance
app.use(cors());
// Event loop overload protection - prevents death spiral under heavy load
// Monitors event loop lag and returns 503 when the server is overwhelmed
const OVERLOAD_LAG_THRESHOLD_MS = parseInt(process.env.OVERLOAD_LAG_THRESHOLD || '2000', 10);
const OVERLOAD_CHECK_INTERVAL_MS = 500;
let eventLoopLag = 0;
let overloadCheckTimer = null;
function startOverloadMonitor() {
let lastCheck = process.hrtime.bigint();
overloadCheckTimer = setInterval(() => {
const now = process.hrtime.bigint();
const elapsed = Number(now - lastCheck) / 1e6; // ms
eventLoopLag = Math.max(0, elapsed - OVERLOAD_CHECK_INTERVAL_MS);
lastCheck = now;
}, OVERLOAD_CHECK_INTERVAL_MS);
if (overloadCheckTimer.unref) overloadCheckTimer.unref();
}
startOverloadMonitor();
const OVERLOAD_SKIP_PREFIXES = ['/configure', '/manifest.json', '/manifest-no-catalogs.json'];
app.use((req, res, next) => {
if (eventLoopLag > OVERLOAD_LAG_THRESHOLD_MS) {
// Allow lightweight endpoints through even under load
if (req.path === '/' || OVERLOAD_SKIP_PREFIXES.some(p => req.path === p || req.path.startsWith(p))) {
return next();
}
console.error(`[OVERLOAD] Rejecting request (lag: ${Math.round(eventLoopLag)}ms): ${req.method} ${req.path.substring(0, 80)}`);
res.status(503).json({ streams: [], err: 'Server overloaded, please retry shortly' });
return;
}
next();
});
// Anti-bot detection middleware
app.use(botDetectionMiddleware);
const COMPRESSION_SKIP_PREFIXES = ['/resolve', '/usenet']; // Skip compression for streaming/redirect-heavy paths to save CPU
// Performance: Add compression for API responses
app.use(compression({
level: 6, // Balanced compression level
threshold: 1024, // Only compress responses larger than 1KB
filter: (req, res) => {
if (COMPRESSION_SKIP_PREFIXES.some(prefix => req.path.startsWith(prefix))) {
return false;
}
return compressionFilter(req, res);
}
}));
// Swagger stats middleware - disabled by default to save CPU under load
// Set SWAGGER_STATS_ENABLED=true in .env to re-enable
if (process.env.SWAGGER_STATS_ENABLED === 'true') {
app.use(swStats.getMiddleware({
name: addonInterface.manifest.name,
version: addonInterface.manifest.version,
}));
console.log('[SERVER] swagger-stats enabled');
}
// Global rate limiter - more permissive limits
const globalRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 400, // Increased from 200 to 400 requests per window
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.clientIp || requestIp.getClientIp(req),
message: {
success: false,
message: 'Too many requests from this IP, please try again later.'
},
skip: (req) => {
// Skip rate limiting for health checks and internal endpoints
const skipPaths = ['/configure', '/manifest-no-catalogs.json', '/'];
return skipPaths.includes(req.path);
}
});
// Specific rate limiter for resolve endpoints (more permissive limits)
const resolveRateLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
limit: 80, // Increased from 40 to 80 resolve requests per window
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.clientIp || requestIp.getClientIp(req),
message: {
success: false,
message: 'Too many resolve requests from this IP, please try again later.'
}
});
// Graceful shutdown - properly close all connections
let isShuttingDown = false;
for (const sig of ["SIGINT","SIGTERM"]) {
process.on(sig, async () => {
if (isShuttingDown) return; // Prevent multiple shutdown attempts
isShuttingDown = true;
console.log(`[SERVER] Received ${sig}. Shutting down gracefully...`);
// Clear all intervals and timeouts
try {
if (cleanupIntervalId) clearInterval(cleanupIntervalId);
if (autoCleanIntervalId) clearInterval(autoCleanIntervalId);
if (autoCleanTimeoutId) clearTimeout(autoCleanTimeoutId);
if (monitorIntervalId) clearInterval(monitorIntervalId);
// MEMORY LEAK FIX: Clear all pending cache timers
for (const timerId of CACHE_TIMERS.values()) {
clearTimeout(timerId);
}
CACHE_TIMERS.clear();
console.log('[SERVER] All intervals, timeouts, and cache timers cleared');
} catch (error) {
console.error(`[SERVER] Error clearing intervals: ${error.message}`);
}
// Close SQLite connections
try {
await Promise.all([
sqliteCache.closeSqlite(),
sqliteHashCache.closeConnection()
]);
console.log('[SERVER] All SQLite connections closed');
} catch (error) {
console.error(`[SERVER] Error closing SQLite connections: ${error.message}`);
}
// MEMORY LEAK FIX: Shutdown additional modules with cleanup intervals
try {
searchCoordinator.shutdown();
scraperPerformance.shutdown();
Usenet.shutdown();
console.log('[SERVER] All module cleanup intervals stopped');
} catch (error) {
console.error(`[SERVER] Error shutting down modules: ${error.message}`);
}
// Close HTTP server
server.close(() => {
console.log('[SERVER] HTTP server closed');
process.exit(0);
});
// Force exit after 10 seconds if graceful shutdown fails
setTimeout(() => {
console.error('[SERVER] Forced shutdown after timeout');
process.exit(1);
}, 10000).unref();
});
}
app.use(globalRateLimiter);
// VVVV REVERTED: The resolver now performs a simple redirect VVVV
app.get('/resolve/:debridProvider/:debridApiKey/:url', resolveRateLimiter, async (req, res) => {
const { debridProvider, debridApiKey, url } = req.params;
// Validate required parameters
if (!url || url === 'undefined') {
console.error('[RESOLVER] Missing or invalid URL parameter');
return res.status(400).send('Missing or invalid URL parameter');
}
const decodedUrl = decodeURIComponent(url);
const clientIp = normalizeClientIp(req.clientIp || requestIp.getClientIp(req));
req.clientIp = clientIp;
// Extract config from query if provided (for NZB resolution)
const configParam = req.query.config;
let config = {};
if (configParam) {
try {
// Safe parsing with memory and size limits
const decodedConfigParam = decodeURIComponent(configParam);
// Check size before parsing to prevent memory issues
if (decodedConfigParam.length > 100000) { // 100KB limit
console.log('[RESOLVER] Config parameter too large, rejecting');
return res.status(400).send('Config parameter too large');
}
config = JSON.parse(decodedConfigParam);
} catch (e) {
console.log('[RESOLVER] Failed to parse config from query', e.message);
}
}
const cacheKey = typeof req.query.cacheKey === 'string' ? req.query.cacheKey : null;
const cacheHash = typeof req.query.cacheHash === 'string' ? req.query.cacheHash : null;
if (cacheKey && cacheKey.length < 512) {
config.cacheKey = cacheKey;
}
if (cacheHash && cacheHash.length < 128) {
config.cacheHash = cacheHash;
}
// Scope resolver cache/in-flight dedupe by provider + URL + requester token + requester IP.
// This prevents cross-user token/IP reuse when two users resolve the same source URL.
const resolverCacheKey = buildResolverScopeKey(debridProvider, debridApiKey, decodedUrl, clientIp);
try {
let finalUrl;
const cachedValue = await getCacheValue(resolverCacheKey);
if (cachedValue) {
// Handle case where cachedValue might be an object from UHDMovies resolver
if (cachedValue && typeof cachedValue === 'object' && cachedValue.url) {
finalUrl = cachedValue.url;
} else {
finalUrl = cachedValue;
}
console.log(`[CACHE] Using cached URL for key: ${resolverCacheKey.substring(0, 16)}...`);
} else if (PENDING_RESOLVES.has(resolverCacheKey)) {
console.log(`[RESOLVER] Joining in-flight resolve for key: ${resolverCacheKey.substring(0, 16)}...`);
finalUrl = await PENDING_RESOLVES.get(resolverCacheKey);
// Handle case where finalUrl might be an object from UHDMovies resolver
if (finalUrl && typeof finalUrl === 'object' && finalUrl.url) {
finalUrl = finalUrl.url;
}
} else {
console.log(`[RESOLVER] Cache miss. Resolving URL for ${debridProvider}`);
const resolvePromise = streamProvider.resolveUrl(debridProvider, debridApiKey, null, decodedUrl, clientIp, config);
// Set a configurable timeout for performance tuning - increase for NZB downloads
const isNzb = decodedUrl.startsWith('nzb:');
const timeoutMs = isNzb ? 600000 : parseInt(process.env.RESOLVE_TIMEOUT || '20000', 10); // 10 min for NZB, 20s otherwise
const timedResolve = Promise.race([
resolvePromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Resolve timeout')), timeoutMs)
)
]);
// MEMORY LEAK FIX: Limit pending requests to prevent unbounded growth
if (PENDING_RESOLVES.size >= PENDING_RESOLVES_MAX_SIZE) {
const oldestKey = PENDING_RESOLVES.keys().next().value;
const oldestPromise = PENDING_RESOLVES.get(oldestKey);
// Cancel the oldest pending request if possible
PENDING_RESOLVES.delete(oldestKey);
console.log(`[RESOLVER] Evicted oldest pending request (size: ${PENDING_RESOLVES.size})`);
}
// Track the pending request
const pendingRequest = timedResolve.catch(err => {
console.error(`[RESOLVER] Pending resolve failed: ${err.message}`);
return null;
}).finally(() => {
PENDING_RESOLVES.delete(resolverCacheKey);
});
PENDING_RESOLVES.set(resolverCacheKey, pendingRequest);
finalUrl = await pendingRequest;
if (finalUrl) {
// Handle case where finalUrl might be an object from UHDMovies resolver that has .url property
let cacheUrl;
if (typeof finalUrl === 'object' && finalUrl.url) {
cacheUrl = finalUrl.url;
} else {
cacheUrl = finalUrl;
}
// MEMORY LEAK FIX: Use new cache function with proper timer tracking
// Make cache TTL configurable for better performance tuning
const cacheTtlMs = parseInt(process.env.RESOLVE_CACHE_TTL_MS || '900000', 10); // 15 min default (reduced from 2 hours)
await setCacheWithTimer(resolverCacheKey, cacheUrl, cacheTtlMs);
}
}
if (finalUrl) {
// Handle case where finalUrl might be an object (e.g., from UHDMovies resolver)
let redirectUrl;
if (finalUrl && typeof finalUrl === 'object' && finalUrl.url) {
redirectUrl = finalUrl.url;
} else {
redirectUrl = finalUrl;
}
// Sanitize finalUrl before logging - it may contain API keys or auth tokens
const sanitizedUrl = obfuscateSensitive(redirectUrl, debridApiKey);
console.log("[RESOLVER] Redirecting to final stream URL:", sanitizedUrl);
// Encode URL to handle spaces and special characters, then issue a 302 redirect
const encodedUrl = encodeUrlForStreaming(redirectUrl);
res.redirect(302, encodedUrl);
} else {
res.status(404).send('Could not resolve link');
}
} catch (error) {
console.error("[RESOLVER] A critical error occurred:", error.message);
res.status(500).send("Error resolving stream.");
}
});
// HTTP Streaming resolver endpoint (for 4KHDHub, UHDMovies, etc.)
// This endpoint provides lazy resolution - decrypts URLs only when user selects a stream
app.get('/resolve/httpstreaming/:url', resolveRateLimiter, async (req, res) => {
const { url } = req.params;
const decodedUrl = decodeURIComponent(url);
const isUHDMoviesUrl = decodedUrl.includes('driveleech') ||
decodedUrl.includes('driveseed') ||
decodedUrl.includes('tech.unblockedgames.world') ||
decodedUrl.includes('tech.creativeexpressionsblog.com') ||
decodedUrl.includes('tech.examzculture.in');
const isShortlinkResolveUrl = decodedUrl.includes('ouo.io') ||
decodedUrl.includes('ouo.press') ||
decodedUrl.includes('oii.la') ||
decodedUrl.includes('viewcrate.cc') ||
decodedUrl.includes('filecrypt.cc') ||
decodedUrl.includes('filecrypt.co');
const isProviderArchiveResolveUrl = decodedUrl.includes('modpro.blog') ||
decodedUrl.includes('leechpro.blog') ||
decodedUrl.includes('episodes.animeflix.') ||
decodedUrl.includes('/getlink/');
// Use hash of URL as cache key
const cacheKeyHash = crypto.createHash('md5').update(decodedUrl).digest('hex');
const cacheKey = `httpstreaming:${cacheKeyHash}`;
const looksLikeAsset = (value) => /\.(?:js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|eot|map|json)(?:$|[?#])/i.test(value || '');
try {
let finalUrl;
const cachedValue = await getCacheValue(cacheKey);
if (cachedValue) {
// Handle case where cachedValue might be an object from UHDMovies resolver
if (typeof cachedValue === 'object' && cachedValue.url) {
finalUrl = cachedValue.url;
} else {
finalUrl = cachedValue;
}
if (looksLikeAsset(finalUrl)) {
console.log(`[HTTP-RESOLVER] Cached URL looks like an asset, ignoring cache for key: httpstreaming:${cacheKeyHash.substring(0, 8)}...`);
finalUrl = null;
} else {
console.log(`[CACHE] Using cached URL for key: httpstreaming:${cacheKeyHash.substring(0, 8)}...`);
}
} else if (PENDING_RESOLVES.has(cacheKey)) {
console.log(`[HTTP-RESOLVER] Joining in-flight resolve for key: ${cacheKeyHash.substring(0, 8)}...`);
finalUrl = await PENDING_RESOLVES.get(cacheKey);
// Handle case where finalUrl might be an object from UHDMovies resolver
if (finalUrl && typeof finalUrl === 'object' && finalUrl.url) {
finalUrl = finalUrl.url;
}
} else {
console.log(`[HTTP-RESOLVER] Resolving HTTP stream URL...`);
// Determine which resolver to use based on URL pattern
let resolvePromise;
if (isUHDMoviesUrl) {
// UHDMovies SID/driveleech URL
console.log(`[HTTP-RESOLVER] Detected UHDMovies URL, using UHDMovies resolver`);