mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1060 lines
36 KiB
JavaScript
1060 lines
36 KiB
JavaScript
/**
|
|
* High-level axios request helpers for each streaming site.
|
|
* Extracted from server.js — wraps proxy logic with site-specific headers and cookies.
|
|
*
|
|
* Some helpers depend on axios instances / session state that live in route modules
|
|
* (e.g. axiosFStream, axiosAnimeSama, axiosCoflix, cookieJar).
|
|
* Call `configure(deps)` once at startup to inject those dependencies.
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const writeFileAtomic = require('write-file-atomic');
|
|
|
|
const {
|
|
ENABLE_DARKINO_PROXY,
|
|
ENABLE_COFLIX_PROXY,
|
|
ENABLE_FRENCH_STREAM_PROXY,
|
|
ENABLE_LECTEURVIDEO_PROXY,
|
|
ENABLE_FSTREAM_PROXY,
|
|
ENABLE_ANIME_PROXY,
|
|
ENABLE_WIFLIX_PROXY,
|
|
DARKINO_PROXIES,
|
|
PROXIES,
|
|
HTTP_PROXIES,
|
|
CLOUDFLARE_WORKERS_PROXIES,
|
|
getProxyAgent,
|
|
getDarkinoHttpProxyAgent,
|
|
getAvailableProxies,
|
|
markProxyAsErrored,
|
|
markProxyAsHealthy,
|
|
buildProxiedUrl,
|
|
makeRequestWithCorsFallback,
|
|
makeCoflixRequest,
|
|
makeLecteurVideoRequest,
|
|
makeWiflixRequest,
|
|
makeAnimeSamaRequest,
|
|
} = require('./proxyManager');
|
|
|
|
// Lazy-bound dependencies injected via configure()
|
|
let deps = {
|
|
darkiHeaders: {},
|
|
coflixHeaders: {},
|
|
cookieJar: null,
|
|
axiosCoflix: null,
|
|
axiosAnimeSama: null,
|
|
axiosFStream: null,
|
|
COFLIX_BASE_URL: '',
|
|
ANIME_SAMA_URL: '',
|
|
FSTREAM_BASE_URL: '',
|
|
ensureFStreamSession: async () => {},
|
|
fstreamCookies: {},
|
|
fstreamRequestCounter: 0,
|
|
getFstreamRequestCounter: () => 0,
|
|
incrementFstreamRequestCounter: () => {}
|
|
};
|
|
|
|
const WIFLIX_BASE_URL = 'https://flemmix.golf';
|
|
|
|
const truncateForLog = (value, maxLength = 300) => {
|
|
if (typeof value !== 'string') return value;
|
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
};
|
|
|
|
const summarizeRequestErrorForLog = (error) => {
|
|
const responseBody = error?.response?.data;
|
|
return {
|
|
message: error?.message,
|
|
code: error?.code,
|
|
status: error?.response?.status,
|
|
statusText: error?.response?.statusText,
|
|
responseSnippet: typeof responseBody === 'string'
|
|
? truncateForLog(responseBody, 500)
|
|
: responseBody && typeof responseBody === 'object'
|
|
? truncateForLog(JSON.stringify(responseBody), 500)
|
|
: undefined
|
|
};
|
|
};
|
|
|
|
const DARKIWORLD_BASE_URL = String(process.env.DARKIWORLD_BASE_URL || 'https://darkiworld2026.com')
|
|
.trim()
|
|
.replace(/\/+$/, '');
|
|
|
|
/**
|
|
* Inject site-specific dependencies that live outside this module.
|
|
* Call once at server startup after creating axios instances.
|
|
*
|
|
* @param {object} injected - Map of dependency names to values
|
|
*/
|
|
function configure(injected) {
|
|
Object.assign(deps, injected);
|
|
}
|
|
|
|
// === Proxy manager handle for Redis-backed Darkino cooldowns ===
|
|
const proxyManager = require('./proxyManager');
|
|
|
|
// Fonction utilitaire pour g\u00e9n\u00e9rer le referer dynamiquement
|
|
function generateDarkiReferer(url) {
|
|
const baseUrl = DARKIWORLD_BASE_URL;
|
|
|
|
if (url.includes('/season/') && url.includes('/episode/') && url.includes('/download')) {
|
|
// Pour les \u00e9pisodes de s\u00e9ries: /titles/{titleId}/season/{seasonId}/episode/{episodeId}/download
|
|
const match = url.match(/\/titles\/(\d+)\/season\/(\d+)\/episode\/(\d+)\/download/);
|
|
if (match) {
|
|
const [, titleId, seasonId, episodeId] = match;
|
|
return `${baseUrl}/titles/${titleId}/season/${seasonId}/episode/${episodeId}/download`;
|
|
}
|
|
} else if (url.includes('/titles/') && url.includes('/content/liens')) {
|
|
// Nouvel endpoint download : /api/v1/titles/{titleId}/content/liens
|
|
// Le navigateur envoie referer = /titles/{titleId}/download, sinon
|
|
// l'API retourne 401 m\u00eame avec session valide.
|
|
const match = url.match(/\/titles\/(\d+)\/content\/liens/);
|
|
if (match) {
|
|
const [, titleId] = match;
|
|
return `${baseUrl}/titles/${titleId}/download`;
|
|
}
|
|
} else if (url.includes('/titles/') && url.includes('/download')) {
|
|
// Pour les films: /titles/{id}/download
|
|
const match = url.match(/\/titles\/(\d+)\/download/);
|
|
if (match) {
|
|
const [, titleId] = match;
|
|
return `${baseUrl}/titles/${titleId}/download`;
|
|
}
|
|
} else if (url.includes('/search/')) {
|
|
// Pour la recherche
|
|
return `${baseUrl}/search`;
|
|
}
|
|
|
|
// Par d\u00e9faut, utiliser l'URL de base
|
|
return baseUrl;
|
|
}
|
|
|
|
function splitCookieHeader(headerValue) {
|
|
if (typeof headerValue !== 'string' || headerValue.trim() === '') {
|
|
return [];
|
|
}
|
|
|
|
return headerValue
|
|
.split(';')
|
|
.map(part => part.trim())
|
|
.filter(Boolean)
|
|
.map(part => {
|
|
const separatorIndex = part.indexOf('=');
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
part.slice(0, separatorIndex).trim(),
|
|
part.slice(separatorIndex + 1).trim()
|
|
];
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function mergeCookieHeaders(...headerValues) {
|
|
const cookieMap = new Map();
|
|
|
|
for (const headerValue of headerValues) {
|
|
for (const [name, value] of splitCookieHeader(headerValue)) {
|
|
cookieMap.set(name, value);
|
|
}
|
|
}
|
|
|
|
return [...cookieMap.entries()]
|
|
.map(([name, value]) => `${name}=${value}`)
|
|
.join('; ');
|
|
}
|
|
|
|
function decodeCookieValue(value) {
|
|
if (typeof value !== 'string' || value === '') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch (_) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function getDarkinoJarState() {
|
|
if (!deps.cookieJar) {
|
|
return {
|
|
cookieHeader: '',
|
|
xsrfToken: ''
|
|
};
|
|
}
|
|
|
|
try {
|
|
const cookies = await deps.cookieJar.getCookies(DARKIWORLD_BASE_URL);
|
|
const xsrfCookie = cookies.find(cookie => cookie.key.toUpperCase() === 'XSRF-TOKEN');
|
|
|
|
return {
|
|
cookieHeader: cookies.map(cookie => cookie.cookieString()).join('; '),
|
|
xsrfToken: xsrfCookie ? decodeCookieValue(xsrfCookie.value) : ''
|
|
};
|
|
} catch (error) {
|
|
console.warn(`[DARKINO REQUEST] Impossible de lire le cookieJar: ${error.message}`);
|
|
return {
|
|
cookieHeader: '',
|
|
xsrfToken: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
async function buildDarkinoRequestHeaders(config) {
|
|
const dynamicReferer = generateDarkiReferer(config.url || '/');
|
|
const jarState = await getDarkinoJarState();
|
|
const requestHeaders = {
|
|
...deps.darkiHeaders,
|
|
referer: dynamicReferer,
|
|
...config.headers,
|
|
};
|
|
const configuredCookieHeader = requestHeaders.cookie || requestHeaders.Cookie || '';
|
|
// IMPORTANT: env cookies (DARKIWORLD_COOKIES) doivent gagner contre le
|
|
// cookieJar. Sans ça, la réponse du GET `/` (refresh session) émet un
|
|
// `set-cookie` avec une session anonyme qui écrase `darkiworld_session`
|
|
// dans le jar → toutes les requêtes suivantes vers les routes auth (ex.
|
|
// /api/v1/titles/{id}/content/liens) reçoivent 401 Unauthenticated.
|
|
// L'ordre des arguments compte : le DERNIER argument gagne dans le merge.
|
|
const mergedCookieHeader = mergeCookieHeaders(jarState.cookieHeader, configuredCookieHeader);
|
|
|
|
if (mergedCookieHeader) {
|
|
requestHeaders.cookie = mergedCookieHeader;
|
|
}
|
|
|
|
delete requestHeaders.Cookie;
|
|
|
|
// x-xsrf-token : env gagne aussi. On ne fallback sur le jar que si l'env
|
|
// ne fournit pas de token (sinon on enverrait un header qui ne matche
|
|
// pas le cookie XSRF-TOKEN env qu'on vient de privilégier).
|
|
if (jarState.xsrfToken && !requestHeaders['x-xsrf-token']) {
|
|
requestHeaders['x-xsrf-token'] = jarState.xsrfToken;
|
|
}
|
|
|
|
return requestHeaders;
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Darkino avec proxies (SOCKS5h)
|
|
async function axiosDarkinoRequest(config) {
|
|
// V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 403 (cluster-wide via Redis)
|
|
const cd403Ms = await proxyManager.getDarkinoCooldownRemainingMs('403');
|
|
if (cd403Ms > 0) {
|
|
const remainingMin = Math.ceil(cd403Ms / 60000);
|
|
const error = new Error(`Darkino en cooldown (403 Cloudflare). R\u00e9essayez dans ${remainingMin} minute(s).`);
|
|
error.isDarkinoCooldown = true;
|
|
error.response = { status: 403 };
|
|
throw error;
|
|
}
|
|
|
|
// V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 429 (rate limit / Cloudflare Workers daily limit)
|
|
const cd429Ms = await proxyManager.getDarkinoCooldownRemainingMs('429');
|
|
if (cd429Ms > 0) {
|
|
const remainingMin = Math.ceil(cd429Ms / 60000);
|
|
const error = new Error(`Darkino en cooldown (rate limit 429). R\u00e9essayez dans ${remainingMin} minute(s).`);
|
|
error.isDarkinoCooldown = true;
|
|
error.response = { status: 429 };
|
|
throw error;
|
|
}
|
|
|
|
// V\u00e9rifier si on est en cooldown apr\u00e8s une erreur serveur (5xx)
|
|
const cd5xxMs = await proxyManager.getDarkinoCooldownRemainingMs('5xx');
|
|
if (cd5xxMs > 0) {
|
|
const remainingMin = Math.ceil(cd5xxMs / 60000);
|
|
const error = new Error(`Darkino en cooldown (erreur serveur). R\u00e9essayez dans ${remainingMin} minute(s).`);
|
|
error.isDarkinoCooldown = true;
|
|
error.response = { status: 503 };
|
|
throw error;
|
|
}
|
|
|
|
// Cooldown apres trop de timeouts / erreurs reseau consecutifs (cluster-wide).
|
|
// Les cooldowns 403/429/5xx ne se declenchent que sur une reponse HTTP : un
|
|
// timeout (ECONNABORTED) n'a pas de error.response donc rien ne s'armait,
|
|
// l'upstream mort etait martele en boucle (RSS qui monte sans redescendre).
|
|
const cdNeterrMs = await proxyManager.getDarkinoCooldownRemainingMs('neterr');
|
|
if (cdNeterrMs > 0) {
|
|
const remainingMin = Math.ceil(cdNeterrMs / 60000);
|
|
const error = new Error(`Darkino en cooldown (trop de timeouts). Réessayez dans ${remainingMin} minute(s).`);
|
|
error.isDarkinoCooldown = true;
|
|
error.response = { status: 504 };
|
|
throw error;
|
|
}
|
|
|
|
const requestUrl = `${DARKIWORLD_BASE_URL}${config.url}`;
|
|
const darkinoRequestHeaders = await buildDarkinoRequestHeaders(config);
|
|
|
|
const darkinoAuthSnapshot = {
|
|
referer: darkinoRequestHeaders.referer,
|
|
xsrfToken: truncateForLog(darkinoRequestHeaders['x-xsrf-token'] || '', 200),
|
|
cookie: truncateForLog(darkinoRequestHeaders.cookie || '', 500),
|
|
cookieLength: (darkinoRequestHeaders.cookie || '').length,
|
|
};
|
|
|
|
if (!ENABLE_DARKINO_PROXY) {
|
|
// Si le proxy est d\u00e9sactiv\u00e9, faire la requ\u00eate directe
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true
|
|
});
|
|
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`[DARKINO REQUEST][DIRECT ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
// En cas d'erreur 403, 429 ou 5xx, activer le cooldown (cluster-wide)
|
|
const directStatus = error.response?.status;
|
|
if (directStatus === 403) {
|
|
await proxyManager.armDarkinoCooldown('403');
|
|
console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`);
|
|
} else if (directStatus === 429) {
|
|
await proxyManager.armDarkinoCooldown('429');
|
|
console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`);
|
|
} else if (directStatus >= 500 && directStatus < 600) {
|
|
await proxyManager.armDarkinoCooldown('5xx');
|
|
console.log(`[DARKINO] Erreur ${directStatus} d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Utiliser les proxies HTTP sp\u00e9cifiques de Darkino avec rotation al\u00e9atoire
|
|
const darkinoProxies = [...DARKINO_PROXIES]; // Copie pour pouvoir m\u00e9langer
|
|
|
|
// M\u00e9langer al\u00e9atoirement les proxies
|
|
for (let i = darkinoProxies.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[darkinoProxies[i], darkinoProxies[j]] = [darkinoProxies[j], darkinoProxies[i]];
|
|
}
|
|
|
|
if (darkinoProxies.length === 0) {
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true
|
|
});
|
|
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`[DARKINO REQUEST][DIRECT NO PROXY ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
const noProxyStatus = error.response?.status;
|
|
if (noProxyStatus === 403) {
|
|
await proxyManager.armDarkinoCooldown('403');
|
|
console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`);
|
|
} else if (noProxyStatus === 429) {
|
|
await proxyManager.armDarkinoCooldown('429');
|
|
console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`);
|
|
} else if (noProxyStatus >= 500 && noProxyStatus < 600) {
|
|
await proxyManager.armDarkinoCooldown('5xx');
|
|
console.log(`[DARKINO] Erreur ${noProxyStatus} d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let lastError = null;
|
|
const maxRetries = 1; // Une seule tentative
|
|
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
const proxy = darkinoProxies[i];
|
|
const agents = getDarkinoHttpProxyAgent(proxy);
|
|
|
|
try {
|
|
const response = await axios({
|
|
...config,
|
|
method: config.method || 'get',
|
|
url: requestUrl,
|
|
headers: darkinoRequestHeaders,
|
|
timeout: 5000,
|
|
withCredentials: false,
|
|
decompress: true,
|
|
httpAgent: agents.httpAgent,
|
|
httpsAgent: agents.httpsAgent,
|
|
proxy: false
|
|
});
|
|
|
|
// Mettre \u00e0 jour le cookieJar avec la r\u00e9ponse
|
|
const setCookieHeader = response.headers['set-cookie'];
|
|
if (setCookieHeader && deps.cookieJar) {
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
// Requete reussie : on n'est plus en serie d'echecs reseau.
|
|
proxyManager.resetDarkinoNetFailures();
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error;
|
|
const statusCode = error.response?.status;
|
|
console.warn(`[DARKINO REQUEST][PROXY ERROR] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
attempt: i + 1,
|
|
status: statusCode,
|
|
error: summarizeRequestErrorForLog(error),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
|
|
// Mettre \u00e0 jour les cookies m\u00eame en cas d'erreur si le header est pr\u00e9sent
|
|
if (error.response && error.response.headers['set-cookie'] && deps.cookieJar) {
|
|
const setCookieHeader = error.response.headers['set-cookie'];
|
|
await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL)));
|
|
}
|
|
|
|
// En cas d'erreur 403 (Cloudflare challenge), activer le cooldown (cluster-wide)
|
|
if (statusCode === 403) {
|
|
await proxyManager.armDarkinoCooldown('403');
|
|
throw error;
|
|
}
|
|
|
|
// En cas d'erreur 429 (Too Many Requests / Cloudflare Workers daily limit),
|
|
// activer le cooldown 429 (cluster-wide) \u2014 inutile de spammer un autre proxy
|
|
// si l'upstream hydracker lui-m\u00eame rate-limit globalement.
|
|
if (statusCode === 429) {
|
|
await proxyManager.armDarkinoCooldown('429');
|
|
console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (proxy) - Cooldown activ\u00e9`);
|
|
throw error;
|
|
}
|
|
|
|
// En cas d'erreur serveur (5xx), activer le cooldown 5xx (cluster-wide)
|
|
if (statusCode >= 500 && statusCode < 600) {
|
|
await proxyManager.armDarkinoCooldown('5xx');
|
|
console.log(`[DARKINO] Erreur ${statusCode} d\u00e9tect\u00e9e (proxy) - Cooldown activ\u00e9`);
|
|
throw error;
|
|
}
|
|
|
|
// Pour les autres erreurs (400, etc.), arr\u00eater imm\u00e9diatement
|
|
if (statusCode) {
|
|
throw error;
|
|
}
|
|
|
|
// Pas de statusCode = erreur reseau sans reponse HTTP (timeout
|
|
// ECONNABORTED, ECONNRESET, ...). Compte l'echec pour le circuit
|
|
// breaker cluster-wide : au seuil, le cooldown 'neterr' est arme.
|
|
await proxyManager.recordDarkinoNetFailure();
|
|
}
|
|
}
|
|
|
|
// Si on arrive ici, tous les proxies ont \u00e9chou\u00e9
|
|
console.error(`[DARKINO REQUEST][ALL FAILED] ${String(config.method || 'get').toUpperCase()} ${config.url}`, {
|
|
...summarizeRequestErrorForLog(lastError),
|
|
auth: darkinoAuthSnapshot,
|
|
});
|
|
throw lastError || new Error('Tous les proxies Darkino ont \u00e9chou\u00e9');
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Coflix avec rotation Cloudflare Workers
|
|
async function axiosCoflixRequest(config) {
|
|
// Si URL relative, on consid\u00e8re que c'est Coflix.
|
|
const targetUrl = config.url || '';
|
|
const isAbsolute = /^https?:\/\//i.test(targetUrl);
|
|
const isCoflixDomain = isAbsolute ? /^https?:\/\/(?:www\.)?coflix\.[^/]+/i.test(targetUrl) : true;
|
|
|
|
try {
|
|
if (!ENABLE_COFLIX_PROXY || !isCoflixDomain) {
|
|
return await deps.axiosCoflix({ ...config });
|
|
}
|
|
|
|
// Utiliser makeCoflixRequest avec rotation SOCKS5
|
|
const absoluteUrl = isAbsolute
|
|
? targetUrl
|
|
: `${deps.COFLIX_BASE_URL}${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`;
|
|
|
|
return await makeCoflixRequest(absoluteUrl, {
|
|
headers: { ...deps.coflixHeaders, ...(config.headers || {}) },
|
|
timeout: config.timeout || 7000,
|
|
decompress: true,
|
|
responseType: config.responseType
|
|
});
|
|
} catch (error) {
|
|
// Logger les erreurs 403
|
|
if (error.response?.status === 403) {
|
|
console.log(`[Coflix] Erreur 403 Forbidden dans axiosCoflixRequest`);
|
|
console.log(`[Coflix] URL: ${targetUrl}`);
|
|
if (error.coflixProxy) {
|
|
console.log(`[Coflix] Cloudflare Worker: ${error.coflixProxy}`);
|
|
}
|
|
if (error.coflixProxiedUrl) {
|
|
console.log(`[Coflix] Proxied URL: ${error.coflixProxiedUrl}`);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates French-Stream (SPA) via CORS fallback
|
|
async function axiosFrenchStreamRequest(config) {
|
|
const targetUrl = config.url || '';
|
|
const isAbsolute = /^https?:\/\//i.test(targetUrl);
|
|
const baseMatches = config.baseURL && /french-?stream/i.test(config.baseURL);
|
|
const isFrenchStream = /french-?stream/i.test(targetUrl) || baseMatches;
|
|
|
|
if (!ENABLE_FRENCH_STREAM_PROXY || !isFrenchStream) {
|
|
return axios({ timeout: 15000, ...config });
|
|
}
|
|
|
|
const absoluteUrl = isAbsolute
|
|
? targetUrl
|
|
: (config.baseURL ? `${config.baseURL.replace(/\/$/, '')}/${targetUrl.replace(/^\//, '')}` : targetUrl);
|
|
|
|
return makeRequestWithCorsFallback(absoluteUrl, {
|
|
headers: { ...(config.headers || {}) },
|
|
timeout: config.timeout || 5000,
|
|
decompress: true,
|
|
responseType: config.responseType
|
|
});
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates LecteurVideo avec proxies Wiflix
|
|
async function axiosLecteurVideoRequest(config) {
|
|
const urlStr = config.url || '';
|
|
const isLecteur = /lecteurvideo|lecteur-video|lecteur/i.test(urlStr) || (config.baseURL && /lecteurvideo|lecteur-video|lecteur/i.test(config.baseURL));
|
|
|
|
if (!ENABLE_LECTEURVIDEO_PROXY || !isLecteur) {
|
|
return axios({ timeout: 15000, ...config });
|
|
}
|
|
|
|
try {
|
|
// Construire l'URL absolue si n\u00e9cessaire
|
|
let absoluteUrl = config.url;
|
|
if (config.baseURL && !config.url.startsWith('http')) {
|
|
// Nettoyer les URLs avant la concat\u00e9nation pour \u00e9viter les espaces
|
|
const cleanBaseURL = config.baseURL.trim();
|
|
const cleanUrl = config.url.trim();
|
|
absoluteUrl = cleanBaseURL + cleanUrl;
|
|
}
|
|
|
|
// Utiliser makeLecteurVideoRequest avec les proxies Wiflix
|
|
return await makeLecteurVideoRequest(absoluteUrl, {
|
|
timeout: config.timeout || 5000,
|
|
headers: config.headers,
|
|
decompress: config.decompress !== false,
|
|
responseType: config.responseType,
|
|
responseEncoding: config.responseEncoding
|
|
});
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates FStream avec proxy (retry sur 429, pool SOCKS5 + Darkino)
|
|
function withOptionalFStreamCookies(headers = {}) {
|
|
if (headers['Cookie'] || headers['cookie']) {
|
|
return headers;
|
|
}
|
|
|
|
const cookieHeader = Object.entries(deps.fstreamCookies)
|
|
.filter(([, v]) => v !== '' && v != null)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join('; ');
|
|
|
|
if (!cookieHeader) {
|
|
return headers;
|
|
}
|
|
|
|
return {
|
|
...headers,
|
|
Cookie: cookieHeader
|
|
};
|
|
}
|
|
|
|
async function axiosFStreamRequest(config) {
|
|
const urlStr = config.url || '';
|
|
const fstreamBaseUrl = deps.FSTREAM_BASE_URL || '';
|
|
const isFStream = urlStr.includes(fstreamBaseUrl.replace('https://', '').replace('http://', '')) ||
|
|
(config.baseURL && config.baseURL.includes(fstreamBaseUrl.replace('https://', '').replace('http://', '')));
|
|
|
|
if (!ENABLE_FSTREAM_PROXY || !isFStream) {
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
}
|
|
|
|
// Pool combine SOCKS5 + Darkino HTTP, 1 proxy aleatoire
|
|
const allProxies = [];
|
|
if (PROXIES && PROXIES.length > 0) {
|
|
PROXIES.forEach(p => allProxies.push({ proxy: p, type: 'socks5' }));
|
|
}
|
|
if (DARKINO_PROXIES && DARKINO_PROXIES.length > 0) {
|
|
DARKINO_PROXIES.forEach(p => allProxies.push({ proxy: p, type: 'darkino' }));
|
|
}
|
|
|
|
if (allProxies.length === 0) {
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
}
|
|
|
|
const entry = allProxies[Math.floor(Math.random() * allProxies.length)];
|
|
const proxyLabel = `${entry.type}:${entry.proxy.host}:${entry.proxy.port}`;
|
|
|
|
try {
|
|
let agents;
|
|
if (entry.type === 'socks5') {
|
|
const agent = getProxyAgent(entry.proxy);
|
|
agents = { httpAgent: agent, httpsAgent: agent };
|
|
} else {
|
|
agents = getDarkinoHttpProxyAgent(entry.proxy);
|
|
}
|
|
|
|
await deps.ensureFStreamSession();
|
|
const existingHeaders = config.headers || {};
|
|
const response = await deps.axiosFStream({
|
|
...config,
|
|
timeout: 8000,
|
|
decompress: true,
|
|
httpAgent: agents.httpAgent,
|
|
httpsAgent: agents.httpsAgent,
|
|
proxy: false,
|
|
headers: withOptionalFStreamCookies(existingHeaders)
|
|
});
|
|
deps.incrementFstreamRequestCounter();
|
|
return response;
|
|
} catch (error) {
|
|
const status = error.response?.status;
|
|
const method = (config.method || 'GET').toUpperCase();
|
|
const rawUrl = config.url || '';
|
|
const isAbs = /^https?:\/\//i.test(rawUrl);
|
|
const fullUrl = isAbs
|
|
? rawUrl
|
|
: (config.baseURL ? `${config.baseURL.replace(/\/$/, '')}/${rawUrl.replace(/^\//, '')}` : rawUrl);
|
|
let qs = '';
|
|
if (config.params && typeof config.params === 'object') {
|
|
try { qs = '?' + new URLSearchParams(config.params).toString(); } catch (_) {}
|
|
}
|
|
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message} | ${method} ${fullUrl}${qs}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isAnimeSamaRetriableError(error) {
|
|
const statusCode = error?.response?.status;
|
|
const errorCode = error?.code;
|
|
const message = String(error?.message || '').toLowerCase();
|
|
|
|
if (statusCode === 403 || statusCode === 407 || statusCode === 408 || statusCode === 425 || statusCode === 429) {
|
|
return true;
|
|
}
|
|
|
|
if (statusCode >= 500 && statusCode < 600) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
errorCode === 'ECONNABORTED' ||
|
|
errorCode === 'ETIMEDOUT' ||
|
|
errorCode === 'ECONNRESET' ||
|
|
errorCode === 'ECONNREFUSED' ||
|
|
errorCode === 'EHOSTUNREACH' ||
|
|
errorCode === 'ENETUNREACH' ||
|
|
errorCode === 'EAI_AGAIN'
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
message.includes('socks5 authentication failed') ||
|
|
message.includes('proxy authentication') ||
|
|
message.includes('tunneling socket could not be established')
|
|
);
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates AnimeSama via CycleTLS (JA3 Chrome) + proxies
|
|
async function axiosAnimeSamaRequest(config) {
|
|
const debugAnimeSama = process.env.DEBUG_ANIMESAMA === 'true';
|
|
let urlStr = config.url || '';
|
|
|
|
const isAnimeSama = urlStr.includes('anime-sama.to') || urlStr.includes('anime-sama.si') || urlStr.includes('anime-sama.fr') ||
|
|
(config.baseURL && (config.baseURL.includes('anime-sama.to') || config.baseURL.includes('anime-sama.si') || config.baseURL.includes('anime-sama.fr')));
|
|
|
|
if (!ENABLE_ANIME_PROXY || !isAnimeSama) {
|
|
return deps.axiosAnimeSama({ ...config, timeout: 30000, proxy: false });
|
|
}
|
|
|
|
// Construire l'URL compl\u00e8te
|
|
let absoluteUrl = urlStr;
|
|
if (config.baseURL && !urlStr.startsWith('http')) {
|
|
absoluteUrl = config.baseURL + urlStr;
|
|
} else if (!urlStr.startsWith('http') && !absoluteUrl.startsWith('http')) {
|
|
// Si l'URL n'est pas compl\u00e8te et qu'on n'a pas de baseURL, utiliser ANIME_SAMA_URL
|
|
absoluteUrl = deps.ANIME_SAMA_URL + (urlStr.startsWith('/') ? urlStr.substring(1) : urlStr);
|
|
}
|
|
|
|
// Headers pour les requ\u00eates Anime-Sama via proxy
|
|
const animeSamaHeaders = {
|
|
'Accept-Language': 'fr-FR,fr;q=0.6',
|
|
'Cache-Control': 'no-cache',
|
|
'Pragma': 'no-cache',
|
|
'Priority': 'u=0, i',
|
|
'Sec-CH-UA': '"Chromium";v="142", "Brave";v="142", "Not_A Brand";v="99"',
|
|
'Sec-CH-UA-Mobile': '?0',
|
|
'Sec-CH-UA-Platform': '"Windows"',
|
|
'Sec-Fetch-Dest': 'document',
|
|
'Sec-Fetch-Mode': 'navigate',
|
|
'Sec-Fetch-Site': 'none',
|
|
'Sec-Fetch-User': '?1',
|
|
'Sec-GPC': '1',
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36'
|
|
};
|
|
|
|
// Supprimer les headers li\u00e9s \u00e0 l'IP d'origine pour \u00e9viter de les transmettre
|
|
const headersToRemove = ['X-Forwarded-For', 'X-Real-IP', 'X-Client-IP', 'CF-Connecting-IP', 'True-Client-IP', 'X-Original-Forwarded-For'];
|
|
const cleanHeaders = { ...animeSamaHeaders };
|
|
|
|
// Si config.headers existe, le nettoyer aussi
|
|
if (config.headers) {
|
|
const configHeaders = { ...config.headers };
|
|
headersToRemove.forEach(header => {
|
|
delete configHeaders[header];
|
|
delete configHeaders[header.toLowerCase()];
|
|
});
|
|
Object.assign(cleanHeaders, configHeaders);
|
|
}
|
|
|
|
// CycleTLS (JA3 Chrome) + rotation sur pool SOCKS5/HTTP
|
|
const method = String(config.method || 'get').toLowerCase();
|
|
let cycleBody = '';
|
|
if (config.data !== undefined && config.data !== null) {
|
|
cycleBody = typeof config.data === 'string' ? config.data : JSON.stringify(config.data);
|
|
}
|
|
const res = await makeAnimeSamaRequest(absoluteUrl, {
|
|
headers: cleanHeaders,
|
|
timeout: 30,
|
|
method,
|
|
body: cycleBody,
|
|
});
|
|
|
|
if (res.status >= 200 && res.status < 400) {
|
|
return {
|
|
data: res.data,
|
|
status: res.status,
|
|
headers: res.headers,
|
|
statusText: '',
|
|
config,
|
|
};
|
|
}
|
|
|
|
const cycleErr = new Error(`[ANIMESAMA CYCLETLS] status ${res.status}`);
|
|
cycleErr.response = { data: res.data, status: res.status, headers: res.headers };
|
|
if (debugAnimeSama) {
|
|
console.warn(`[ANIMESAMA REQUEST] cycletls returned ${res.status}`, summarizeRequestErrorForLog(cycleErr));
|
|
}
|
|
throw cycleErr;
|
|
}
|
|
|
|
// Fonction utilitaire pour requ\u00eates Wiflix avec proxy
|
|
// D\u00e9l\u00e8gue \u00e0 makeWiflixRequest (free proxies -> Cloudflare Workers -> direct)
|
|
async function axiosWiflixRequest(config) {
|
|
const urlStr = config.url || '';
|
|
|
|
// Construire l'URL compl\u00e8te
|
|
let absoluteUrl = urlStr;
|
|
if (config.baseURL && !urlStr.startsWith('http')) {
|
|
absoluteUrl = config.baseURL + urlStr;
|
|
} else if (!urlStr.startsWith('http')) {
|
|
absoluteUrl = WIFLIX_BASE_URL + (urlStr.startsWith('/') ? urlStr : '/' + urlStr);
|
|
}
|
|
|
|
const { url: _, baseURL: __, headers, ...restConfig } = config;
|
|
|
|
return makeWiflixRequest(absoluteUrl, {
|
|
method: restConfig.method || 'GET',
|
|
headers: headers || {},
|
|
timeout: restConfig.timeout || 15000,
|
|
...(restConfig.data !== undefined ? { data: restConfig.data } : {}),
|
|
...(restConfig.responseType ? { responseType: restConfig.responseType } : {}),
|
|
});
|
|
}
|
|
|
|
// === UTILITY: Fusion des streaming_links par langue ===
|
|
function mergeStreamingLinks(oldLinks, newLinks) {
|
|
// oldLinks et newLinks sont des tableaux d'objets { language, players }
|
|
const merged = {};
|
|
|
|
// D'abord, copier les anciens liens
|
|
(oldLinks || []).forEach(l => {
|
|
merged[l.language] = Array.isArray(l.players) ? [...l.players] : [];
|
|
});
|
|
|
|
// Ensuite, remplacer par les nouveaux liens si disponibles
|
|
(newLinks || []).forEach(l => {
|
|
if (l.players && l.players.length > 0) {
|
|
// Si on a de nouveaux lecteurs, on remplace compl\u00e8tement les anciens
|
|
merged[l.language] = Array.isArray(l.players) ? [...l.players] : [];
|
|
}
|
|
});
|
|
|
|
// Retourne sous forme d'array d'objets
|
|
return Object.entries(merged).map(([language, players]) => ({ language, players }));
|
|
}
|
|
|
|
// === UTILITY: Migration des anciens fichiers de cache s\u00e9par\u00e9s vers le cache unifi\u00e9 ===
|
|
async function migrateOldCacheFiles(safeAnimeName, animeCacheDir) {
|
|
try {
|
|
const allCacheFiles = await fsp.readdir(animeCacheDir).catch(() => []);
|
|
const oldSeasonFiles = allCacheFiles.filter(f => f.startsWith(safeAnimeName + '_') && f.endsWith('.json'));
|
|
|
|
if (oldSeasonFiles.length === 0) return;
|
|
|
|
const migratedSeasons = {};
|
|
|
|
for (const seasonFile of oldSeasonFiles) {
|
|
try {
|
|
const seasonContent = await fsp.readFile(path.join(animeCacheDir, seasonFile), 'utf-8');
|
|
const seasonCache = JSON.parse(seasonContent);
|
|
const seasonName = seasonFile.replace(safeAnimeName + '_', '').replace('.json', '');
|
|
|
|
migratedSeasons[seasonName] = {
|
|
timestamp: seasonCache.timestamp || Date.now(),
|
|
episodes: seasonCache.episodes || []
|
|
};
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
if (Object.keys(migratedSeasons).length > 0) {
|
|
const unifiedCacheData = {
|
|
timestamp: Date.now(),
|
|
seasons: migratedSeasons
|
|
};
|
|
|
|
const animeCachePath = path.join(animeCacheDir, `${safeAnimeName}.json`);
|
|
await writeFileAtomic(animeCachePath, JSON.stringify(unifiedCacheData), 'utf-8');
|
|
|
|
// Nettoyer les anciens fichiers apr\u00e8s migration r\u00e9ussie
|
|
await cleanupOldCacheFiles(safeAnimeName, animeCacheDir);
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
// === UTILITY: Nettoyage des anciens fichiers de cache s\u00e9par\u00e9s ===
|
|
async function cleanupOldCacheFiles(safeAnimeName, animeCacheDir) {
|
|
try {
|
|
const allCacheFiles = await fsp.readdir(animeCacheDir).catch(() => []);
|
|
const oldSeasonFiles = allCacheFiles.filter(f => f.startsWith(safeAnimeName + '_') && f.endsWith('.json'));
|
|
|
|
for (const oldFile of oldSeasonFiles) {
|
|
try {
|
|
await fsp.unlink(path.join(animeCacheDir, oldFile));
|
|
} catch (e) {
|
|
}
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
|
|
// Fonction pour supprimer les lecteurs fsvid des r\u00e9ponses FStream
|
|
function removeFsvidPlayers(data, isVip = false) {
|
|
// Si pas de donn\u00e9es, retourner tel quel
|
|
if (!data) {
|
|
return data;
|
|
}
|
|
|
|
// Si l'utilisateur est VIP, ne pas filtrer les lecteurs fsvid
|
|
if (isVip) {
|
|
return data;
|
|
}
|
|
|
|
// Par d\u00e9faut, toujours filtrer les lecteurs fsvid (m\u00eame si remove_fsvid est false)
|
|
|
|
// Pour les films : filtrer players.organized
|
|
if (data.players && typeof data.players === 'object') {
|
|
const filteredPlayers = {};
|
|
let totalPlayers = 0;
|
|
|
|
Object.keys(data.players).forEach(playerType => {
|
|
if (Array.isArray(data.players[playerType])) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = data.players[playerType].filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredPlayers[playerType] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
} else {
|
|
// Si ce n'est pas un array, garder tel quel
|
|
filteredPlayers[playerType] = data.players[playerType];
|
|
}
|
|
});
|
|
|
|
return {
|
|
...data,
|
|
players: filteredPlayers,
|
|
total: totalPlayers,
|
|
metadata: {
|
|
...data.metadata,
|
|
fsvidFiltered: true
|
|
}
|
|
};
|
|
}
|
|
|
|
// Pour les s\u00e9ries : filtrer episodes
|
|
if (data.episodes && typeof data.episodes === 'object') {
|
|
const filteredEpisodes = {};
|
|
let totalPlayers = 0;
|
|
|
|
Object.keys(data.episodes).forEach(episodeKey => {
|
|
const episode = data.episodes[episodeKey];
|
|
if (episode && typeof episode === 'object') {
|
|
const filteredEpisode = { ...episode };
|
|
|
|
// V\u00e9rifier si l'\u00e9pisode a une structure "languages"
|
|
if (episode.languages && typeof episode.languages === 'object') {
|
|
const filteredLanguages = {};
|
|
|
|
Object.keys(episode.languages).forEach(languageKey => {
|
|
const languagePlayers = episode.languages[languageKey];
|
|
|
|
if (Array.isArray(languagePlayers)) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = languagePlayers.filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredLanguages[languageKey] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
} else {
|
|
// Si ce n'est pas un array, garder tel quel
|
|
filteredLanguages[languageKey] = languagePlayers;
|
|
}
|
|
});
|
|
|
|
filteredEpisode.languages = filteredLanguages;
|
|
} else {
|
|
// Structure ancienne sans "languages" - filtrer directement
|
|
Object.keys(episode).forEach(playerType => {
|
|
if (Array.isArray(episode[playerType])) {
|
|
// Filtrer les lecteurs dont l'URL contient "fsvid"
|
|
const filteredPlayerList = episode[playerType].filter(player => {
|
|
return !player.url || !player.url.includes('fsvid');
|
|
});
|
|
|
|
if (filteredPlayerList.length > 0) {
|
|
filteredEpisode[playerType] = filteredPlayerList;
|
|
totalPlayers += filteredPlayerList.length;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Garder l'\u00e9pisode seulement s'il a encore des lecteurs
|
|
if (episode.languages ? Object.keys(filteredEpisode.languages).length > 0 : Object.keys(filteredEpisode).some(key => Array.isArray(filteredEpisode[key]) && filteredEpisode[key].length > 0)) {
|
|
filteredEpisodes[episodeKey] = filteredEpisode;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
...data,
|
|
episodes: filteredEpisodes,
|
|
total: totalPlayers,
|
|
metadata: {
|
|
...data.metadata,
|
|
fsvidFiltered: true
|
|
}
|
|
};
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// Fonction pour formater les erreurs Coflix
|
|
function formatCoflixError(error, context = '') {
|
|
if (error && error.isAxiosError) {
|
|
const url = error.config && error.config.url ? error.config.url : '';
|
|
const statusCode = error.response ? error.response.status : '';
|
|
const statusText = error.response ? error.response.statusText : '';
|
|
|
|
// Ne pas logger les erreurs 400
|
|
if (statusCode === 400) {
|
|
return '';
|
|
}
|
|
|
|
return `[AxiosError] ${error.code || ''} ${error.message} ${statusCode ? `(${statusCode} ${statusText})` : ''} ${url}`;
|
|
} else {
|
|
const msg = error && error.message
|
|
? error.message
|
|
: (typeof error === 'string' ? error : JSON.stringify(error));
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
configure,
|
|
|
|
// Darkino
|
|
generateDarkiReferer,
|
|
axiosDarkinoRequest,
|
|
|
|
// Coflix
|
|
axiosCoflixRequest,
|
|
formatCoflixError,
|
|
|
|
// French-Stream
|
|
axiosFrenchStreamRequest,
|
|
|
|
// LecteurVideo
|
|
axiosLecteurVideoRequest,
|
|
|
|
// FStream
|
|
axiosFStreamRequest,
|
|
|
|
// AnimeSama
|
|
axiosAnimeSamaRequest,
|
|
|
|
// Wiflix
|
|
axiosWiflixRequest,
|
|
|
|
// Streaming link utilities
|
|
mergeStreamingLinks,
|
|
migrateOldCacheFiles,
|
|
cleanupOldCacheFiles,
|
|
|
|
// FStream player filter
|
|
removeFsvidPlayers
|
|
};
|