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>
746 lines
28 KiB
JavaScript
746 lines
28 KiB
JavaScript
/**
|
|
* DarkiWorld routes module.
|
|
* Extracted from server.js — handles DarkiWorld download links, decoding,
|
|
* seasons and episodes retrieval.
|
|
*
|
|
* Mounted at /api/darkiworld (paths below are relative to that prefix).
|
|
*
|
|
* Under the hydracker freeze (2026-05-15), the list of links and the decode
|
|
* step come exclusively from the local sqlite snapshots under
|
|
* `Mainapi/darkino-backups/`. No outbound calls to hydracker.com are made
|
|
* from this file; the only upstream surface that remains is the seasons
|
|
* metadata endpoints in `/seasons/:titleId` and `/episodes/:titleId/:seasonNumber`.
|
|
*
|
|
* See: docs/superpowers/specs/2026-05-15-hydracker-freeze-sqlite-source-design.md
|
|
*/
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const path = require('path');
|
|
const fsp = require('fs').promises;
|
|
const { generateCacheKey } = require('../utils/cacheManager');
|
|
const { getAuthIfValid } = require('../middleware/auth');
|
|
const { getPool: getMovixPool } = require('../mysqlPool');
|
|
const darkiworldSqlite = require('../utils/darkiworldSqlite');
|
|
const hydrackerLiveModule = require('../utils/hydrackerLive');
|
|
const { redis: redisClient } = require('../config/redis');
|
|
const axios = require('axios');
|
|
|
|
// IDs to watch — any decode hit on these fires a Discord alert.
|
|
const SCRAPER_WATCHLIST = new Set(['17084892']);
|
|
|
|
// Comma-separated IPs in SCRAPER_BLOCKED_IPS env var get poison 200 on /decode.
|
|
// Reload needs restart; use CIDR not supported (exact match only).
|
|
function getRequestIp(req) {
|
|
return (
|
|
req.headers['cf-connecting-ip'] ||
|
|
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
req.socket?.remoteAddress ||
|
|
''
|
|
);
|
|
}
|
|
|
|
function isBlockedScraper(req) {
|
|
const raw = process.env.SCRAPER_BLOCKED_IPS || '';
|
|
if (!raw) return false;
|
|
const ip = getRequestIp(req);
|
|
return raw.split(',').map(s => s.trim()).includes(ip);
|
|
}
|
|
|
|
function poisonDecodeResponse(id) {
|
|
return {
|
|
success: true,
|
|
id: String(id),
|
|
provider: 'direct',
|
|
embed_url: {
|
|
lien: 'https://t.me/movix_site',
|
|
taille: null,
|
|
created_at: null,
|
|
},
|
|
metadata: { size: null, upload_date: null },
|
|
source: 'mirror',
|
|
};
|
|
}
|
|
|
|
async function fireScraperWebhook(req, id) {
|
|
const webhookUrl = process.env.DISCORD_SCRAPER_WEBHOOK;
|
|
if (!webhookUrl) return;
|
|
const ip =
|
|
req.headers['cf-connecting-ip'] ||
|
|
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
req.socket?.remoteAddress ||
|
|
'unknown';
|
|
const fields = [
|
|
{ name: 'ID', value: String(id), inline: true },
|
|
{ name: 'title_id', value: String(req.query.title_id || '-'), inline: true },
|
|
{ name: 'IP', value: ip, inline: true },
|
|
{ name: 'User-Agent', value: String(req.headers['user-agent'] || '-'), inline: false },
|
|
{ name: 'Origin', value: String(req.headers['origin'] || '-'), inline: true },
|
|
{ name: 'Referer', value: String(req.headers['referer'] || '-'), inline: true },
|
|
{ name: 'Sec-Fetch-Site', value: String(req.headers['sec-fetch-site'] || '-'), inline: true },
|
|
{ name: 'Sec-Fetch-Mode', value: String(req.headers['sec-fetch-mode'] || '-'), inline: true },
|
|
{ name: 'Accept-Language', value: String(req.headers['accept-language'] || '-'), inline: true },
|
|
{ name: 'CF-IPCountry', value: String(req.headers['cf-ipcountry'] || '-'), inline: true },
|
|
{ name: 'X-Forwarded-For (full)', value: String(req.headers['x-forwarded-for'] || '-'), inline: false },
|
|
];
|
|
const embed = {
|
|
title: `🔍 Scraper Watch — decode/${id}`,
|
|
color: 0xff4444,
|
|
fields,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
try {
|
|
await axios.post(webhookUrl, { embeds: [embed] }, { timeout: 5000 });
|
|
} catch (e) {
|
|
console.warn('[scraperWatch] webhook failed:', e.message);
|
|
}
|
|
}
|
|
|
|
// One-shot boot log: surface live-fallback configuration so operators can
|
|
// confirm at process start whether the live path will engage on sqlite miss.
|
|
//
|
|
// HYDRACKER_LIVE_ENABLED is the master kill-switch for outbound lien fetches.
|
|
// true → cache+sqlite miss falls through to hydracker.com /api/v1/content/liens
|
|
// (decode) and /api/v1/titles/.../content/liens (list). hydracker
|
|
// returns the raw host link (raw_url/directDL) directly.
|
|
// false → cache+sqlite are the only sources. Every miss returns sqlite_miss
|
|
// without any outbound HTTP. No new liens are discovered.
|
|
(() => {
|
|
const enabled = process.env.HYDRACKER_LIVE_ENABLED === 'true';
|
|
const cookiesLen = (process.env.DARKIWORLD_COOKIES || '').length;
|
|
const xsrfLen = (process.env.DARKIWORLD_XSRF_TOKEN || '').length;
|
|
const concurrency = parseInt(process.env.HYDRACKER_LIVE_CONCURRENCY, 10) || 6;
|
|
const timeoutMs = parseInt(process.env.HYDRACKER_LIVE_TIMEOUT_MS, 10) || 20000;
|
|
console.log(
|
|
`[hydrackerLive][boot] enabled=${enabled} ` +
|
|
`mode=${enabled ? 'cache+sqlite+hydracker_live' : 'cache+sqlite_only'} ` +
|
|
`concurrency=${concurrency} timeout=${timeoutMs}ms ` +
|
|
`cookies_len=${cookiesLen} xsrf_len=${xsrfLen} pid=${process.pid}`,
|
|
);
|
|
if (enabled && cookiesLen === 0) {
|
|
console.warn('[hydrackerLive][boot] DARKIWORLD_COOKIES is empty — every hydracker fetch will return live_hydracker_error');
|
|
}
|
|
})();
|
|
|
|
const HOST_ICON_MAP = {
|
|
'1fichier': '/hosts/1fichier.svg',
|
|
'Mega': '/hosts/mega.svg',
|
|
'Uploaded': '/hosts/uploaded.svg',
|
|
'RapidGator': '/hosts/rapidgator.svg',
|
|
'Google Drive': '/hosts/gdrive.svg',
|
|
'Dropbox': '/hosts/dropbox.svg',
|
|
};
|
|
|
|
// Master kill-switch for outbound lien discovery.
|
|
// Returns a configured live instance only when HYDRACKER_LIVE_ENABLED=true.
|
|
// When null, both `decode` (resolveLien) and `download` (listLiensForTitle)
|
|
// fall back to cache+sqlite only — no new liens are fetched from hydracker.com.
|
|
let _hydrackerLive = null;
|
|
function getHydrackerLive() {
|
|
if (process.env.HYDRACKER_LIVE_ENABLED !== 'true') return null;
|
|
if (_hydrackerLive) return _hydrackerLive;
|
|
console.log(`[hydrackerLive][init] building live instance on first decode request (pid=${process.pid})`);
|
|
_hydrackerLive = hydrackerLiveModule.createHydrackerLive({
|
|
redis: redisClient,
|
|
axios,
|
|
cookies: process.env.DARKIWORLD_COOKIES || '',
|
|
xsrf: process.env.DARKIWORLD_XSRF_TOKEN || '',
|
|
concurrency: parseInt(process.env.HYDRACKER_LIVE_CONCURRENCY, 10) || 6,
|
|
timeoutMs: parseInt(process.env.HYDRACKER_LIVE_TIMEOUT_MS, 10) || 20000,
|
|
cacheDir: DOWNLOAD_CACHE_DIR,
|
|
cacheGet: (dir, key) => getFromCacheNoExpiration(dir, key),
|
|
cacheSet: (dir, key, val) => saveToCache(dir, key, val),
|
|
cacheKeyFor: (id) => generateCacheKey(`darkiworld_decode_v2_${id}`),
|
|
});
|
|
return _hydrackerLive;
|
|
}
|
|
|
|
async function resolveMovixUsername(userId, authType) {
|
|
try {
|
|
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
const safeUserType = authType === 'bip-39' ? 'bip39' : 'oauth';
|
|
const userPath = path.join(__dirname, '..', 'data', 'users', safeUserType, `${safeUserId}.json`);
|
|
const data = JSON.parse(await fsp.readFile(userPath, 'utf8'));
|
|
if (data.profiles && data.profiles.length > 0) {
|
|
return { username: data.profiles[0].name || 'Admin', avatar: data.profiles[0].avatar || null };
|
|
}
|
|
} catch { /* fall through */ }
|
|
return { username: 'Admin', avatar: null };
|
|
}
|
|
|
|
async function fetchMovixDownloadLinks(type, id, season, episode) {
|
|
const pool = getMovixPool();
|
|
let raw = [];
|
|
if (type === 'movie') {
|
|
const [rows] = await pool.execute('SELECT download_links FROM films WHERE id = ?', [id]);
|
|
raw = rows[0]?.download_links
|
|
? (typeof rows[0].download_links === 'string' ? JSON.parse(rows[0].download_links) : rows[0].download_links)
|
|
: [];
|
|
} else {
|
|
const seasonNum = Number(season);
|
|
const episodeNum = Number(episode);
|
|
const [episodeRows] = await pool.execute(
|
|
'SELECT download_links FROM series WHERE series_id = ? AND season_number = ? AND episode_number = ?',
|
|
[id, seasonNum, episodeNum]
|
|
);
|
|
const [seasonRows] = await pool.execute(
|
|
'SELECT download_links FROM series WHERE series_id = ? AND season_number = ? AND episode_number = 0',
|
|
[id, seasonNum]
|
|
);
|
|
const parse = (rows) => rows[0]?.download_links
|
|
? (typeof rows[0].download_links === 'string' ? JSON.parse(rows[0].download_links) : rows[0].download_links)
|
|
: [];
|
|
raw = [...parse(episodeRows), ...parse(seasonRows)];
|
|
}
|
|
|
|
const normalized = await Promise.all(raw.map(async (l) => {
|
|
const addedBy = l.added_by || {};
|
|
const userInfo = addedBy.id
|
|
? await resolveMovixUsername(addedBy.id, addedBy.auth_type)
|
|
: { username: 'Admin', avatar: null };
|
|
const isFullSeason = Boolean(l.full_saison);
|
|
return {
|
|
source: 'movix',
|
|
id: `movix:${l.url}`,
|
|
url: l.url,
|
|
host_id: -1,
|
|
host_name: l.host || 'Movix',
|
|
host_icon: HOST_ICON_MAP[l.host] || null,
|
|
provider: 'movix',
|
|
language: l.language,
|
|
quality: l.quality,
|
|
sub: Boolean(l.sub),
|
|
size: l.size || '',
|
|
full_saison: isFullSeason ? 1 : undefined,
|
|
saison: type === 'tv' ? Number(season) : undefined,
|
|
episode: type === 'tv' ? (isFullSeason ? 0 : Number(episode)) : undefined,
|
|
added_at: l.added_at || null,
|
|
added_by: { username: userInfo.username, avatar: userInfo.avatar },
|
|
};
|
|
}));
|
|
|
|
return normalized;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dependencies injected via configure()
|
|
// ---------------------------------------------------------------------------
|
|
let DARKINO_MAINTENANCE;
|
|
let DOWNLOAD_CACHE_DIR;
|
|
let getFromCacheNoExpiration;
|
|
let saveToCache;
|
|
let shouldUpdateCache;
|
|
// Seasons routes still use axiosDarkinoRequest + refreshDarkinoSessionIfNeeded.
|
|
let axiosDarkinoRequest;
|
|
let refreshDarkinoSessionIfNeeded;
|
|
|
|
/**
|
|
* Inject runtime dependencies that still live in server.js.
|
|
*/
|
|
function configure(deps) {
|
|
if (deps.DARKINO_MAINTENANCE !== undefined) DARKINO_MAINTENANCE = deps.DARKINO_MAINTENANCE;
|
|
if (deps.DOWNLOAD_CACHE_DIR) DOWNLOAD_CACHE_DIR = deps.DOWNLOAD_CACHE_DIR;
|
|
if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration;
|
|
if (deps.saveToCache) saveToCache = deps.saveToCache;
|
|
if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache;
|
|
if (deps.axiosDarkinoRequest) axiosDarkinoRequest = deps.axiosDarkinoRequest;
|
|
if (deps.refreshDarkinoSessionIfNeeded) refreshDarkinoSessionIfNeeded = deps.refreshDarkinoSessionIfNeeded;
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
async function fetchLegacySeasonsPage(titleId, page, perPage) {
|
|
const response = await axiosDarkinoRequest({
|
|
method: 'get',
|
|
url: `/api/v1/titles/${titleId}/seasons?perPage=${perPage}&query=&page=${page}`
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
mode: 'legacy',
|
|
...response.data
|
|
};
|
|
}
|
|
|
|
async function fetchSeasonPageForCount(titleId, seasonNumbers = []) {
|
|
const attemptedSeasonNumbers = new Set();
|
|
|
|
for (const rawSeasonNumber of seasonNumbers) {
|
|
const seasonNumber = parsePositiveInt(rawSeasonNumber, null);
|
|
if (!seasonNumber || attemptedSeasonNumbers.has(seasonNumber)) {
|
|
continue;
|
|
}
|
|
|
|
attemptedSeasonNumbers.add(seasonNumber);
|
|
|
|
try {
|
|
const response = await axiosDarkinoRequest({
|
|
method: 'get',
|
|
url: `/api/v1/titles/${titleId}/seasons/${seasonNumber}?loader=seasonPage`
|
|
});
|
|
|
|
const seasonsCount = parsePositiveInt(response.data?.title?.seasons_count, 0);
|
|
if (seasonsCount > 0) {
|
|
return {
|
|
seasonsCount,
|
|
data: response.data
|
|
};
|
|
}
|
|
} catch (_) { /* upstream 404/422 expected, others swallowed */ }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildSyntheticSeasonsResponse(titleId, page, perPage, seasonPagePayload) {
|
|
const currentPage = parsePositiveInt(page, 1);
|
|
const itemsPerPage = parsePositiveInt(perPage, 8);
|
|
const seasonsCount = parsePositiveInt(seasonPagePayload?.title?.seasons_count, 0);
|
|
const selectedSeason = seasonPagePayload?.season || null;
|
|
const selectedSeasonNumber = parsePositiveInt(selectedSeason?.number, 0);
|
|
const lastPage = seasonsCount > 0 ? Math.ceil(seasonsCount / itemsPerPage) : 1;
|
|
const safePage = Math.min(currentPage, lastPage);
|
|
const startIndex = seasonsCount > 0 ? (safePage - 1) * itemsPerPage : 0;
|
|
|
|
const allSeasons = Array.from({ length: seasonsCount }, (_, index) => {
|
|
const seasonNumber = index + 1;
|
|
const isSelectedSeason = selectedSeasonNumber === seasonNumber;
|
|
|
|
return {
|
|
id: isSelectedSeason && selectedSeason?.id ? selectedSeason.id : seasonNumber,
|
|
poster: isSelectedSeason ? (selectedSeason?.poster || '') : '',
|
|
release_date: isSelectedSeason
|
|
? (selectedSeason?.release_date || seasonPagePayload?.title?.release_date || '')
|
|
: '',
|
|
number: seasonNumber,
|
|
title_id: isSelectedSeason && selectedSeason?.title_id
|
|
? selectedSeason.title_id
|
|
: parsePositiveInt(titleId, 0),
|
|
episodes_count: isSelectedSeason
|
|
? parsePositiveInt(selectedSeason?.episodes_count ?? selectedSeason?.episode_count, 0)
|
|
: 0,
|
|
model_type: isSelectedSeason ? (selectedSeason?.model_type || 'season') : 'season',
|
|
first_episode: isSelectedSeason ? (selectedSeason?.first_episode || null) : null
|
|
};
|
|
});
|
|
|
|
const data = allSeasons.slice(startIndex, startIndex + itemsPerPage);
|
|
const from = data.length > 0 ? startIndex + 1 : 0;
|
|
const to = data.length > 0 ? startIndex + data.length : 0;
|
|
|
|
return {
|
|
success: true,
|
|
mode: 'seasonPage',
|
|
title: seasonPagePayload?.title || null,
|
|
loader: seasonPagePayload?.loader || 'seasonPage',
|
|
pagination: {
|
|
current_page: safePage,
|
|
data,
|
|
from,
|
|
last_page: lastPage,
|
|
next_page: safePage < lastPage ? safePage + 1 : null,
|
|
per_page: itemsPerPage,
|
|
prev_page: safePage > 1 ? safePage - 1 : null,
|
|
to,
|
|
total: seasonsCount
|
|
}
|
|
};
|
|
}
|
|
|
|
async function fetchSeasonsCountResponse(titleId, page, perPage) {
|
|
let seasonPageResult = await fetchSeasonPageForCount(titleId, [1]);
|
|
|
|
if (!seasonPageResult) {
|
|
try {
|
|
const legacyBootstrap = await fetchLegacySeasonsPage(titleId, 1, 1);
|
|
const firstLegacySeasonNumber = legacyBootstrap?.pagination?.data?.[0]?.number;
|
|
seasonPageResult = await fetchSeasonPageForCount(titleId, [firstLegacySeasonNumber]);
|
|
} catch (_) { /* upstream missing, fall through to legacy path */ }
|
|
}
|
|
|
|
if (!seasonPageResult) {
|
|
return null;
|
|
}
|
|
|
|
return buildSyntheticSeasonsResponse(titleId, page, perPage, seasonPageResult.data);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /download/:type/:id
|
|
// Récupérer tous les liens d'amélioration DarkiWorld pour un film ou un épisode
|
|
// Params: type (movie/tv), id (TMDB ID)
|
|
// Query: season (optionnel pour les séries), episode (optionnel pour les séries)
|
|
// ---------------------------------------------------------------------------
|
|
router.get('/download/:type/:id', async (req, res) => {
|
|
try {
|
|
const { type, id } = req.params;
|
|
const { season, episode, tmdbId } = req.query;
|
|
|
|
if (!['movie', 'tv'].includes(type)) {
|
|
return res.status(400).json({ success: false, error: 'Type invalide. Utilisez "movie" ou "tv"' });
|
|
}
|
|
if (type === 'tv' && (!season || !episode)) {
|
|
return res.status(400).json({ success: false, error: 'Pour les séries, les paramètres season et episode sont requis' });
|
|
}
|
|
|
|
const cacheKey = generateCacheKey(
|
|
`darkiworld_download_v2_${type}_${id}${type === 'tv' ? `_${season}_${episode}` : ''}`
|
|
);
|
|
|
|
const movixLookupId = tmdbId ? String(tmdbId) : id;
|
|
const movixLinks = await fetchMovixDownloadLinks(type, movixLookupId, season, episode);
|
|
|
|
// Disk cache holds the sqlite-derived rows only (sqlite is static so
|
|
// caching forever is safe). Live rows come from a separate Redis-cached
|
|
// layer (titleListCacheTtl ~5min) so they refresh as hydracker keeps
|
|
// adding new liens past the sqlite freeze cutoff.
|
|
let darkiList;
|
|
const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
if (cachedData && Array.isArray(cachedData.all)) {
|
|
darkiList = cachedData.all.map((r) => ({ ...r, source: r.source || 'darkiworld' }));
|
|
} else {
|
|
const sqliteList = darkiworldSqlite.listByTitle({
|
|
type,
|
|
titleId: id,
|
|
season: type === 'tv' ? Number(season) : undefined,
|
|
episode: type === 'tv' ? Number(episode) : undefined,
|
|
});
|
|
darkiList = sqliteList.map((r) => ({ ...r, source: 'darkiworld' }));
|
|
// Background disk-cache the sqlite portion. Live portion is NOT
|
|
// persisted to disk — Redis 5min cache + freshness on every request.
|
|
(async () => {
|
|
try {
|
|
if (sqliteList.length > 0) {
|
|
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, {
|
|
success: true,
|
|
all: sqliteList.map((r) => ({ ...r, source: 'darkiworld' })),
|
|
});
|
|
}
|
|
} catch (_) { /* silent */ }
|
|
})();
|
|
}
|
|
|
|
// Live supplement: hydracker keeps adding liens past the sqlite freeze
|
|
// date. Fetch the live listing (Redis-cached 5min) and merge any rows
|
|
// that aren't already in the sqlite result set (dedup by lien id).
|
|
let liveLinks = [];
|
|
const live = getHydrackerLive();
|
|
if (live && typeof live.listLiensForTitle === 'function') {
|
|
try {
|
|
const live0 = Date.now();
|
|
const liveRows = await live.listLiensForTitle(id, {
|
|
type,
|
|
season: type === 'tv' ? Number(season) : undefined,
|
|
episode: type === 'tv' ? Number(episode) : undefined,
|
|
});
|
|
const darkiIds = new Set(darkiList.map((r) => r.id));
|
|
liveLinks = liveRows
|
|
.filter((r) => !darkiIds.has(r.id))
|
|
.map((r) => ({ ...r, source: 'hydracker-live' }));
|
|
console.log(
|
|
`[download] id=${id} type=${type} sqlite=${darkiList.length} ` +
|
|
`live=${liveRows.length} new_from_live=${liveLinks.length} dt=${Date.now() - live0}ms`,
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[download] live listLiensForTitle threw id=${id}: ${e?.message}`);
|
|
}
|
|
}
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
all: [...movixLinks, ...darkiList, ...liveLinks],
|
|
movixCount: movixLinks.length,
|
|
});
|
|
|
|
} catch (error) {
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors de la récupération des liens DarkiWorld',
|
|
message: error.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /decode/:id
|
|
// Extraire le lien décodé (m3u8) pour un ID de lien DarkiWorld
|
|
// Params: id (ID du lien DarkiWorld)
|
|
// ---------------------------------------------------------------------------
|
|
router.get('/decode/:id', async (req, res) => {
|
|
const t0 = Date.now();
|
|
const { id } = req.params;
|
|
const { title_id, debug: debugQuery } = req.query;
|
|
const debugMode = debugQuery === '1' || debugQuery === 'true';
|
|
const live = getHydrackerLive();
|
|
console.log(
|
|
`[decode] start id=${id} title_id=${title_id || '-'} live_wired=${!!live} ` +
|
|
`pid=${process.pid}`,
|
|
);
|
|
try {
|
|
if (!id) return res.status(400).json({ success: false, error: 'ID du lien requis' });
|
|
|
|
if (SCRAPER_WATCHLIST.has(String(id))) {
|
|
fireScraperWebhook(req, id).catch(() => {});
|
|
}
|
|
|
|
if (isBlockedScraper(req)) {
|
|
console.log(`[decode] poison id=${id} ip=${getRequestIp(req)}`);
|
|
return res.status(200).json(poisonDecodeResponse(id));
|
|
}
|
|
|
|
if (DARKINO_MAINTENANCE) {
|
|
console.log(`[decode] short-circuit maintenance id=${id}`);
|
|
return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' });
|
|
}
|
|
|
|
const result = await darkiworldSqlite.decodeLink(id, {
|
|
cacheDir: DOWNLOAD_CACHE_DIR,
|
|
generateCacheKey,
|
|
getFromCacheNoExpiration,
|
|
saveToCache,
|
|
hydrackerLive: live,
|
|
});
|
|
|
|
const dt = Date.now() - t0;
|
|
if (result.payload) {
|
|
console.log(
|
|
`[decode] ok id=${id} provider=${result.payload.provider || '?'} ` +
|
|
`source=${result.payload.source || '?'} host=${result.payload?.metadata?.host || '?'} dt=${dt}ms`,
|
|
);
|
|
if (debugMode) {
|
|
return res.status(200).json({
|
|
...result.payload,
|
|
_debug: { id, title_id: title_id || null, live_wired: !!live, dt_ms: dt, env_enabled: process.env.HYDRACKER_LIVE_ENABLED === 'true' },
|
|
});
|
|
}
|
|
return res.status(200).json(result.payload);
|
|
}
|
|
if (result.failed) {
|
|
console.log(
|
|
`[decode] failed id=${id} debug=${result.failed.debug || '?'} ` +
|
|
`status=${result.failed.status ?? '-'} dt=${dt}ms`,
|
|
);
|
|
const body = {
|
|
success: false,
|
|
error: result.failed.error || 'Lien non trouvé ou inaccessible',
|
|
id: result.failed.id || id,
|
|
debug: result.failed.debug || '',
|
|
};
|
|
if (debugMode) {
|
|
body._debug = {
|
|
title_id: title_id || null,
|
|
live_wired: !!live,
|
|
dt_ms: dt,
|
|
env_enabled: process.env.HYDRACKER_LIVE_ENABLED === 'true',
|
|
marker_status: result.failed.status ?? null,
|
|
marker_failedAt: result.failed.failedAt ?? null,
|
|
};
|
|
}
|
|
return res.status(404).json(body);
|
|
}
|
|
return res.status(500).json({ success: false, error: 'Unknown decode result' });
|
|
|
|
} catch (error) {
|
|
console.error(`[decode] throw id=${id} msg=${error?.message} stack=${error?.stack?.split('\n')[1]?.trim() || '-'}`);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors du décodage du lien DarkiWorld',
|
|
message: error.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /seasons/:titleId
|
|
// Récupérer les saisons d'une série depuis DarkiWorld
|
|
// Params: titleId (ID DarkiWorld de la série)
|
|
// Query: page (optionnel, défaut: 1), perPage (optionnel, défaut: 8)
|
|
// ---------------------------------------------------------------------------
|
|
router.get('/seasons/:titleId', async (req, res) => {
|
|
let cacheKey;
|
|
let dataReturned = false;
|
|
try {
|
|
const { titleId } = req.params;
|
|
const { page = 1, perPage = 8, mode = 'auto' } = req.query;
|
|
|
|
if (!titleId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'ID de la série requis'
|
|
});
|
|
}
|
|
|
|
const currentPage = parsePositiveInt(page, 1);
|
|
const itemsPerPage = parsePositiveInt(perPage, 8);
|
|
const normalizedMode = mode === 'legacy' ? 'legacy' : mode === 'seasonPage' ? 'seasonPage' : 'auto';
|
|
|
|
// Nouvelle clé de cache pour éviter de ressortir l'ancien format paginé.
|
|
cacheKey = generateCacheKey(`darkiworld_seasons_v2_${normalizedMode}_${titleId}_${currentPage}_${itemsPerPage}`);
|
|
|
|
// Check if results are in cache without expiration (stale-while-revalidate)
|
|
const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
|
|
if (cachedData) {
|
|
// console.log(`Saisons pour ${titleId} récupérées du cache`);
|
|
res.status(200).json(cachedData); // Return cached data immediately
|
|
dataReturned = true;
|
|
}
|
|
|
|
// Récupérer les saisons depuis DarkiWorld
|
|
let responseData = null;
|
|
|
|
if (normalizedMode !== 'legacy') {
|
|
responseData = await fetchSeasonsCountResponse(titleId, currentPage, itemsPerPage);
|
|
}
|
|
|
|
if (!responseData) {
|
|
responseData = await fetchLegacySeasonsPage(titleId, currentPage, itemsPerPage);
|
|
}
|
|
|
|
// Si on n'a pas encore retourné de données, retourner maintenant
|
|
if (!dataReturned) {
|
|
res.json(responseData);
|
|
}
|
|
|
|
// Background update du cache
|
|
(async () => {
|
|
try {
|
|
// Vérifier si le cache doit être mis à jour
|
|
const shouldUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
if (!shouldUpdate) {
|
|
return; // Ne pas mettre à jour le cache
|
|
}
|
|
|
|
// Si on a des données, sauvegarder dans le cache
|
|
if (responseData && responseData.pagination) {
|
|
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, responseData);
|
|
}
|
|
} catch (cacheError) {
|
|
// Silent fail on cache save
|
|
}
|
|
})();
|
|
|
|
} catch (error) {
|
|
|
|
// Si Darkino retourne 500, ne pas créer/mettre à jour le cache et renvoyer le cache existant si présent
|
|
if (error.response && error.response.status >= 500) {
|
|
// Si on a déjà retourné des données (cache), on ne fait RIEN
|
|
if (dataReturned) return;
|
|
|
|
try {
|
|
const fallbackCache = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
if (fallbackCache) {
|
|
return res.status(200).json(fallbackCache);
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors de la récupération des saisons DarkiWorld',
|
|
message: error.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /episodes/:titleId/:seasonNumber
|
|
// Récupérer les épisodes d'une saison depuis DarkiWorld
|
|
// Params: titleId (ID DarkiWorld de la série), seasonNumber (numéro de la saison: 0, 1, 2, etc.)
|
|
// Query: page (optionnel, défaut: 1), perPage (optionnel, défaut: 30)
|
|
// ---------------------------------------------------------------------------
|
|
router.get('/episodes/:titleId/:seasonNumber', async (req, res) => {
|
|
let dataReturned = false;
|
|
try {
|
|
const { titleId, seasonNumber } = req.params;
|
|
const { page = 1, perPage = 30 } = req.query;
|
|
|
|
if (!titleId || seasonNumber === undefined) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'ID de la série et numéro de saison requis'
|
|
});
|
|
}
|
|
|
|
// Generate cache key
|
|
const cacheKey = generateCacheKey(`darkiworld_episodes_${titleId}_${seasonNumber}_${page}_${perPage}`);
|
|
|
|
// Check if results are in cache without expiration (stale-while-revalidate)
|
|
const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
|
|
if (cachedData) {
|
|
// console.log(`Épisodes pour ${titleId}/${seasonNumber} récupérés du cache`);
|
|
res.status(200).json(cachedData); // Return cached data immediately
|
|
dataReturned = true;
|
|
}
|
|
|
|
// Récupérer les épisodes depuis DarkiWorld
|
|
const episodesResponse = await axiosDarkinoRequest({
|
|
method: 'get',
|
|
url: `/api/v1/titles/${titleId}/seasons/${seasonNumber}/episodes?perPage=${perPage}&excludeDescription=true&query=&orderBy=episode_number&orderDir=asc&page=${page}`
|
|
});
|
|
|
|
const responseData = {
|
|
success: true,
|
|
...episodesResponse.data
|
|
};
|
|
|
|
// Si on n'a pas encore retourné de données, retourner maintenant
|
|
if (!dataReturned) {
|
|
res.json(responseData);
|
|
}
|
|
|
|
// Background update du cache
|
|
(async () => {
|
|
try {
|
|
// Vérifier si le cache doit être mis à jour
|
|
const shouldUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
if (!shouldUpdate) {
|
|
return; // Ne pas mettre à jour le cache
|
|
}
|
|
|
|
// Si on a des données, sauvegarder dans le cache
|
|
if (episodesResponse.data && episodesResponse.data.pagination) {
|
|
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, responseData);
|
|
}
|
|
} catch (cacheError) {
|
|
// Silent fail on cache save
|
|
}
|
|
})();
|
|
|
|
} catch (error) {
|
|
|
|
// Si Darkino retourne 500, ne pas créer/mettre à jour le cache et renvoyer le cache existant si présent
|
|
if (error.response && error.response.status >= 500) {
|
|
// Si on a déjà retourné des données (cache), on ne fait RIEN
|
|
if (dataReturned) return;
|
|
|
|
try {
|
|
const fallbackCache = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
|
|
if (fallbackCache) {
|
|
return res.status(200).json(fallbackCache);
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors de la récupération des épisodes DarkiWorld',
|
|
message: error.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.configure = configure;
|