MovixOpenSource/API/Mainapi/routes/fstream.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
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>
2026-07-05 18:22:13 +02:00

2070 lines
86 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FStream routes.
* Extracted from server.js -- FStream authentication, search, scraping, player extraction, and routes.
* Mount point: app.use('/api/fstream', router)
*/
const express = require('express');
const router = express.Router();
const axios = require('axios');
const cheerio = require('cheerio');
const path = require('path');
const fsp = require('fs').promises;
const { getRedis } = require('../config/redis');
const { fetchTmdbDetails } = require('../utils/tmdbCache');
const {
CACHE_DIR,
generateFStreamCacheKey,
getFStreamFromCache,
saveFStreamToCache,
ongoingFStreamRequests,
getOrCreateFStreamRequest
} = require('../utils/cacheManager');
const axiosHelpers = require('../utils/axiosHelpers');
const { axiosFStreamRequest } = axiosHelpers;
const { PROXIES, DARKINO_PROXIES, getProxyAgent, getDarkinoHttpProxyAgent } = require('../utils/proxyManager');
// === FStream Configuration ===
const TMDB_API_KEY = process.env.TMDB_API_KEY || '';
const TMDB_API_URL = 'https://api.themoviedb.org/3';
const FSTREAM_BASE_URL = 'https://french-stream.one';
const FSTREAM_SEARCH_URL = `${FSTREAM_BASE_URL}/engine/ajax/search.php`;
// === FStream Authentication (disabled) ===
const FSTREAM_LOGIN_NAME = process.env.FSTREAM_LOGIN_NAME || '';
const FSTREAM_LOGIN_PASSWORD = process.env.FSTREAM_LOGIN_PASSWORD || '';
let fstreamLoginPromise = null;
// Configuration des cookies FStream
const fstreamCookies = {
'PHPSESSID': '',
'dle_user_id': '',
'dle_password': '',
'dle_skin': 'VFV25',
'dle_newpm': '0',
'__cf_logged_in': '1',
'CF_VERIFIED_DEVICE_ae9bb95a6761c08a92f916b7ed7d2c4a985eb220591d1410240412c516f37b0c': '1756239054',
// Anti-bot "fsschal": le challenge JS de FStream pose juste ce cookie statique puis recharge.
// Sans lui, search.php / episodes_p.php / film_api.php renvoient la page "Verification..." (1466b)
// au lieu des donnees. Les fichiers /static/ en sont exempts. Valeur overridable si elle tourne.
'fsschal': process.env.FSTREAM_FSSCHAL || '1'
};
// Construit le header Cookie complet depuis fstreamCookies, pour les requetes axios
// directes (episodes_p, film_api, static) qui ne passent pas par withOptionalFStreamCookies.
function buildFStreamCookieHeader() {
return Object.entries(fstreamCookies)
.filter(([, v]) => v !== '' && v != null)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
let fstreamRequestCounter = 0;
const MAX_REQUESTS_PER_SESSION = 5;
function extractCookieValue(cookies, name) {
if (!cookies || !Array.isArray(cookies)) return null;
const target = cookies.find(c => typeof c === 'string' && c.startsWith(`${name}=`));
if (!target) return null;
const semi = target.indexOf(';');
const pair = semi !== -1 ? target.slice(0, semi) : target;
const idx = pair.indexOf('=');
return idx !== -1 ? pair.slice(idx + 1) : null;
}
function hasFStreamAuthCookies() {
return Boolean(fstreamCookies['dle_user_id'] && fstreamCookies['dle_password']);
}
function hasUsableFStreamSession() {
return hasFStreamAuthCookies() || Boolean(fstreamCookies['PHPSESSID']);
}
function canUseFStreamAuth() {
return false;
}
async function loginToFStream() {
if (!canUseFStreamAuth()) return false;
if (fstreamLoginPromise) return fstreamLoginPromise;
fstreamLoginPromise = (async () => {
try {
// We need the raw axiosFStream instance for login (no proxy)
// Use axios directly for the login request
const formData = new URLSearchParams();
formData.append('login_name', FSTREAM_LOGIN_NAME);
formData.append('login_password', FSTREAM_LOGIN_PASSWORD);
formData.append('login', 'submit');
const response = await axios({
method: 'post',
url: FSTREAM_BASE_URL,
data: formData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': FSTREAM_BASE_URL,
'Referer': FSTREAM_BASE_URL,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
timeout: 15000,
maxRedirects: 5,
validateStatus: () => true
});
const setCookie = response.headers && (response.headers['set-cookie'] || response.headers['Set-Cookie']);
const phpsessid = extractCookieValue(setCookie, 'PHPSESSID');
const dleUserId = extractCookieValue(setCookie, 'dle_user_id');
const dlePassword = extractCookieValue(setCookie, 'dle_password');
const dleNewpm = extractCookieValue(setCookie, 'dle_newpm');
fstreamCookies['PHPSESSID'] = phpsessid || '';
if (dleUserId) fstreamCookies['dle_user_id'] = dleUserId;
if (dlePassword) fstreamCookies['dle_password'] = dlePassword;
if (dleNewpm) fstreamCookies['dle_newpm'] = dleNewpm;
if (!hasFStreamAuthCookies()) {
console.warn('[FSTREAM LOGIN] Cookies d\'auth non presents dans set-cookie, poursuite sans authentification');
return false;
}
// Connexion reussie, cookies recuperes
// Changer le skin vers VFV25 via POST
try {
const skinCookieHeader = Object.entries(fstreamCookies)
.filter(([, v]) => v !== '' && v != null)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
const skinFormData = new URLSearchParams();
skinFormData.append('skin_name', 'VFV25');
skinFormData.append('action_skin_change', 'yes');
await axios({
method: 'post',
url: FSTREAM_BASE_URL,
data: skinFormData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': FSTREAM_BASE_URL,
'Referer': FSTREAM_BASE_URL + '/',
'Cookie': skinCookieHeader,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
timeout: 15000,
maxRedirects: 5,
validateStatus: () => true
});
fstreamCookies['dle_skin'] = 'VFV25';
// Skin change vers VFV25
} catch (skinError) {
console.warn('[FSTREAM LOGIN] Erreur changement de skin:', skinError.message);
}
return true;
} finally {
fstreamLoginPromise = null;
}
})();
return fstreamLoginPromise;
}
async function ensureFStreamSession() {
if (!canUseFStreamAuth()) return false;
if (!hasUsableFStreamSession() || fstreamRequestCounter >= MAX_REQUESTS_PER_SESSION) {
try {
const loggedIn = await loginToFStream();
if (loggedIn) {
fstreamRequestCounter = 0;
}
return loggedIn;
} catch (error) {
console.warn('[FSTREAM LOGIN] Auth optionnelle indisponible, poursuite sans cookies:', error.message);
return false;
}
}
return true;
}
// Inject fstream session management into axiosHelpers so axiosFStreamRequest works
axiosHelpers.configure({
ensureFStreamSession,
fstreamCookies,
getFstreamRequestCounter: () => fstreamRequestCounter,
incrementFstreamRequestCounter: () => { fstreamRequestCounter++; }
});
// === TMDB Helper (cached via Redis) ===
async function getFStreamTMDBDetails(id, type) {
try {
const [frData, enData] = await Promise.all([
fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, id, type, 'fr-FR'),
fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, id, type, 'en-US')
]);
if (!frData) return null;
return {
id: frData.id,
title: type === 'movie' ? frData.title : frData.name,
original_title: type === 'movie' ? frData.original_title : frData.original_name,
name_no_lang: enData ? (type === 'movie' ? enData.title : enData.name) : null,
release_date: type === 'movie' ? frData.release_date : frData.first_air_date,
overview: frData.overview
};
} catch (error) {
console.error(`Erreur lors de la recuperation des details TMDB pour ${id} (${type}):`, error);
return null;
}
}
// === Title Similarity (local copy for FStream matching) ===
function calculateTitleSimilarity(title1, title2) {
if (!title1 || !title2) return 0;
const t1 = title1.toLowerCase();
const t2 = title2.toLowerCase();
const normalize = (str) => str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
const norm1 = normalize(t1);
const norm2 = normalize(t2);
if (norm1 === norm2) return 1.0;
// Token overlap approach
const tokens1 = new Set(norm1.split(' ').filter(Boolean));
const tokens2 = new Set(norm2.split(' ').filter(Boolean));
let intersection = 0;
for (const t of tokens1) { if (tokens2.has(t)) intersection++; }
const union = new Set([...tokens1, ...tokens2]).size;
return union === 0 ? 0 : intersection / union;
}
// === Search Functions ===
async function searchFStream(query, page = 1) {
try {
const formData = new URLSearchParams();
formData.append('query', query);
formData.append('page', page.toString());
const response = await axiosFStreamRequest({
method: 'post',
url: FSTREAM_SEARCH_URL,
data: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.status !== 200) throw new Error(`Erreur HTTP: ${response.status}`);
return response.data;
} catch (error) {
if (error.response) {
const status = error.response.status;
if (status === 429 || status === 403 || status === 503 || status === 502) throw error;
}
console.error(`Erreur lors de la recherche FStream: ${error.message}`);
throw error;
}
}
async function searchFStreamDirect(query, page = 1) {
try {
const formData = new URLSearchParams();
formData.append('query', query);
formData.append('page', page.toString());
const response = await axiosFStreamRequest({
method: 'post',
url: FSTREAM_SEARCH_URL,
data: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.status !== 200) throw new Error(`Erreur HTTP: ${response.status}`);
return response.data;
} catch (error) {
if (error.response) {
const status = error.response.status;
if (status === 429 || status === 403 || status === 503 || status === 502) throw error;
}
throw error;
}
}
// Fallback "fuzzy" : search.php avec titre nu + filtre permissif (pas de filtre annee).
// Remplace l'ancien get_seasons.php (mort cote upstream depuis ~2026-05) qui prenait
// un TMDB id ; on simule le meme role en listant toutes les saisons matchant le titre.
async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) {
if (!serieTitle) return [];
try {
const formData = new URLSearchParams();
formData.append('query', serieTitle);
const response = await axiosFStreamRequest({
method: 'post',
url: FSTREAM_SEARCH_URL,
data: formData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
timeout: 6000
});
const html = typeof response.data === 'string' ? response.data : '';
if (!html.trim()) return [];
const $ = cheerio.load(html);
const normalize = (s) => (s || '').toLowerCase().normalize('NFD')
.replace(/[̀-ͯ]/g, '').replace(/[''`´]/g, '')
.replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
const normalizedSerie = normalize(serieTitle);
if (!normalizedSerie) return [];
const results = [];
$('div.search-item').each((_, element) => {
const $el = $(element);
const rawTitle = $el.find('.search-title').text().trim();
const onclickAttr = $el.attr('onclick') || '';
const linkMatch = onclickAttr.match(/location\.href=['"]([^'"]+)['"]/);
const link = linkMatch ? linkMatch[1] : null;
if (!rawTitle || !link) return;
const seasonMatch = rawTitle.match(/Saison\s+(\d+)/i);
if (!seasonMatch) return;
const seasonNumber = parseInt(seasonMatch[1], 10);
if (Number.isNaN(seasonNumber)) return;
const baseTitle = rawTitle.replace(/\s*-\s*Saison\s+\d+.*$/i, '').replace(/\s*\(\d{4}\)\s*$/, '').trim();
const normalizedBase = normalize(baseTitle);
if (!normalizedBase) return;
if (!normalizedBase.includes(normalizedSerie) && !normalizedSerie.includes(normalizedBase)) return;
const titleYearMatch = rawTitle.match(/\((\d{4})\)/);
const urlYearMatch = link.match(/-(\d{4})\.html/);
const year = titleYearMatch ? parseInt(titleYearMatch[1], 10)
: urlYearMatch ? parseInt(urlYearMatch[1], 10) : null;
const cleanTitle = baseTitle ? `${baseTitle} - Saison ${seasonNumber}` : rawTitle;
const normalizedLink = link.startsWith('http')
? link
: `${FSTREAM_BASE_URL}${link.startsWith('/') ? '' : '/'}${link}`;
results.push({
title: cleanTitle,
originalTitle: rawTitle,
link: normalizedLink,
seasonNumber,
year
});
});
return results;
} catch (error) {
console.error(`[FSTREAM TV] Erreur lors de la recuperation des saisons pour ${tmdbId}: ${error.message}`);
return [];
}
}
// get_seasons.php (ajax related-seasons de DLE) est toujours vivant cote upstream,
// contrairement a l'hypothese de fetchFStreamSeasonSearchResults. serie_tag = s-<tmdbId>
// (l'id TMDB brut), news_id = page id d'une saison deja trouvee via search. Retourne
// TOUTES les saisons de la serie (id, title, full_url), y compris celles que search.php
// ne liste pas (ex: Mayans MC -> search ne voit que S4/S5, get_seasons rend S1/S2/S3/S5).
// Fallback quand la saison demandee est absente des resultats search mais existe cote FStream.
async function fetchFStreamSeasonsAjax(tmdbId, newsId, baseUrl) {
if (!tmdbId || !newsId) return [];
const apiUrl = `${baseUrl}/engine/ajax/get_seasons.php?serie_tag=s-${tmdbId}&news_id=${newsId}`;
const proxies = getShuffledAllProxies();
const maxAttempts = Math.min(proxies.length, 3);
for (let i = 0; i < maxAttempts; i++) {
const entry = proxies[i];
const agents = getAgentForProxy(entry);
try {
const response = await axios({
method: 'get',
url: apiUrl,
timeout: 8000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Referer': `${baseUrl}/`,
'Accept': 'application/json, text/javascript, */*',
'X-Requested-With': 'XMLHttpRequest',
'Cookie': buildFStreamCookieHeader()
},
...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {})
});
if (response.status === 429) continue;
if (response.status !== 200 || !response.data) return [];
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
if (!Array.isArray(data)) return [];
return data.map(item => {
const seasonMatch = (item.title || '').match(/Saison\s+(\d+)/i);
const seasonNumber = seasonMatch ? parseInt(seasonMatch[1], 10) : null;
const rawUrl = item.full_url || '';
const link = !rawUrl ? null
: rawUrl.startsWith('http') ? rawUrl
: `${baseUrl}/${rawUrl.replace(/^\//, '')}`;
return { title: item.title || `Saison ${seasonNumber}`, originalTitle: item.title || '', link, seasonNumber, year: null };
}).filter(r => r.link && r.seasonNumber);
} catch (error) {
if (error.response?.status === 429) continue;
console.error(`[FStream] Erreur get_seasons.php (proxy ${entry.type} #${i}): ${error.message}`);
continue;
}
}
return [];
}
// === Scraping Functions ===
async function scrapeFStreamRecentMovies() {
try {
const response = await axiosFStreamRequest({
method: 'get',
url: `${FSTREAM_BASE_URL}/films/`,
timeout: 10000
});
if (response.status !== 200) throw new Error(`Erreur HTTP: ${response.status}`);
if (!response.data || typeof response.data !== 'string') throw new Error('La reponse n\'est pas du HTML valide');
const $ = cheerio.load(response.data);
const movies = [];
const dleContent = $('#dle-content');
let filmElements;
if (dleContent.length > 0) {
filmElements = dleContent.find('.short.film');
if (filmElements.length === 0) filmElements = dleContent.find('div.short.film');
if (filmElements.length === 0) filmElements = dleContent.find('div[class*="short"][class*="film"]');
if (filmElements.length === 0) {
const shortInDle = dleContent.find('.short');
if (shortInDle.length > 0) filmElements = shortInDle;
}
} else {
filmElements = $('.short.film');
if (filmElements.length === 0) filmElements = $('div.short.film');
if (filmElements.length === 0) filmElements = $('div[class*="short"][class*="film"]');
if (filmElements.length === 0) {
const allShorts = $('.short');
if (allShorts.length > 0) filmElements = allShorts;
}
}
filmElements.each((index, element) => {
try {
const $el = $(element);
const titleElement = $el.find('.short-title');
if (titleElement.length === 0) return;
const title = titleElement.text().trim();
if (!title) return;
const linkElement = $el.find('a.short-poster');
if (linkElement.length === 0) return;
const href = linkElement.attr('href');
if (!href) return;
const fullLink = href.startsWith('http') ? href
: href.startsWith('/') ? `${FSTREAM_BASE_URL}${href}`
: `${FSTREAM_BASE_URL}/${href}`;
let movieId = null;
const idMatch = href.match(/(\d+)/);
if (idMatch) movieId = idMatch[1];
let trailerId = null;
if (movieId) {
let trailerElement = $el.find(`span#trailer-${movieId}`);
if (trailerElement.length === 0) trailerElement = $(`span#trailer-${movieId}`);
if (trailerElement.length > 0) trailerId = trailerElement.text().trim();
}
let description = null;
if (movieId) {
let descElement = $el.find(`span#desc-${movieId}`);
if (descElement.length === 0) descElement = $(`span#desc-${movieId}`);
if (descElement.length > 0) description = descElement.text().trim();
}
const quality = $el.find('.film-quality a').text().trim() || null;
const version = $el.find('.film-version a').text().trim() || null;
const imgElement = $el.find('img');
let posterUrl = null;
if (imgElement.length > 0) {
posterUrl = imgElement.attr('src');
if (posterUrl && !posterUrl.startsWith('http')) {
posterUrl = posterUrl.startsWith('/')
? `${FSTREAM_BASE_URL}${posterUrl}`
: `${FSTREAM_BASE_URL}/${posterUrl}`;
}
}
const ratingElement = $el.find('.vote-score');
let rating = null;
if (ratingElement.length > 0) {
const ratingText = ratingElement.text().trim();
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
if (ratingMatch) rating = parseFloat(ratingMatch[1]);
}
movies.push({
title, link: fullLink, id: movieId, trailerId, description,
quality, version, posterUrl, rating, source: 'fstream_recent'
});
} catch (error) {
console.error(`[FSTREAM RECENT] Erreur lors du parsing d'un film: ${error.message}`);
}
});
return movies;
} catch (error) {
console.error(`[FSTREAM RECENT] Erreur lors du scraping: ${error.message}`);
return [];
}
}
async function scrapeFStreamRecentSeries() {
try {
const response = await axiosFStreamRequest({
method: 'get',
url: `${FSTREAM_BASE_URL}/s-tv/`,
timeout: 10000
});
if (response.status !== 200) throw new Error(`Erreur HTTP: ${response.status}`);
const $ = cheerio.load(response.data);
const series = [];
const seriesElements = $('#dle-content .short.serie');
seriesElements.each((index, element) => {
try {
const $el = $(element);
const titleElement = $el.find('.short-title');
if (titleElement.length === 0) return;
const title = titleElement.text().trim();
if (!title) return;
const linkElement = $el.find('a.short-poster');
if (linkElement.length === 0) return;
const href = linkElement.attr('href');
if (!href) return;
const fullLink = href.startsWith('http') ? href : `${FSTREAM_BASE_URL}${href}`;
let seriesId = null;
const idMatch = href.match(/(\d+)/);
if (idMatch) seriesId = idMatch[1];
series.push({
title, link: fullLink, id: seriesId, tmdbId: null, source: 'fstream_recent_series'
});
} catch (error) {
console.error(`[FSTREAM RECENT SERIES] Erreur lors du parsing d'une serie: ${error.message}`);
}
});
return series;
} catch (error) {
console.error(`[FSTREAM RECENT SERIES] Erreur lors du scraping: ${error.message}`);
return [];
}
}
// === Finding Functions ===
async function findMovieInRecentFStream(tmdbTitle, tmdbYear) {
try {
const recentMovies = await scrapeFStreamRecentMovies();
if (recentMovies.length === 0) return null;
const extractYear = (title) => {
const yearMatch = title.match(/\((\d{4})\)/);
return yearMatch ? yearMatch[1] : null;
};
const removeYear = (title) => title.replace(/\s*\((\d{4})\)\s*$/, '').trim();
const normalizeTitle = (str) => {
if (!str) return '';
let cleaned = removeYear(str);
return cleaned.toLowerCase().normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
let yearToMatch = tmdbYear;
if (!yearToMatch) yearToMatch = extractYear(tmdbTitle);
const tmdbTitleWithoutYear = removeYear(tmdbTitle);
const normalizedTmdbTitle = normalizeTitle(tmdbTitleWithoutYear);
let bestMatch = null;
let bestSimilarity = 0;
for (const movie of recentMovies) {
const normalizedMovieTitle = normalizeTitle(movie.title);
if (normalizedMovieTitle === normalizedTmdbTitle) return movie;
const similarity = calculateTitleSimilarity(normalizedTmdbTitle, normalizedMovieTitle);
if (similarity > bestSimilarity) {
bestSimilarity = similarity;
bestMatch = movie;
}
}
if (bestMatch && bestSimilarity >= 0.7) return bestMatch;
if (bestMatch && bestSimilarity >= 0.6 && normalizedTmdbTitle.length > 3) {
const tmdbWords = normalizedTmdbTitle.split(/\s+/).filter(w => w.length > 2);
const movieWords = normalizeTitle(bestMatch.title).split(/\s+/).filter(w => w.length > 2);
const allWordsMatch = tmdbWords.length > 0 && (
tmdbWords.every(word => movieWords.some(mw => mw.includes(word) || word.includes(mw))) ||
movieWords.every(word => tmdbWords.some(tw => tw.includes(word) || word.includes(tw)))
);
if (allWordsMatch) {
// Verifier que les tokens bruts (sans filtre de longueur) ont un recouvrement suffisant
const rawTmdbTokens = normalizedTmdbTitle.split(/\s+/).filter(Boolean);
const rawMovieTokens = new Set(normalizeTitle(bestMatch.title).split(/\s+/).filter(Boolean));
const rawCoverage = rawTmdbTokens.length > 0 ? rawTmdbTokens.filter(t => rawMovieTokens.has(t)).length / rawTmdbTokens.length : 0;
if (rawCoverage >= 0.75) return bestMatch;
}
}
return null;
} catch (error) {
console.error(`[FSTREAM RECENT] Erreur lors de la recherche: ${error.message}`);
return null;
}
}
async function findSeriesInRecentFStream(tmdbTitle, tmdbYear) {
try {
const recentSeries = await scrapeFStreamRecentSeries();
if (recentSeries.length === 0) return null;
const normalizeTitle = (str) => (str || '')
.toLowerCase().normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
const normalizedTmdbTitle = normalizeTitle(tmdbTitle);
for (const series of recentSeries) {
const normalizedSeriesTitle = normalizeTitle(series.title);
if (normalizedSeriesTitle === normalizedTmdbTitle) return series;
const similarity = calculateTitleSimilarity(normalizedTmdbTitle, normalizedSeriesTitle);
if (similarity > 0.8) return series;
}
return null;
} catch (error) {
console.error(`[FSTREAM RECENT SERIES] Erreur lors de la recherche: ${error.message}`);
return null;
}
}
// === API-based extraction helpers ===
function extractPageIdFromUrl(url) {
if (!url) return null;
const match = url.match(/\/(\d+)-[^/]+\.html/);
return match ? match[1] : null;
}
function extractBaseUrlFromLink(url) {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}`;
} catch {
return FSTREAM_BASE_URL;
}
}
// Construit une liste melangee de proxies SOCKS5 + Darkino HTTP
function getShuffledAllProxies() {
const all = [];
// Ajouter les SOCKS5
if (PROXIES && PROXIES.length > 0) {
PROXIES.forEach(p => all.push({ proxy: p, type: 'socks5' }));
}
// Ajouter les Darkino HTTP
if (DARKINO_PROXIES && DARKINO_PROXIES.length > 0) {
DARKINO_PROXIES.forEach(p => all.push({ proxy: p, type: 'darkino' }));
}
// Melanger
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return all;
}
function getAgentForProxy(entry) {
if (entry.type === 'socks5') {
const agent = getProxyAgent(entry.proxy);
return { httpAgent: agent, httpsAgent: agent };
}
// darkino HTTP proxy
return getDarkinoHttpProxyAgent(entry.proxy);
}
// Parse le payload episodes FStream (shape commune {vf,vostfr,vo,info}) en map normalisee.
// Partage entre la source statique (<base>/static/series) et l'API dynamique (episodes_p.php).
function parseEpisodesPayload(data) {
if (!data || typeof data !== 'object') return null;
const episodes = {};
const langMap = { vf: 'VF', vostfr: 'VOSTFR', vo: 'VOENG' };
for (const [langKey, langLabel] of Object.entries(langMap)) {
const langData = data[langKey];
if (!langData || typeof langData !== 'object') continue;
for (const [epNum, providers] of Object.entries(langData)) {
const epNumber = parseInt(epNum);
if (isNaN(epNumber) || epNumber === 0) continue;
if (!episodes[epNumber]) {
episodes[epNumber] = {
number: epNumber,
title: `Episode ${epNumber}`,
languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] }
};
}
for (const [provider, url] of Object.entries(providers)) {
if (!url || typeof url !== 'string' || !url.startsWith('http')) continue;
let displayName = provider;
if (provider === 'premium') displayName = 'Premium';
else if (provider === 'vidzy') displayName = 'Vidzy';
else if (provider === 'uqload') displayName = 'Uqload';
else if (provider === 'netu') displayName = 'Netu';
else if (provider === 'voe') displayName = 'Voe';
else displayName = provider.charAt(0).toUpperCase() + provider.slice(1);
const exists = episodes[epNumber].languages[langLabel].some(p => p.url === url);
if (!exists) {
episodes[epNumber].languages[langLabel].push({ url, type: 'embed', quality: 'HD', player: displayName });
}
}
}
}
// Enrichir avec les infos (titres, synopsis) si disponibles
if (data.info && typeof data.info === 'object') {
for (const [epNum, info] of Object.entries(data.info)) {
const epNumber = parseInt(epNum);
if (episodes[epNumber] && info.title) {
episodes[epNumber].title = info.title;
}
}
}
if (Object.keys(episodes).length > 0) return episodes;
return null;
}
// Source statique prioritaire: <base>/static/series/<id>.js (JSON fige, frais, inclut premium).
// Meme domaine que le lien de recherche (base url), pas un host distinct.
async function fetchEpisodesFromStaticJs(pageUrl) {
const pageId = extractPageIdFromUrl(pageUrl);
if (!pageId) return null;
const baseUrl = extractBaseUrlFromLink(pageUrl);
const apiUrl = `${baseUrl}/static/series/${pageId}.js?v=${Date.now()}`;
const proxies = getShuffledAllProxies();
const maxAttempts = Math.min(proxies.length, 3);
let lastError = null;
for (let i = 0; i < maxAttempts; i++) {
const entry = proxies[i];
const agents = getAgentForProxy(entry);
try {
const response = await axios({
method: 'get',
url: apiUrl,
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Referer': pageUrl,
'Accept': 'application/json, text/javascript, */*',
'Cookie': buildFStreamCookieHeader()
},
...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {})
});
if (response.status === 429) {
console.log(`[FStream] static series.js: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
if (response.status !== 200 || !response.data) return null;
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
return parseEpisodesPayload(data);
} catch (error) {
lastError = error;
if (error.response?.status === 429) {
console.log(`[FStream] static series.js: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
console.error(`[FStream] Erreur static series.js (proxy ${entry.type} #${i}): ${error.message}`);
continue;
}
}
if (lastError) console.error(`[FStream] static series.js: tous les proxies ont echoue. Derniere erreur: ${lastError.message}`);
return null;
}
async function fetchEpisodesFromApi(pageUrl) {
const pageId = extractPageIdFromUrl(pageUrl);
if (!pageId) return null;
const baseUrl = extractBaseUrlFromLink(pageUrl);
const apiUrl = `${baseUrl}/engine/ajax/episodes_p.php?id=${pageId}`;
const proxies = getShuffledAllProxies();
const maxAttempts = Math.min(proxies.length, 3);
let lastError = null;
for (let i = 0; i < maxAttempts; i++) {
const entry = proxies[i];
const agents = getAgentForProxy(entry);
try {
const response = await axios({
method: 'get',
url: apiUrl,
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Referer': pageUrl,
'Accept': 'application/json, text/plain, */*',
'Cookie': buildFStreamCookieHeader()
},
...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {})
});
if (response.status === 429) {
console.log(`[FStream] API episodes_p: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
if (response.status !== 200 || !response.data) return null;
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
return parseEpisodesPayload(data);
} catch (error) {
lastError = error;
if (error.response?.status === 429) {
console.log(`[FStream] API episodes_p: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
console.error(`[FStream] Erreur API episodes_p (proxy ${entry.type} #${i}): ${error.message}`);
continue;
}
}
if (lastError) console.error(`[FStream] API episodes_p: tous les proxies ont echoue. Derniere erreur: ${lastError.message}`);
return null;
}
async function fetchMoviePlayersFromApi(pageUrl) {
const pageId = extractPageIdFromUrl(pageUrl);
if (!pageId) return null;
const baseUrl = extractBaseUrlFromLink(pageUrl);
const apiUrl = `${baseUrl}/engine/ajax/film_api.php?id=${pageId}`;
const proxies = getShuffledAllProxies();
const maxAttempts = Math.min(proxies.length, 3);
let lastError = null;
for (let i = 0; i < maxAttempts; i++) {
const entry = proxies[i];
const agents = getAgentForProxy(entry);
try {
const response = await axios({
method: 'get',
url: apiUrl,
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Referer': pageUrl,
'Accept': 'application/json, text/plain, */*',
'Cookie': buildFStreamCookieHeader()
},
...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {})
});
if (response.status === 429) {
console.log(`[FStream] API film_api: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
if (response.status !== 200 || !response.data) return null;
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
if (!data || !data.players || typeof data.players !== 'object') return null;
const players = [];
const versionMap = { 'default': 'Default', 'vf': 'VF', 'vfq': 'VFQ', 'vff': 'VFF', 'vostfr': 'VOSTFR' };
for (const [provider, versions] of Object.entries(data.players)) {
if (!versions || typeof versions !== 'object') continue;
for (const [versionKey, url] of Object.entries(versions)) {
if (!url || typeof url !== 'string') continue;
let finalUrl = url;
if (provider === 'netu' && !url.startsWith('http')) {
finalUrl = `https://www.fembed.com/v/${url}`;
}
if (!finalUrl.startsWith('http')) continue;
let displayName = provider;
if (provider === 'premium') displayName = 'Premium';
else if (provider === 'vidzy') displayName = 'Vidzy';
else if (provider === 'uqload') displayName = 'Uqload';
else if (provider === 'netu') displayName = 'Netu';
else if (provider === 'voe') displayName = 'Voe';
else if (provider === 'dood') displayName = 'Dood';
else if (provider === 'filmoon') displayName = 'Filmoon';
else displayName = provider.charAt(0).toUpperCase() + provider.slice(1);
const version = versionMap[versionKey] || versionKey.toUpperCase();
players.push({ url: finalUrl, type: 'embed', quality: 'HD', player: displayName, version });
}
}
if (players.length > 0) {
return players;
}
return null;
} catch (error) {
lastError = error;
if (error.response?.status === 429) {
console.log(`[FStream] API film_api: 429 avec proxy ${entry.type} #${i}, retry...`);
continue;
}
console.error(`[FStream] Erreur API film_api (proxy ${entry.type} #${i}): ${error.message}`);
continue;
}
}
if (lastError) console.error(`[FStream] API film_api: tous les proxies ont echoue. Derniere erreur: ${lastError.message}`);
return null;
}
// === High-level wrappers: API-first, HTML fallback ===
// Bypass le fetch HTML (souvent 429) en appelant l'API directe d'abord
async function getSeriesPlayersForUrl(pageUrl) {
// 1. Source statique <base>/static/series/<id>.js (prioritaire: fraiche, premium, inclut tous les eps)
let apiEpisodes = await fetchEpisodesFromStaticJs(pageUrl);
// 2. Fallback: API dynamique episodes_p.php (si la statique 404/echoue ou host a tourne)
if (!apiEpisodes || Object.keys(apiEpisodes).length === 0) {
apiEpisodes = await fetchEpisodesFromApi(pageUrl);
}
if (apiEpisodes && Object.keys(apiEpisodes).length > 0) {
const organizedPlayers = { VF: [], VOSTFR: [], VOENG: [], Default: [] };
let totalPlayers = 0;
Object.values(apiEpisodes).forEach(episode => {
Object.entries(episode.languages).forEach(([lang, players]) => {
if (players.length > 0) {
if (organizedPlayers[lang]) organizedPlayers[lang].push(...players);
totalPlayers += players.length;
}
});
});
return { organized: organizedPlayers, episodes: apiEpisodes, total: totalPlayers, fstreamReleaseDate: null, fromApi: true };
}
// 3. Fallback: fetch HTML (scrape direct de la page french-stream.one)
console.log(`[FStream] getSeriesPlayersForUrl: sources JSON echouees, fallback HTML pour ${pageUrl}`);
const contentResponse = await axiosFStreamRequest({ method: 'get', url: pageUrl });
if (contentResponse.status !== 200) return { organized: { VF: [], VOSTFR: [], VOENG: [], Default: [] }, episodes: {}, total: 0, fstreamReleaseDate: null };
return await extractFStreamPlayers(contentResponse.data, true, pageUrl);
}
async function getMoviePlayersForUrl(pageUrl) {
// 1. API directe
const apiPlayers = await fetchMoviePlayersFromApi(pageUrl);
if (apiPlayers && apiPlayers.length > 0) {
const uniquePlayers = [];
const seenUrls = new Set();
apiPlayers.forEach(player => {
if (!seenUrls.has(player.url)) { seenUrls.add(player.url); uniquePlayers.push(player); }
});
const organized = { VFQ: [], VFF: [], VOSTFR: [], Default: [] };
uniquePlayers.forEach(player => {
const version = (player.version && organized[player.version]) ? player.version : 'Default';
organized[version].push({ url: player.url, type: player.type, quality: player.quality, player: player.player || 'Lecteur' });
});
return { organized, total: uniquePlayers.length, fromApi: true };
}
// 2. Fallback: fetch HTML
console.log(`[FStream] getMoviePlayersForUrl: API echouee, fallback HTML pour ${pageUrl}`);
const contentResponse = await axiosFStreamRequest({ method: 'get', url: pageUrl });
if (contentResponse.status !== 200) return { organized: { VFQ: [], VFF: [], VOSTFR: [], Default: [] }, total: 0 };
return await extractFStreamPlayers(contentResponse.data, false, pageUrl);
}
// === Player Extraction ===
async function extractFStreamPlayers(htmlContent, isSeries = false, pageUrl = null) {
try {
const $ = cheerio.load(htmlContent);
let players = [];
if (isSeries) {
return await extractFStreamSeriesPlayers(htmlContent, pageUrl);
} else {
// Methode API (la plus fiable)
if (pageUrl) {
const apiPlayers = await fetchMoviePlayersFromApi(pageUrl);
if (apiPlayers && apiPlayers.length > 0) {
players = apiPlayers;
}
}
// Pour les films - extraction via #film-data
const filmData = $('#film-data');
if (players.length === 0 && filmData.length > 0) {
const providers = ['premium', 'vidzy', 'uqload', 'dood', 'voe', 'filmoon', 'netu'];
providers.forEach(provider => {
const versions = { 'vostfr': 'VOSTFR', 'vff': 'VFF', 'vfq': 'VFQ', '': 'Default' };
Object.entries(versions).forEach(([suffix, label]) => {
const attr = `data-${provider}${suffix}`;
const value = filmData.attr(attr);
if (value && value.trim() !== '') {
let url = value;
if (provider === 'netu' && !value.startsWith('http')) {
url = `https://www.fembed.com/v/${value}`;
}
if (url.startsWith('http')) {
players.push({ url, type: 'embed', quality: 'HD', player: provider, version: label });
}
}
});
});
}
// Fallback: script parsing
if (players.length === 0) {
const scriptContent = $('script').text();
const playerUrlsMatch = scriptContent.match(/var\s+playerUrls\s*=\s*({[\s\S]*?});/);
if (playerUrlsMatch && playerUrlsMatch[1]) {
try {
const playerUrlsStr = playerUrlsMatch[1];
const playerPattern = /"([^"]+)":\s*{([^}]+)}/g;
let playerMatch;
while ((playerMatch = playerPattern.exec(playerUrlsStr)) !== null) {
const playerName = playerMatch[1];
const versionsStr = playerMatch[2];
const versionPattern = /"([^"]+)":\s*"([^"]*)"/g;
let versionMatch;
while ((versionMatch = versionPattern.exec(versionsStr)) !== null) {
const version = versionMatch[1];
const url = versionMatch[2];
if (url && url.trim() !== '') {
players.push({ url, type: 'embed', quality: 'HD', player: playerName, version });
}
}
}
if (players.length === 0) {
const urlPattern = /"([^"]+)":\s*"([^"]+)"/g;
let match;
while ((match = urlPattern.exec(playerUrlsStr)) !== null) {
const key = match[1];
const url = match[2];
if (url && url.includes('http') && !key.includes('Default') && !key.includes('VFQ') && !key.includes('VFF') && !key.includes('VOSTFR')) {
players.push({ url, type: 'embed', quality: 'HD', player: key });
}
}
}
} catch (parseError) {
console.error('Erreur lors du parsing de playerUrls:', parseError.message);
}
}
if (players.length === 0) {
const urlPattern = /https?:\/\/[^\s"']+/g;
const urls = scriptContent.match(urlPattern);
if (urls) {
urls.forEach(url => {
if (url.includes('embed') || url.includes('player')) {
players.push({ url, type: 'embed', quality: 'HD' });
}
});
}
}
}
// Fallback: iframes
if (players.length === 0) {
$('iframe[src], a[href*="embed"], a[href*="player"]').each((_, element) => {
const $el = $(element);
const src = $el.attr('src') || $el.attr('href');
if (src && !src.includes('episodes-suivant')) {
players.push({
url: src.startsWith('http') ? src : `${FSTREAM_BASE_URL}${src}`,
type: 'embed', quality: 'HD'
});
}
});
}
}
// Deduplicate and organize
const uniquePlayers = [];
const seenUrls = new Set();
players.forEach(player => {
if (!seenUrls.has(player.url)) {
seenUrls.add(player.url);
uniquePlayers.push(player);
}
});
const organizedPlayers = { VFQ: [], VFF: [], VOSTFR: [], Default: [] };
uniquePlayers.forEach(player => {
const version = (player.version && organizedPlayers[player.version]) ? player.version : 'Default';
organizedPlayers[version].push({
url: player.url, type: player.type, quality: player.quality, player: player.player || 'Lecteur'
});
});
return { organized: organizedPlayers, total: uniquePlayers.length };
} catch (error) {
console.error(`Erreur lors de l'extraction des lecteurs FStream: ${error.message}`);
return { organized: { VFQ: [], VFF: [], VOSTFR: [], Default: [] }, total: 0 };
}
}
async function extractFStreamSeriesPlayers(htmlContent, pageUrl = null) {
try {
const $ = cheerio.load(htmlContent);
let episodes = {};
// Extract release date (toujours depuis le HTML)
let fstreamReleaseDate = null;
const selectors = [
'html body div:nth-child(2) div div:nth-child(2) article div:nth-child(2) div:nth-child(1) div:nth-child(1) div:nth-child(1) span:nth-child(2)',
'span.release', 'div[class*="release"] span', 'article div span[class*="release"]',
'div[class*="info"] span[class*="release"]', 'div[class*="meta"] span[class*="release"]'
];
for (const selector of selectors) {
const releaseSpan = $(selector);
if (releaseSpan.length > 0) {
const releaseText = releaseSpan.text().trim();
const yearMatch = releaseText.match(/(\d{4})/);
if (yearMatch) { fstreamReleaseDate = yearMatch[1]; break; }
}
}
if (!fstreamReleaseDate) {
const allText = $.text();
const yearMatches = allText.match(/(\d{4})\s*-\s*/g);
if (yearMatches && yearMatches.length > 0) {
const firstYear = yearMatches[0].match(/(\d{4})/)[1];
if (parseInt(firstYear) >= 1900 && parseInt(firstYear) <= new Date().getFullYear() + 2) {
fstreamReleaseDate = firstYear;
}
}
}
// Methode API (la plus fiable)
let foundEpisodesData = false;
if (pageUrl) {
const apiEpisodes = await fetchEpisodesFromApi(pageUrl);
if (apiEpisodes) {
Object.assign(episodes, apiEpisodes);
foundEpisodesData = true;
}
}
// New method: HTML IDs
if (!foundEpisodesData) try {
const versionMap = {
'#episodes-vf-data': 'VF',
'#episodes-vostfr-data': 'VOSTFR',
'#episodes-vo-data': 'VOENG'
};
for (const [selector, langKey] of Object.entries(versionMap)) {
const container = $(selector);
if (container.length > 0) {
container.children('div').each((_, element) => {
const $el = $(element);
const epNumStr = $el.attr('data-ep');
if (!epNumStr) return;
const epNum = parseInt(epNumStr);
if (isNaN(epNum) || epNum === 0) return;
const attributes = {
'data-premium': 'FSvid', 'data-vidzy': 'Vidzy',
'data-uqload': 'Uqload', 'data-netu': 'Netu', 'data-voe': 'Voe'
};
const playersToAdd = [];
Object.entries(attributes).forEach(([attr, playerName]) => {
const url = $el.attr(attr);
if (url && url.startsWith('http')) {
playersToAdd.push({ url, type: 'embed', quality: 'HD', player: playerName });
}
});
if (playersToAdd.length > 0) {
if (!episodes[epNum]) {
episodes[epNum] = { number: epNum, title: `Episode ${epNum}`, languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } };
}
playersToAdd.forEach(player => {
const exists = episodes[epNum].languages[langKey].some(p => p.url === player.url);
if (!exists) episodes[epNum].languages[langKey].push(player);
});
}
});
if (Object.keys(episodes).length > 0) foundEpisodesData = true;
}
}
if (foundEpisodesData) {
// Donnees trouvees via les IDs HTML
}
} catch (newMethodError) {
console.error(`[FStream] Erreur nouvelle methode extraction: ${newMethodError.message}`);
}
// Legacy: episodesData from script
if (!foundEpisodesData) $('script').each((_, scriptEl) => {
const scriptContent = $(scriptEl).html() || '';
const episodesDataMatch = scriptContent.match(/var\s+episodesData\s*=\s*(\{[\s\S]*?\});(?:\s*var|\s*\n\s*var|\s*\n\s*\n)/);
if (episodesDataMatch && episodesDataMatch[1]) {
try {
let jsonStr = episodesDataMatch[1];
const vfMatch = jsonStr.match(/vf:\s*\{([\s\S]*?)\},\s*(?:vostfr|vo):/);
const vostfrMatch = jsonStr.match(/vostfr:\s*\{([\s\S]*?)\},\s*vo:/);
const voMatch = jsonStr.match(/vo:\s*\{([\s\S]*?)\}\s*\}/);
const parseLanguageEpisodes = (langContent, langKey) => {
if (!langContent) return;
const episodePattern = /(\d+):\s*\{([^}]+)\}/g;
let epMatch;
while ((epMatch = episodePattern.exec(langContent)) !== null) {
const epNum = parseInt(epMatch[1]);
const playersContent = epMatch[2];
if (!episodes[epNum]) {
episodes[epNum] = { number: epNum, title: `Episode ${epNum}`, languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } };
}
const playerPattern = /(\w+):"([^"]+)"/g;
let playerMatch;
while ((playerMatch = playerPattern.exec(playersContent)) !== null) {
const playerName = playerMatch[1];
const playerUrl = playerMatch[2];
if (!playerUrl || playerUrl.includes('&#91;') || playerUrl.includes('xfvalue_')) continue;
let displayName = playerName.toUpperCase();
if (playerName === 'vidzy') displayName = 'Vidzy';
else if (playerName === 'uqload') displayName = 'Uqload';
else if (playerName === 'netu') displayName = 'Netu';
else if (playerName === 'voe') displayName = 'Voe';
else if (playerName === 'premium') displayName = 'Premium';
const player = { url: playerUrl, type: 'embed', quality: 'HD', player: displayName };
const targetLang = langKey === 'vo' ? 'VOENG' : (langKey === 'vostfr' ? 'VOSTFR' : 'VF');
const exists = episodes[epNum].languages[targetLang].some(p => p.url === playerUrl);
if (!exists) episodes[epNum].languages[targetLang].push(player);
}
}
};
if (vfMatch && vfMatch[1]) parseLanguageEpisodes(vfMatch[1], 'vf');
if (vostfrMatch && vostfrMatch[1]) parseLanguageEpisodes(vostfrMatch[1], 'vostfr');
if (voMatch && voMatch[1]) parseLanguageEpisodes(voMatch[1], 'vo');
foundEpisodesData = Object.keys(episodes).length > 0;
if (foundEpisodesData) {
console.log(`[FStream] Parsed episodesData: ${Object.keys(episodes).length} episodes found`);
}
} catch (parseError) {
console.error('[FStream] Erreur parsing episodesData:', parseError.message);
}
}
});
// Legacy HTML method
if (!foundEpisodesData) {
console.log('[FStream] episodesData non trouve, utilisation de la methode legacy...');
$('div.fullsfeature').each((_, element) => {
const $episode = $(element);
const titleSpan = $episode.find('.selink span').first();
const episodeTitle = titleSpan.text().trim();
if (!episodeTitle || episodeTitle.trim() === '') return;
let language = 'Default';
if (episodeTitle.toLowerCase().includes('vostfr')) language = 'VOSTFR';
else if (episodeTitle.toLowerCase().includes('vf')) language = 'VFF';
const episodeMatch = episodeTitle.match(/episode\s+(\d+)/i);
const episodeNumber = episodeMatch ? parseInt(episodeMatch[1]) : 1;
const episodePlayers = [];
$episode.find('ul.btnss a.fsctab').each((_, linkElement) => {
const $link = $(linkElement);
const href = $link.attr('href');
const playerName = $link.text().trim();
if (href && href.startsWith('http') && !href.includes('episodes-suivant')) {
episodePlayers.push({ url: href, type: 'embed', quality: 'HD', player: playerName });
}
});
if (episodePlayers.length > 0) {
if (!episodes[episodeNumber]) {
episodes[episodeNumber] = { number: episodeNumber, title: episodeTitle, languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } };
}
const langKey = language === 'VOSTFR' ? 'VOSTFR' : 'VF';
episodes[episodeNumber].languages[langKey] = episodePlayers;
}
});
$('div.elink a.fstab').each((_, linkElement) => {
const $link = $(linkElement);
const href = $link.attr('href');
const linkText = $link.text().trim();
if (!href || !href.startsWith('http') || linkText.trim() === '') return;
const episodeMatch = linkText.match(/episode\s+(\d+)/i);
if (!episodeMatch) return;
const episodeNumber = parseInt(episodeMatch[1]);
let language = 'Default';
const lowerText = linkText.toLowerCase();
if (lowerText.includes('vosteng') || lowerText.includes('voeng')) language = 'VOENG';
else if (lowerText.includes('vostfr')) language = 'VOSTFR';
else if (lowerText.includes('vf')) language = 'VFF';
let playerName = 'Unknown';
if (href.includes('fsvid.lol')) playerName = 'FSvid';
else if (href.includes('vidzy.org')) playerName = 'Vidzy';
else if (href.includes('uqload')) playerName = 'Uqload';
else if (href.includes('voe.sx')) playerName = 'Voe';
const player = { url: href, type: 'embed', quality: 'HD', player: playerName };
if (!episodes[episodeNumber]) {
episodes[episodeNumber] = { number: episodeNumber, title: linkText, languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } };
}
const langKey = language === 'VOENG' ? 'VOENG' : (language === 'VOSTFR' ? 'VOSTFR' : 'VF');
const existingPlayer = episodes[episodeNumber].languages[langKey].find(p => p.url === href);
if (!existingPlayer) episodes[episodeNumber].languages[langKey].push(player);
});
}
// Organize by language
const organizedPlayers = { VF: [], VOSTFR: [], VOENG: [], Default: [] };
let totalPlayers = 0;
Object.values(episodes).forEach(episode => {
Object.entries(episode.languages).forEach(([lang, players]) => {
if (players.length > 0) {
organizedPlayers[lang].push(...players);
totalPlayers += players.length;
}
});
});
return { organized: organizedPlayers, episodes, total: totalPlayers, fstreamReleaseDate };
} catch (error) {
console.error(`Erreur lors de l'extraction des lecteurs serie FStream: ${error.message}`);
return { organized: { VF: [], VOSTFR: [], VOENG: [], Default: [] }, episodes: {}, total: 0, fstreamReleaseDate: null };
}
}
// === Filtering ===
function filterFStreamResults(results, originalTitle, releaseYear) {
try {
const $ = cheerio.load(results);
const filteredResults = [];
$('div.search-item').each((_, element) => {
const $el = $(element);
const titleElement = $el.find('.search-title');
const title = titleElement.text().trim();
const onclickAttr = $el.attr('onclick');
let link = null;
if (onclickAttr) {
const linkMatch = onclickAttr.match(/location\.href=['"]([^'\"]+)['"]/);
if (linkMatch) link = linkMatch[1];
}
if (!title || !link) return;
let cleanTitle = title;
let seasonNumber = null;
let year = null;
const yearMatch = title.match(/\((\d{4})\)/);
if (yearMatch) {
year = parseInt(yearMatch[1]);
cleanTitle = title.replace(/\s*\(\d{4}\)/, '').trim();
}
// Fallback: extraire l'annee de l'URL (ex: magnum-saison-5-1980.html)
if (!year && link) {
const urlYearMatch = link.match(/-(\d{4})\.html/);
if (urlYearMatch) year = parseInt(urlYearMatch[1]);
}
const seasonMatch = cleanTitle.match(/Saison\s+(\d+)/i);
if (seasonMatch) {
seasonNumber = parseInt(seasonMatch[1]);
cleanTitle = cleanTitle.replace(/\s*-\s*Saison\s+\d+$/i, '').trim();
} else {
const originalSeasonMatch = title.match(/Saison\s+(\d+)/i);
if (originalSeasonMatch) seasonNumber = parseInt(originalSeasonMatch[1]);
}
const normalize = (str) => (str || '').toLowerCase().normalize('NFD')
.replace(/[\u0300-\u036f]/g, '').replace(/[''`\u00b4]/g, '')
.replace(/[^a-z0-9\s-]/g, '').replace(/\s+-\s+/g, ' ')
.replace(/\s+/g, ' ').trim();
const normalizedOriginal = normalize(originalTitle);
const normalizedClean = normalize(cleanTitle);
const tokenize = (str) => (str || '').split(' ').filter(Boolean);
const originalTokens = new Set(tokenize(normalizedOriginal));
const cleanTokens = new Set(tokenize(normalizedClean));
let intersectionSize = 0;
for (const token of cleanTokens) { if (originalTokens.has(token)) intersectionSize += 1; }
const largerSetSize = Math.max(1, Math.max(originalTokens.size, cleanTokens.size));
const overlapScore = intersectionSize / largerSetSize;
const hasBothYears = Boolean(releaseYear && year);
const yearMatches = hasBothYears ? parseInt(releaseYear) === year : true;
const exactTitleMatch = normalizedClean === normalizedOriginal;
const threshold = 0.7;
const partialLengthOk = cleanTokens.size <= originalTokens.size + 1;
const subsetCoverage = cleanTokens.size / Math.max(1, originalTokens.size);
const resultIsSubsetOfOriginal = Array.from(cleanTokens).every(t => originalTokens.has(t)) && subsetCoverage >= 0.75;
const shouldInclude = yearMatches && (
exactTitleMatch || resultIsSubsetOfOriginal || (overlapScore >= threshold && partialLengthOk)
);
if (shouldInclude) {
filteredResults.push({
title: cleanTitle, originalTitle: title,
link: link.startsWith('http') ? link : `${FSTREAM_BASE_URL}${link}`,
seasonNumber, year
});
}
});
return filteredResults;
} catch (error) {
console.error(`Erreur lors du filtrage des resultats FStream: ${error.message}`);
return [];
}
}
// === Routes ===
// GET /movie/:id
router.get('/movie/:id', async (req, res) => {
const { id } = req.params;
const cacheKey = generateFStreamCacheKey('movie', id);
try {
const cachedData = await getFStreamFromCache(cacheKey);
if (cachedData) {
res.status(200).json(cachedData);
// Background update
setImmediate(async () => {
try {
await getOrCreateFStreamRequest(`${cacheKey}_background`, async () => {
const tmdbDetails = await getFStreamTMDBDetails(id, 'movie');
if (!tmdbDetails) return;
const searchQuery = tmdbDetails.title;
let searchResults = await searchFStreamDirect(searchQuery);
let filteredResults = filterFStreamResults(searchResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (filteredResults.length === 0) {
try {
const recentMovie = await findMovieInRecentFStream(tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (recentMovie) {
const players = await getMoviePlayersForUrl(recentMovie.link);
if (players.total > 0) {
const response = {
success: true, source: 'FStream', type: 'movie', tmdb: tmdbDetails,
search: { query: tmdbDetails.title, results: 1, bestMatch: { title: recentMovie.title, originalTitle: `${recentMovie.title} (${tmdbDetails.release_date?.split('-')[0]})`, link: recentMovie.link, seasonNumber: null, year: parseInt(tmdbDetails.release_date?.split('-')[0]) } },
players: players.organized, total: players.total,
metadata: { extractedAt: new Date().toISOString(), backgroundUpdate: true, foundInRecent: true }
};
await saveFStreamToCache(cacheKey, response);
return;
}
}
} catch (recentError) {
console.error(`[FSTREAM BACKGROUND] Erreur lors de la recherche dans les recents: ${recentError.message}`);
}
const errorResult = { success: false, error: 'Aucun resultat trouve', message: `Aucun contenu trouve pour "${tmdbDetails.title}" sur FStream`, search: { query: tmdbDetails.title, results: 0, checkedRecent: true }, timestamp: new Date().toISOString() };
await saveFStreamToCache(cacheKey, errorResult);
return;
}
let bestResult = null;
const tmdbYear = tmdbDetails.release_date?.split('-')[0];
if (tmdbYear) {
const yearMatches = filteredResults.filter(result => result.year === parseInt(tmdbYear));
if (yearMatches.length > 0) {
bestResult = yearMatches[0];
} else {
try {
const recentMovie = await findMovieInRecentFStream(tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (recentMovie) {
const players = await getMoviePlayersForUrl(recentMovie.link);
if (players.total > 0) {
const response = {
success: true, source: 'FStream', type: 'movie', tmdb: tmdbDetails,
search: { query: tmdbDetails.title, results: 1, bestMatch: { title: recentMovie.title, originalTitle: `${recentMovie.title} (${tmdbDetails.release_date?.split('-')[0]})`, link: recentMovie.link, seasonNumber: null, year: parseInt(tmdbDetails.release_date?.split('-')[0]) } },
players: players.organized, total: players.total,
metadata: { extractedAt: new Date().toISOString(), backgroundUpdate: true, foundInRecent: true }
};
await saveFStreamToCache(cacheKey, response);
return;
}
}
} catch (recentError) {
console.error(`[FSTREAM BACKGROUND] Erreur lors de la recherche dans les recents: ${recentError.message}`);
}
const errorResult = { success: false, error: 'Aucune correspondance d\'annee', message: `Aucun contenu trouve avec l'annee ${tmdbYear} pour "${tmdbDetails.title}" sur FStream`, search: { query: tmdbDetails.title, results: filteredResults.length, year: tmdbYear, checkedRecent: true }, timestamp: new Date().toISOString() };
await saveFStreamToCache(cacheKey, errorResult);
return;
}
} else {
bestResult = filteredResults[0];
}
if (!bestResult) {
const errorResult = { success: false, error: 'Aucun resultat valide', message: `Aucun resultat valide trouve pour "${tmdbDetails.title}" sur FStream`, search: { query: tmdbDetails.title, results: filteredResults.length }, timestamp: new Date().toISOString() };
await saveFStreamToCache(cacheKey, errorResult);
return;
}
const players = await getMoviePlayersForUrl(bestResult.link);
if (players.total === 0) return;
const response = {
success: true, source: 'FStream', type: 'movie', tmdb: tmdbDetails,
search: { query: searchQuery, results: filteredResults.length, bestMatch: bestResult },
players: players.organized, total: players.total,
metadata: { extractedAt: new Date().toISOString(), backgroundUpdate: true }
};
await saveFStreamToCache(cacheKey, response);
return response;
});
} catch (error) { /* background error, ignore */ }
});
return;
}
// No cache - make request with deduplication
const result = await getOrCreateFStreamRequest(cacheKey, async () => {
const tmdbDetails = await getFStreamTMDBDetails(id, 'movie');
if (!tmdbDetails) throw new Error('Contenu TMDB non trouve');
const searchQuery = tmdbDetails.title;
let searchResults = await searchFStreamDirect(searchQuery);
let filteredResults = filterFStreamResults(searchResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
let bestResult = null;
const tmdbYear = tmdbDetails.release_date?.split('-')[0];
if (tmdbYear) {
const yearMatches = filteredResults.filter(result => result.year === parseInt(tmdbYear));
if (yearMatches.length > 0) {
bestResult = yearMatches[0];
} else {
const recentMovie = await findMovieInRecentFStream(tmdbDetails.title, tmdbYear);
if (recentMovie) {
bestResult = { title: recentMovie.title, originalTitle: `${recentMovie.title} (${tmdbYear})`, link: recentMovie.link, seasonNumber: null, year: parseInt(tmdbYear) };
} else {
throw new Error(`Aucun resultat trouve avec l'annee ${tmdbYear} sur FStream`);
}
}
} else {
if (filteredResults.length > 0) bestResult = filteredResults[0];
}
if (filteredResults.length === 0 && !bestResult) {
const recentMovie = await findMovieInRecentFStream(tmdbDetails.title, tmdbYear);
if (recentMovie) {
bestResult = { title: recentMovie.title, originalTitle: `${recentMovie.title}${tmdbYear ? ` (${tmdbYear})` : ''}`, link: recentMovie.link, seasonNumber: null, year: tmdbYear ? parseInt(tmdbYear) : null };
} else {
throw new Error('Aucun resultat trouve sur FStream');
}
}
if (!bestResult) throw new Error('Aucun resultat valide trouve sur FStream');
const players = await getMoviePlayersForUrl(bestResult.link);
if (players.total === 0) {
return res.status(404).json({ error: 'Aucun lecteur video trouve', searchQuery, bestResult: bestResult.title });
}
return {
success: true, source: 'FStream', type: 'movie', tmdb: tmdbDetails,
search: { query: searchQuery, results: filteredResults.length, bestMatch: bestResult },
players: players.organized, total: players.total,
metadata: { extractedAt: new Date().toISOString() }
};
});
await saveFStreamToCache(cacheKey, result);
res.status(200).json(result);
} catch (error) {
console.error(`[FSTREAM MOVIE] Erreur: ${error.message}`);
const errorResult = { success: false, error: 'Erreur lors de la recuperation des sources FStream', message: error.message, timestamp: new Date().toISOString() };
await saveFStreamToCache(cacheKey, errorResult);
res.status(500).json(errorResult);
}
});
// GET /movie/:id/clear-cache
router.get('/movie/:id/clear-cache', async (req, res) => {
const { id } = req.params;
const cacheKey = generateFStreamCacheKey('movie', id);
const cacheFilePath = path.join(CACHE_DIR.FSTREAM, `${cacheKey}.json`);
try {
await fsp.unlink(cacheFilePath);
try { const redis = getRedis(); if (redis) await redis.del(`fstream:${cacheKey}`); } catch {}
console.log(`[FSTREAM Cache] Cache cleared for movie ${id}`);
res.json({ success: true, message: `Cache cleared for movie ${id}` });
} catch (error) {
if (error.code === 'ENOENT') {
try { const redis = getRedis(); if (redis) await redis.del(`fstream:${cacheKey}`); } catch {}
res.status(404).json({ error: `No cache found for movie ${id}` });
} else {
console.error(`[FSTREAM Cache] Error clearing cache for movie ${id}:`, error.message);
res.status(500).json({ error: 'Failed to clear cache' });
}
}
});
// GET /tv/:id/season/:season/clear-cache
router.get('/tv/:id/season/:season/clear-cache', async (req, res) => {
const { id, season } = req.params;
const { episode } = req.query;
const cacheKey = generateFStreamCacheKey('tv', id, season, episode || null);
const cacheFilePath = path.join(CACHE_DIR.FSTREAM, `${cacheKey}.json`);
try {
await fsp.unlink(cacheFilePath);
try { const redis = getRedis(); if (redis) await redis.del(`fstream:${cacheKey}`); } catch {}
console.log(`[FSTREAM Cache] Cache cleared for tv ${id} S${season}${episode ? ' E' + episode : ''}`);
res.json({ success: true, message: `Cache cleared for tv ${id} season ${season}${episode ? ' episode ' + episode : ''}` });
} catch (error) {
if (error.code === 'ENOENT') {
try { const redis = getRedis(); if (redis) await redis.del(`fstream:${cacheKey}`); } catch {}
res.status(404).json({ error: `No cache found for tv ${id} season ${season}` });
} else {
console.error(`[FSTREAM Cache] Error clearing cache for tv ${id}:`, error.message);
res.status(500).json({ error: 'Failed to clear cache' });
}
}
});
// GET /tv/:id/season/:season
router.get('/tv/:id/season/:season', async (req, res) => {
const { id, season } = req.params;
const { episode } = req.query;
const cacheKey = generateFStreamCacheKey('tv', id, season, episode);
try {
const cachedData = await getFStreamFromCache(cacheKey);
if (cachedData) {
res.status(200).json(cachedData);
// Background update
setImmediate(async () => {
try {
await getOrCreateFStreamRequest(`${cacheKey}_background`, async () => {
const tmdbDetails = await getFStreamTMDBDetails(id, 'tv');
if (!tmdbDetails) return;
let searchQuery;
if (id === '259909') { searchQuery = 'Dexter : Resurrection - Saison 1'; }
else { searchQuery = `${tmdbDetails.title} - Saison ${season}`; }
let searchResults = await searchFStreamDirect(searchQuery);
let filteredResults = filterFStreamResults(searchResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (filteredResults.length === 0) {
const directSeasonsEarly = await fetchFStreamSeasonSearchResults(id, tmdbDetails.title);
if (directSeasonsEarly.length > 0) filteredResults = directSeasonsEarly;
if (tmdbDetails.release_date) {
const year = tmdbDetails.release_date.split('-')[0];
const fallbackQuery = `${tmdbDetails.title} (${year}) - Saison ${season}`;
try {
let fallbackSearchResults = await searchFStreamDirect(fallbackQuery);
let fallbackFilteredResults = filterFStreamResults(fallbackSearchResults, tmdbDetails.title, year);
if (fallbackFilteredResults.length > 0) filteredResults = fallbackFilteredResults;
} catch (fallbackError) {
console.log(`[FSTREAM TV BACKGROUND] Erreur lors de la recherche de fallback avec annee: ${fallbackError.message}`);
}
}
if (filteredResults.length === 0 && tmdbDetails.name_no_lang && tmdbDetails.name_no_lang !== tmdbDetails.title) {
const noLangFallbackQuery = `${tmdbDetails.name_no_lang} - Saison ${season}`;
try {
let noLangSearchResults = await searchFStreamDirect(noLangFallbackQuery);
let noLangFilteredResults = filterFStreamResults(noLangSearchResults, tmdbDetails.name_no_lang, tmdbDetails.release_date?.split('-')[0]);
if (noLangFilteredResults.length > 0) {
filteredResults = noLangFilteredResults;
console.log(`[FSTREAM TV BACKGROUND] Fallback avec nom sans langue reussi: "${noLangFallbackQuery}"`);
}
} catch (noLangFallbackError) {
console.log(`[FSTREAM TV BACKGROUND] Erreur lors de la recherche de fallback avec nom sans langue: ${noLangFallbackError.message}`);
}
}
if (filteredResults.length === 0) {
const directSeasons = await fetchFStreamSeasonSearchResults(id, tmdbDetails.title);
if (directSeasons.length > 0) filteredResults = directSeasons;
if (filteredResults.length === 0) {
const releaseDate = tmdbDetails.release_date;
let shouldCheckRecent = false;
if (releaseDate) {
const releaseDateTime = new Date(releaseDate);
const now = new Date();
const diffInDays = (now - releaseDateTime) / (1000 * 60 * 60 * 24);
if (diffInDays <= 2) shouldCheckRecent = true;
}
if (shouldCheckRecent) {
try {
const recentSeries = await findSeriesInRecentFStream(tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (recentSeries) {
const players = await getSeriesPlayersForUrl(recentSeries.link);
if (players.total > 0) {
const response = {
success: true, source: 'FStream', type: 'tv', tmdb: tmdbDetails,
search: { query: `${tmdbDetails.title} - Saison ${season}`, results: 1, bestMatch: { title: recentSeries.title, originalTitle: `${recentSeries.title} (${tmdbDetails.release_date?.split('-')[0]})`, link: recentSeries.link, seasonNumber: parseInt(season), year: parseInt(tmdbDetails.release_date?.split('-')[0]) } },
episodes: players.episodes, total: players.total,
metadata: { season: parseInt(season), episode: episode ? parseInt(episode) : null, extractedAt: new Date().toISOString(), backgroundUpdate: true, foundInRecent: true }
};
await saveFStreamToCache(cacheKey, response);
return;
}
}
} catch (recentError) {
console.error(`[FSTREAM TV BACKGROUND] Erreur lors de la recherche dans les recents: ${recentError.message}`);
}
}
// Background update: ne pas écraser le cache avec une erreur
return;
}
}
}
const requestedSeason = parseInt(season);
const bgTmdbYear = tmdbDetails.release_date ? parseInt(tmdbDetails.release_date.split('-')[0]) : null;
// Trouver tous les resultats correspondant a la saison demandee
const seasonMatches = filteredResults.filter(result => result.seasonNumber && result.seasonNumber === requestedSeason);
let bestResult = null;
if (seasonMatches.length > 1 && bgTmdbYear) {
bestResult = seasonMatches.find(r => r.year === bgTmdbYear) || seasonMatches[0];
} else if (seasonMatches.length === 1) {
bestResult = seasonMatches[0];
}
if (!bestResult) {
const titleSeasonMatches = filteredResults.filter(result => {
if (result.originalTitle) {
const seasonInTitle = result.originalTitle.match(/Saison\s+(\d+)/i);
if (seasonInTitle) return parseInt(seasonInTitle[1]) === requestedSeason;
}
return false;
});
if (titleSeasonMatches.length > 1 && bgTmdbYear) {
bestResult = titleSeasonMatches.find(r => r.year === bgTmdbYear) || titleSeasonMatches[0];
} else if (titleSeasonMatches.length === 1) {
bestResult = titleSeasonMatches[0];
}
}
// Fallback: rechercher sans le numero de saison (FStream retourne parfois des resultats differents)
if (!bestResult) {
try {
const noNumQuery = `${tmdbDetails.title} - Saison`;
const noNumResults = await searchFStreamDirect(noNumQuery);
const noNumFiltered = filterFStreamResults(noNumResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
const noNumSeasonMatches = noNumFiltered.filter(r => r.seasonNumber === requestedSeason);
if (noNumSeasonMatches.length > 0) {
bestResult = noNumSeasonMatches.length > 1 && bgTmdbYear ? (noNumSeasonMatches.find(r => r.year === bgTmdbYear) || noNumSeasonMatches[0]) : noNumSeasonMatches[0];
filteredResults = noNumFiltered;
console.log(`[FSTREAM TV BACKGROUND] Fallback sans numero de saison reussi pour "${tmdbDetails.title}" saison ${requestedSeason}`);
}
} catch (e) {
console.log(`[FSTREAM TV BACKGROUND] Erreur fallback sans numero de saison: ${e.message}`);
}
}
// Fallback get_seasons.php: search ne liste pas toujours toutes les saisons.
if (!bestResult) {
const newsIdSource = filteredResults.find(r => extractPageIdFromUrl(r.link));
if (newsIdSource) {
const ajaxSeasons = await fetchFStreamSeasonsAjax(id, extractPageIdFromUrl(newsIdSource.link), extractBaseUrlFromLink(newsIdSource.link));
const ajaxMatch = ajaxSeasons.find(r => r.seasonNumber === requestedSeason);
if (ajaxMatch) bestResult = ajaxMatch;
}
}
if (!bestResult) {
// Background update: ne pas écraser le cache avec une erreur
return;
}
const players = await getSeriesPlayersForUrl(bestResult.link);
if (players.total === 0) return;
let isAvailable = true;
if (tmdbDetails.release_date) {
const tmdbYearStr = tmdbDetails.release_date.split('-')[0];
if (players.fromApi) {
if (bestResult.year && bestResult.year !== parseInt(tmdbYearStr)) {
isAvailable = false;
}
} else {
if (players.fstreamReleaseDate) {
if (players.fstreamReleaseDate !== tmdbYearStr) isAvailable = false;
} else { isAvailable = false; }
}
}
const response = {
success: isAvailable, source: 'FStream', type: 'tv', tmdb: tmdbDetails,
search: { query: searchQuery, results: filteredResults.length, bestMatch: bestResult },
episodes: isAvailable ? players.episodes : {}, total: isAvailable ? players.total : 0,
metadata: { season: parseInt(season), episode: episode ? parseInt(episode) : null, extractedAt: new Date().toISOString(), backgroundUpdate: true, fstreamReleaseDate: players.fstreamReleaseDate, dateValidation: { fstreamYear: players.fstreamReleaseDate, tmdbYear: tmdbDetails.release_date?.split('-')[0], isAvailable } }
};
await saveFStreamToCache(cacheKey, response);
return response;
});
} catch (error) { /* background error, ignore */ }
});
return;
}
// No cache
const result = await getOrCreateFStreamRequest(cacheKey, async () => {
const tmdbDetails = await getFStreamTMDBDetails(id, 'tv');
if (!tmdbDetails) throw new Error('Contenu TMDB non trouve');
let searchQuery;
if (id === '259909') { searchQuery = 'Dexter : Resurrection - Saison 1'; }
else { searchQuery = `${tmdbDetails.title} - Saison ${season}`; }
let searchResults = await searchFStreamDirect(searchQuery);
let filteredResults = filterFStreamResults(searchResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
if (filteredResults.length === 0) {
const directSeasonsEarly = await fetchFStreamSeasonSearchResults(id, tmdbDetails.title);
if (directSeasonsEarly.length > 0) { filteredResults = directSeasonsEarly; console.log(`[FSTREAM TV] Requete directe des saisons reussie (ajax - precoce)`); }
if (tmdbDetails.release_date) {
const year = tmdbDetails.release_date.split('-')[0];
const fallbackQuery = `${tmdbDetails.title} (${year}) - Saison ${season}`;
try {
let fallbackSearchResults = await searchFStreamDirect(fallbackQuery);
let fallbackFilteredResults = filterFStreamResults(fallbackSearchResults, tmdbDetails.title, year);
if (fallbackFilteredResults.length > 0) filteredResults = fallbackFilteredResults;
} catch (fallbackError) {
console.log(`[FSTREAM TV] Erreur lors de la recherche de fallback avec annee: ${fallbackError.message}`);
}
}
if (filteredResults.length === 0 && tmdbDetails.name_no_lang && tmdbDetails.name_no_lang !== tmdbDetails.title) {
const noLangFallbackQuery = `${tmdbDetails.name_no_lang} - Saison ${season}`;
try {
let noLangSearchResults = await searchFStreamDirect(noLangFallbackQuery);
let noLangFilteredResults = filterFStreamResults(noLangSearchResults, tmdbDetails.name_no_lang, tmdbDetails.release_date?.split('-')[0]);
if (noLangFilteredResults.length > 0) { filteredResults = noLangFilteredResults; console.log(`[FSTREAM TV] Fallback avec nom sans langue reussi: "${noLangFallbackQuery}"`); }
} catch (noLangFallbackError) {
console.log(`[FSTREAM TV] Erreur lors de la recherche de fallback avec nom sans langue: ${noLangFallbackError.message}`);
}
}
if (filteredResults.length === 0) {
const directSeasons = await fetchFStreamSeasonSearchResults(id, tmdbDetails.title);
if (directSeasons.length > 0) { filteredResults = directSeasons; console.log(`[FSTREAM TV] Requete directe des saisons reussie (ajax)`); }
}
if (filteredResults.length === 0) throw new Error('Aucun resultat trouve sur FStream');
}
const requestedSeason = parseInt(season);
const tmdbYear = tmdbDetails.release_date ? parseInt(tmdbDetails.release_date.split('-')[0]) : null;
// Trouver tous les resultats correspondant a la saison demandee
const seasonMatches = filteredResults.filter(result => result.seasonNumber && result.seasonNumber === requestedSeason);
let bestResult = null;
if (seasonMatches.length > 1 && tmdbYear) {
// Prioriser le resultat dont l'annee correspond a l'annee TMDB
bestResult = seasonMatches.find(r => r.year === tmdbYear) || seasonMatches[0];
} else if (seasonMatches.length === 1) {
bestResult = seasonMatches[0];
}
if (!bestResult) {
// Fallback: chercher dans le titre original
const titleSeasonMatches = filteredResults.filter(result => {
if (result.originalTitle) {
const seasonInTitle = result.originalTitle.match(/Saison\s+(\d+)/i);
if (seasonInTitle) return parseInt(seasonInTitle[1]) === requestedSeason;
}
return false;
});
if (titleSeasonMatches.length > 1 && tmdbYear) {
bestResult = titleSeasonMatches.find(r => r.year === tmdbYear) || titleSeasonMatches[0];
} else if (titleSeasonMatches.length === 1) {
bestResult = titleSeasonMatches[0];
}
}
// Fallback: rechercher sans le numero de saison (FStream retourne parfois des resultats differents)
if (!bestResult) {
try {
const noNumQuery = `${tmdbDetails.title} - Saison`;
const noNumResults = await searchFStreamDirect(noNumQuery);
const noNumFiltered = filterFStreamResults(noNumResults, tmdbDetails.title, tmdbDetails.release_date?.split('-')[0]);
const noNumSeasonMatches = noNumFiltered.filter(r => r.seasonNumber === requestedSeason);
if (noNumSeasonMatches.length > 0) {
bestResult = noNumSeasonMatches.length > 1 && tmdbYear ? (noNumSeasonMatches.find(r => r.year === tmdbYear) || noNumSeasonMatches[0]) : noNumSeasonMatches[0];
filteredResults = noNumFiltered;
console.log(`[FSTREAM TV] Fallback sans numero de saison reussi pour "${tmdbDetails.title}" saison ${requestedSeason}`);
}
} catch (e) {
console.log(`[FSTREAM TV] Erreur fallback sans numero de saison: ${e.message}`);
}
}
// Fallback get_seasons.php: search.php ne liste pas toujours toutes les saisons.
// On a au moins une saison trouvee -> son page id sert de news_id, serie_tag = s-<tmdbId>.
if (!bestResult) {
const newsIdSource = filteredResults.find(r => extractPageIdFromUrl(r.link));
if (newsIdSource) {
const ajaxSeasons = await fetchFStreamSeasonsAjax(id, extractPageIdFromUrl(newsIdSource.link), extractBaseUrlFromLink(newsIdSource.link));
const ajaxMatch = ajaxSeasons.find(r => r.seasonNumber === requestedSeason);
if (ajaxMatch) {
bestResult = ajaxMatch;
console.log(`[FSTREAM TV] Fallback get_seasons.php reussi: saison ${requestedSeason} pour "${tmdbDetails.title}"`);
}
}
}
if (!bestResult) {
const availableSeasons = filteredResults
.filter(r => r.seasonNumber && r.title.toLowerCase().includes(tmdbDetails.title.toLowerCase().split(/[:\-]/)[0].trim().toLowerCase()))
.map(r => r.seasonNumber)
.filter((s, i, arr) => arr.indexOf(s) === i)
.sort((a, b) => a - b);
const seasonText = availableSeasons.length > 0
? `Saisons disponibles pour "${tmdbDetails.title}": ${availableSeasons.join(', ')}`
: `Aucune saison trouvee pour "${tmdbDetails.title}"`;
throw new Error(`Saison ${requestedSeason} non trouvee. ${seasonText}`);
}
// Verifier la correspondance d'annee meme quand l'API est utilisee
if (tmdbYear && bestResult.year && bestResult.year !== tmdbYear) {
console.log(`[FSTREAM TV] Annee non correspondante: TMDB=${tmdbYear}, FStream=${bestResult.year} pour "${bestResult.title}"`);
}
const players = await getSeriesPlayersForUrl(bestResult.link);
if (players.total === 0) throw new Error('Aucun lecteur video trouve');
let isAvailable = true;
if (tmdbDetails.release_date) {
const tmdbYearStr = tmdbDetails.release_date.split('-')[0];
if (players.fromApi) {
// Quand l'API est utilisee, valider via l'annee du resultat de recherche
if (bestResult.year && bestResult.year !== parseInt(tmdbYearStr)) {
isAvailable = false;
}
} else {
if (players.fstreamReleaseDate) {
if (players.fstreamReleaseDate !== tmdbYearStr) isAvailable = false;
} else { isAvailable = false; }
}
}
return {
success: isAvailable, source: 'FStream', type: 'tv', tmdb: tmdbDetails,
search: { query: searchQuery, results: filteredResults.length, bestMatch: bestResult },
episodes: isAvailable ? players.episodes : {}, total: isAvailable ? players.total : 0,
metadata: { season: parseInt(season), episode: episode ? parseInt(episode) : null, extractedAt: new Date().toISOString(), fstreamReleaseDate: players.fstreamReleaseDate, dateValidation: { fstreamYear: players.fstreamReleaseDate, tmdbYear: tmdbDetails.release_date?.split('-')[0], isAvailable } }
};
});
await saveFStreamToCache(cacheKey, result);
res.status(200).json(result);
} catch (error) {
console.error(`[FSTREAM TV] Erreur: ${error.message}`);
const errorResult = { success: false, error: 'Erreur lors de la recuperation des sources FStream', message: error.message, timestamp: new Date().toISOString() };
await saveFStreamToCache(cacheKey, errorResult);
res.status(500).json(errorResult);
}
});
// GET /test/recent
router.get('/test/recent', async (req, res) => {
try {
const recentMovies = await scrapeFStreamRecentMovies();
res.status(200).json({ success: true, count: recentMovies.length, movies: recentMovies.slice(0, 10), timestamp: new Date().toISOString() });
} catch (error) {
res.status(500).json({ success: false, error: error.message, timestamp: new Date().toISOString() });
}
});
// GET /test/recent-series
router.get('/test/recent-series', async (req, res) => {
try {
const recentSeries = await scrapeFStreamRecentSeries();
res.status(200).json({ success: true, count: recentSeries.length, series: recentSeries.slice(0, 10), timestamp: new Date().toISOString() });
} catch (error) {
res.status(500).json({ success: false, error: error.message, timestamp: new Date().toISOString() });
}
});
// GET /debug/ongoing
router.get('/debug/ongoing', async (req, res) => {
try {
const ongoingKeys = Array.from(ongoingFStreamRequests.keys());
res.status(200).json({ success: true, ongoingRequests: ongoingKeys.length, keys: ongoingKeys, timestamp: new Date().toISOString() });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;