mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 12:57:38 +00:00
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV, le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de l’application mobile. Lecteur, sources et Live TV - Refonte du lecteur HLS avec sélection et mémorisation de la qualité, détection des résolutions, scan des liens, choix des pistes audio et fallback contrôlé de 1080p vers 720p. - Ajoute une garde contre les boucles de changement de source, notamment lors des erreurs vidéo et des réponses 429. - Revoit SeekStreaming de bout en bout : validation des embeds, prise en charge de plusieurs candidats, regroupement par serveur, règles de headers persistantes et cache d’extraction raccourci. - Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache stale-while-revalidate, déduplication des requêtes et limite partagée côté API. - Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill switch, développe les wrappers 1jour1film et actualise le domaine Wiflix. - Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même chaîne et permet de choisir le serveur ou la qualité. - Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi que de nouvelles métriques de visionnage. Cast et application mobile - Unifie le contrôle Cast entre le Web Cast SDK et le pont Android : état distant, lecture, pause, seek, arrêt et reprise après reconnexion. - Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground. - Prépare les sources avant diffusion et prend en charge les playlists HLS TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les segments MPEG-TS encapsulés dans des PNG. - Renforce le proxy média local : support Range, validation des redirections, liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid. - Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités propres à chaque document et invalidation lors des navigations. - Ajoute le Picture-in-Picture Android avec lecture/pause et seek de 10 secondes depuis la fenêtre flottante. - Maintient désormais l’écran allumé uniquement pendant une lecture active. - Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses métadonnées d’intégrité. API, base de données et paiements - Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL centralisé, additif et capable de détecter les divergences de structure. - Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL, confirmations pour les tables Wrapped et couverture de tests dédiée. - Rend configurables les limites du pool MySQL et fiabilise les advisory locks. - Revoit entièrement PayGate : références de callback séparées, nonces, validation des wallets et transactions, états monotones, déduplication des paiements et protection contre les courses ou réutilisations de txid. - Renforce l’annulation, la réconciliation et la livraison idempotente des factures VIP, avec réponses no-store sur les routes sensibles. - Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer correctement 401 ou 503 sans provoquer de déconnexions inutiles. - Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en solution de secours. - Améliore la gestion HLS et Range dans proxiesembed. Frontend, profils et infrastructure - Protège la sélection et l’hydratation des profils contre les réponses concurrentes, les intentions obsolètes et l’effacement accidentel du stockage local. - Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données structurées, le service worker, robots.txt et sitemap.xml. - Optimise les probes réseau du service worker avec cache court et single-flight. - Charge dynamiquement les traductions anglaises, plusieurs routes secondaires et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires. - Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit les animations, calculs et rendus hors écran. - Ajoute la page publique des frais, les coins carrés persistants, une nouvelle intro Movix, les avertissements de publicités adultes et de nombreux textes Cast, qualité, Live TV et sources. - Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast. - Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur. - Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et actualise la documentation d’installation et d’initialisation MySQL. Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
337 lines
14 KiB
JavaScript
337 lines
14 KiB
JavaScript
const cluster = require('cluster');
|
||
const os = require('os');
|
||
require('dotenv').config();
|
||
|
||
// === CLUSTER MODE CONFIGURATION (au tout début pour éviter que le master charge tout) ===
|
||
const NUM_WORKERS = parseInt(process.env.NUM_WORKERS) || 6; // 6 coeurs physiques sur le serveur
|
||
|
||
if (cluster.isPrimary ?? cluster.isMaster) {
|
||
// === MODE MASTER — ne charge RIEN d'autre (pas de MySQL, Redis, Express, etc.) ===
|
||
console.log(`🚀 Master process ${process.pid} démarré en mode cluster`);
|
||
console.log(`📊 Création de ${NUM_WORKERS} workers...`);
|
||
|
||
for (let i = 0; i < NUM_WORKERS; i++) {
|
||
const worker = cluster.fork();
|
||
console.log(
|
||
`✓ Worker ${worker.process.pid} créé (${i + 1}/${NUM_WORKERS})`,
|
||
);
|
||
}
|
||
// Anti-fork-bomb : limiter les redémarrages rapides
|
||
const workerRestarts = new Map(); // pid -> [timestamps]
|
||
const MAX_RESTARTS = 5;
|
||
const RESTART_WINDOW_MS = 60000; // 1 minute
|
||
let isShuttingDown = false; // Flag pour empêcher le redémarrage des workers pendant le shutdown
|
||
|
||
cluster.on('exit', (worker, code, signal) => {
|
||
if (signal) {
|
||
console.warn(`⚠️ Worker ${worker.process.pid} tué par le signal ${signal}`);
|
||
} else if (code !== 0) {
|
||
console.error(`❌ Worker ${worker.process.pid} terminé avec le code ${code}`);
|
||
} else {
|
||
console.log(`ℹ️ Worker ${worker.process.pid} terminé normalement`);
|
||
}
|
||
|
||
// Ne pas redémarrer les workers si le master est en cours d'arrêt
|
||
if (isShuttingDown) {
|
||
console.log(`🛑 Shutdown en cours — worker ${worker.process.pid} ne sera pas redémarré`);
|
||
return;
|
||
}
|
||
|
||
// Vérifier le taux de redémarrage pour éviter la boucle infinie
|
||
const now = Date.now();
|
||
const restarts = workerRestarts.get('global') || [];
|
||
const recentRestarts = restarts.filter(t => now - t < RESTART_WINDOW_MS);
|
||
recentRestarts.push(now);
|
||
workerRestarts.set('global', recentRestarts);
|
||
|
||
if (recentRestarts.length > MAX_RESTARTS) {
|
||
console.error(`🚨 Trop de redémarrages (${recentRestarts.length} en ${RESTART_WINDOW_MS / 1000}s) — arrêt du fork`);
|
||
return;
|
||
}
|
||
|
||
console.log(`🔄 Redémarrage d'un nouveau worker...`);
|
||
const newWorker = cluster.fork();
|
||
console.log(`✅ Nouveau worker ${newWorker.process.pid} créé`);
|
||
});
|
||
|
||
// Graceful shutdown master
|
||
const shutdownMaster = () => {
|
||
if (isShuttingDown) return; // Guard: SIGINT + SIGTERM peuvent arriver quasi-simultanement
|
||
isShuttingDown = true;
|
||
console.log('\n🛑 Signal de fermeture reçu par le master...');
|
||
console.log('📤 Envoi du signal de fermeture à tous les workers...');
|
||
for (const id in cluster.workers) {
|
||
cluster.workers[id].send('shutdown');
|
||
}
|
||
let workersAlive = Object.keys(cluster.workers).length;
|
||
const checkInterval = setInterval(() => {
|
||
workersAlive = Object.keys(cluster.workers).length;
|
||
if (workersAlive === 0) {
|
||
clearInterval(checkInterval);
|
||
console.log('✅ Tous les workers sont arrêtés. Arrêt du master.');
|
||
process.exit(0);
|
||
}
|
||
}, 100);
|
||
setTimeout(() => {
|
||
console.warn('⚠️ Timeout atteint (30s). Arrêt forcé du master.');
|
||
process.exit(1);
|
||
}, 30000);
|
||
};
|
||
|
||
process.on('SIGTERM', shutdownMaster);
|
||
process.on('SIGINT', shutdownMaster);
|
||
|
||
console.log(`
|
||
╔═══════════════════════════════════════════════════════╗
|
||
║ 🎯 Mode Cluster Actif ║
|
||
║ 👷 Workers: ${NUM_WORKERS.toString().padEnd(42, ' ')}║
|
||
║ 🔒 Redis Locks: Verrous distribués entre workers ║
|
||
║ ⚛️ Atomic writes: Garantis entre les processus ║
|
||
╚═══════════════════════════════════════════════════════╝
|
||
`);
|
||
|
||
// === WORKER RECYCLE (anti-leak mitigation) ================================
|
||
// Root cause of the multi-day RSS climb is not yet confirmed. Recycling each
|
||
// worker one-by-one on a long interval caps how far any slow leak can grow
|
||
// before that worker's memory is reclaimed by a fresh fork. Each worker gets
|
||
// the same graceful 'shutdown' message the SIGTERM path sends, so in-flight
|
||
// requests drain via server.close() first; the cluster.on('exit') handler
|
||
// above then forks the replacement.
|
||
//
|
||
// Tunables (env):
|
||
// WORKER_RECYCLE_INTERVAL_MS default 12h — set 0 to disable entirely
|
||
// WORKER_RECYCLE_STAGGER_MS default 5min — gap between each worker so
|
||
// capacity never drops hard
|
||
const WORKER_RECYCLE_INTERVAL_MS = (() => {
|
||
const raw = process.env.WORKER_RECYCLE_INTERVAL_MS;
|
||
if (raw === undefined || raw === '') return 12 * 60 * 60 * 1000;
|
||
const parsed = parseInt(raw, 10);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 12 * 60 * 60 * 1000;
|
||
})();
|
||
const WORKER_RECYCLE_STAGGER_MS =
|
||
parseInt(process.env.WORKER_RECYCLE_STAGGER_MS, 10) || (5 * 60 * 1000);
|
||
|
||
if (WORKER_RECYCLE_INTERVAL_MS > 0) {
|
||
console.log(
|
||
`🔁 Worker recycle: every ${Math.round(WORKER_RECYCLE_INTERVAL_MS / 3600000)}h, ` +
|
||
`stagger ${Math.round(WORKER_RECYCLE_STAGGER_MS / 60000)}min`,
|
||
);
|
||
const recycleTimer = setInterval(() => {
|
||
if (isShuttingDown) return;
|
||
const workers = Object.values(cluster.workers || {});
|
||
if (workers.length === 0) return;
|
||
console.log(`🔁 Worker recycle cycle starting — ${workers.length} workers`);
|
||
workers.forEach((worker, idx) => {
|
||
setTimeout(() => {
|
||
if (isShuttingDown || worker.isDead()) return;
|
||
try {
|
||
console.log(`🔁 Recycling worker pid=${worker.process.pid}`);
|
||
worker.send('shutdown');
|
||
// Force-kill fallback if the worker's graceful 15s server.close +
|
||
// resource cleanup hasn't exited it within 35s.
|
||
setTimeout(() => {
|
||
try {
|
||
if (!worker.isDead()) worker.kill('SIGKILL');
|
||
} catch (_) { /* ignore */ }
|
||
}, 35000);
|
||
} catch (e) {
|
||
console.warn(`🔁 Recycle worker ${worker.process?.pid} failed: ${e.message}`);
|
||
}
|
||
}, idx * WORKER_RECYCLE_STAGGER_MS);
|
||
});
|
||
}, WORKER_RECYCLE_INTERVAL_MS);
|
||
recycleTimer.unref();
|
||
} else {
|
||
console.log('🔁 Worker recycle disabled (WORKER_RECYCLE_INTERVAL_MS=0)');
|
||
}
|
||
|
||
// Le master ne fait RIEN d'autre — pas de require express, mysql, redis, etc.
|
||
return;
|
||
}
|
||
|
||
// =============================================================================
|
||
// === WORKER PROCESS ONLY (below) =============================================
|
||
// =============================================================================
|
||
|
||
process.env.UV_THREADPOOL_SIZE ||= "8"; // 8 threads libuv par worker (6 workers x 8 = 48 threads total)
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const v8 = require('v8');
|
||
const path = require('path');
|
||
const { app, appReady } = require('./app');
|
||
const { redis } = require('./config/redis');
|
||
const { shutdownCycleTLS, refreshProxyScrapeProxies } = require('./utils/proxyManager');
|
||
const { getPool } = require('./mysqlPool');
|
||
|
||
const PORT = parseInt(process.env.PORT, 10) || 25565;
|
||
|
||
// ===========================================================================
|
||
// === MEMORY DIAGNOSTICS (worker) ===========================================
|
||
// ===========================================================================
|
||
// The cluster RSS climbs continuously over multi-day uptime with no confirmed
|
||
// root cause yet. These hooks gather the evidence:
|
||
// - Periodic [memstats] line — feed it to a graph to see the leak slope and
|
||
// which segment (heap vs external/arrayBuffers vs sockets) is growing.
|
||
// - SIGUSR2 → v8 heap snapshot on disk — open in Chrome DevTools, compare two
|
||
// snapshots taken hours apart to find the retained object class.
|
||
// Trigger: kill -USR2 <worker-pid>
|
||
//
|
||
// Both are read-only and effectively free; safe to keep in production.
|
||
const MEMORY_LOG_INTERVAL_MS = parseInt(process.env.MEMORY_LOG_INTERVAL_MS, 10) || (5 * 60 * 1000);
|
||
const HEAPDUMP_DIR = process.env.HEAPDUMP_DIR || os.tmpdir();
|
||
|
||
function fmtMB(n) {
|
||
return `${(Number(n || 0) / 1024 / 1024).toFixed(1)}MB`;
|
||
}
|
||
|
||
// http.globalAgent.sockets is keyed by `host:port` — its key count is the
|
||
// number of distinct upstream origins currently holding live sockets. A
|
||
// climbing count points at keep-alive socket pool churn.
|
||
function countAgentHosts(agent, prop) {
|
||
try {
|
||
return agent && agent[prop] ? Object.keys(agent[prop]).length : 0;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function logMemoryUsage() {
|
||
const m = process.memoryUsage();
|
||
console.log(
|
||
`[memstats] pid=${process.pid} uptime=${Math.round(process.uptime())}s ` +
|
||
`rss=${fmtMB(m.rss)} heapTotal=${fmtMB(m.heapTotal)} heapUsed=${fmtMB(m.heapUsed)} ` +
|
||
`external=${fmtMB(m.external)} arrayBuffers=${fmtMB(m.arrayBuffers)} ` +
|
||
`httpHosts=${countAgentHosts(http.globalAgent, 'sockets')}/` +
|
||
`${countAgentHosts(http.globalAgent, 'freeSockets')} ` +
|
||
`httpsHosts=${countAgentHosts(https.globalAgent, 'sockets')}/` +
|
||
`${countAgentHosts(https.globalAgent, 'freeSockets')}`,
|
||
);
|
||
}
|
||
|
||
const memoryLogTimer = setInterval(logMemoryUsage, MEMORY_LOG_INTERVAL_MS);
|
||
memoryLogTimer.unref();
|
||
|
||
// SIGUSR2 — write a heap snapshot. nodemon also uses SIGUSR2, but production
|
||
// runs node directly so there is no conflict here.
|
||
process.on('SIGUSR2', () => {
|
||
const snapshotPath = path.join(
|
||
HEAPDUMP_DIR,
|
||
`heap-${process.pid}-${Date.now()}.heapsnapshot`,
|
||
);
|
||
try {
|
||
const start = Date.now();
|
||
v8.writeHeapSnapshot(snapshotPath);
|
||
console.warn(
|
||
`[heapdump] pid=${process.pid} written ${snapshotPath} in ${Date.now() - start}ms`,
|
||
);
|
||
} catch (e) {
|
||
console.error(`[heapdump] pid=${process.pid} failed: ${e.message}`);
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// startServer — create HTTP server with retry logic
|
||
// ---------------------------------------------------------------------------
|
||
const startServer = async (retries = 3) => {
|
||
try {
|
||
await appReady;
|
||
} catch (error) {
|
||
if (retries > 0) {
|
||
console.error(`[BOOTSTRAP] Échec avant listen: ${error.message}`);
|
||
console.log(`Redémarrage... (${retries} restantes)`);
|
||
return setTimeout(() => startServer(retries - 1), 5000);
|
||
}
|
||
|
||
console.error('Échec du bootstrap applicatif après plusieurs tentatives');
|
||
process.exit(1);
|
||
}
|
||
|
||
try {
|
||
await refreshProxyScrapeProxies({ force: false, silent: false });
|
||
} catch (error) {
|
||
console.warn(`[PROXYSCRAPE] Initialisation incomplete avant listen: ${error.message}`);
|
||
}
|
||
|
||
const server = http.createServer(app);
|
||
|
||
// Configure Keep-Alive settings
|
||
server.keepAliveTimeout = 65000; // 65 secondes
|
||
server.headersTimeout = 66000; // 66 secondes
|
||
server.maxRequestsPerSocket = 0; // Illimité
|
||
server.requestTimeout = 300000; // 5 minutes timeout to prevent hung sockets
|
||
|
||
// Backlog increased to 4096 to handle burst connections
|
||
server.listen(PORT, '0.0.0.0', 4096, () => {
|
||
console.log(`Serveur démarré sur le port ${PORT} - Process ${process.pid}`);
|
||
console.log(`Keep-Alive configuré: timeout=${server.keepAliveTimeout}ms, max=1000`);
|
||
console.log(`Performance tuning: UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE}, RequestTimeout=${server.requestTimeout}ms`);
|
||
});
|
||
|
||
server.on('error', (err) => {
|
||
console.error('Erreur de démarrage:', err);
|
||
if (retries > 0) {
|
||
console.log(`Redémarrage... (${retries} restantes)`);
|
||
setTimeout(() => startServer(retries - 1), 5000);
|
||
} else {
|
||
console.error('Échec du démarrage après plusieurs tentatives');
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
return server;
|
||
};
|
||
|
||
// === DÉMARRAGE DU WORKER ===
|
||
let activeServer = null;
|
||
|
||
startServer().then((server) => {
|
||
activeServer = server;
|
||
console.log(`✅ Worker ${process.pid} - Serveur démarré sur le port ${PORT}`);
|
||
logMemoryUsage(); // baseline [memstats] line at boot
|
||
}).catch((error) => {
|
||
console.error(`❌ Worker ${process.pid} - Échec du démarrage:`, error);
|
||
process.exit(1);
|
||
});
|
||
|
||
// Graceful shutdown worker — cleanup all resources
|
||
const { setShuttingDown, isShuttingDown } = require('./utils/shutdownFlag');
|
||
let isWorkerShuttingDown = false;
|
||
|
||
const shutdownWorker = async () => {
|
||
if (isWorkerShuttingDown) return;
|
||
isWorkerShuttingDown = true;
|
||
setShuttingDown();
|
||
console.log(`\n🛑 Worker ${process.pid} - Signal de fermeture reçu...`);
|
||
|
||
// 1. Arrêter d'accepter de nouvelles connexions et attendre les requêtes en cours
|
||
if (activeServer) {
|
||
await new Promise((resolve) => {
|
||
// Empêcher les nouvelles connexions keep-alive de prolonger le shutdown
|
||
activeServer.keepAliveTimeout = 1;
|
||
activeServer.close(() => {
|
||
console.log(`✅ Worker ${process.pid} - Serveur HTTP fermé (plus de requêtes en cours)`);
|
||
resolve();
|
||
});
|
||
|
||
// Force-close après 15s si des connexions trainent
|
||
setTimeout(() => {
|
||
console.warn(`⚠️ Worker ${process.pid} - Timeout 15s, fermeture forcée des connexions`);
|
||
activeServer.closeAllConnections();
|
||
resolve();
|
||
}, 15000);
|
||
});
|
||
}
|
||
|
||
// 2. Cleanup des ressources
|
||
try { await redis.quit(); } catch { /* ignore */ }
|
||
try { await shutdownCycleTLS(); } catch { /* ignore */ }
|
||
try { const pool = getPool(); if (pool) await pool.end(); } catch { /* ignore */ }
|
||
process.exit(0);
|
||
};
|
||
|
||
process.on('SIGTERM', shutdownWorker);
|
||
process.on('SIGINT', shutdownWorker);
|
||
process.on('message', (msg) => {
|
||
if (msg === 'shutdown') shutdownWorker();
|
||
});
|