/** * PurStream routes module. * Proxy vers l'API PurStream pour récupérer les streams de films et séries. * Résolution TMDB → PurStream ID via recherche par titre + poster. * Cache fichier L2 + memoryCache L1 (pattern identique aux autres routes). * Pattern stale-while-revalidate : on sert le cache, on update en background. * * Mounted at /api/purstream */ const express = require('express'); const axios = require('axios'); const router = express.Router(); const { generateCacheKey, CACHE_DIR } = require('../utils/cacheManager'); const { fetchTmdbDetails, fetchTmdbImages } = require('../utils/tmdbCache'); const { pickRandomProxy, getProxyAgent } = require('../utils/proxyManager'); const PURSTREAM_BASE = 'https://api.purstream.cc/api/v1'; const PURSTREAM_CACHE_DIR = CACHE_DIR.PURSTREAM; // --------------------------------------------------------------------------- // Dependencies injected via configure() // --------------------------------------------------------------------------- let TMDB_API_KEY; let TMDB_API_URL; let PROXY_SERVER_URL; let verifyAccessKey; let getFromCacheNoExpiration; let saveToCache; let shouldUpdateCache; function configure(deps) { if (deps.TMDB_API_KEY) TMDB_API_KEY = deps.TMDB_API_KEY; if (deps.TMDB_API_URL) TMDB_API_URL = deps.TMDB_API_URL; if (deps.PROXY_SERVER_URL) PROXY_SERVER_URL = deps.PROXY_SERVER_URL; if (deps.verifyAccessKey) verifyAccessKey = deps.verifyAccessKey; if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration; if (deps.saveToCache) saveToCache = deps.saveToCache; if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache; } /** Wrap une URL m3u8 dans le proxy cinep si VIP et PROXY_SERVER_URL configuré */ function wrapSourceUrl(url, isVip) { if (isVip && PROXY_SERVER_URL && url) { // PROXY_SERVER_URL = "https://proxy.movix.cloud/proxy" → on veut la base sans /proxy const serverBase = PROXY_SERVER_URL.replace(/\/proxy\/?$/, '').replace(/\/+$/, ''); return `${serverBase}/cinep-proxy?url=${encodeURIComponent(url)}`; } return url; } /** Fait une requête vers PurStream avec un proxy SOCKS5 aléatoire */ async function purstreamRequest(urlPath) { const proxy = pickRandomProxy(); const agent = proxy ? getProxyAgent(proxy) : null; return axios({ url: `${PURSTREAM_BASE}${urlPath}`, method: 'get', timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36', 'Accept': 'application/json' }, ...(agent ? { httpAgent: agent, httpsAgent: agent, proxy: false } : {}), decompress: true }); } // Marqueur pour les résultats négatifs cachés const NOT_FOUND_MARKER = { __not_found: true }; // --------------------------------------------------------------------------- // Résolution TMDB ID → PurStream ID // --------------------------------------------------------------------------- async function resolvePurstreamId(tmdbId, type) { const cacheKey = generateCacheKey(`purstream_map_${type}_${tmdbId}`); const cached = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); if (cached) { if (cached.__not_found) { const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) { backgroundUpdateMapping(tmdbId, type, cacheKey).catch(() => {}); } return null; } const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) { backgroundUpdateMapping(tmdbId, type, cacheKey).catch(() => {}); } return cached; } return await fetchAndCacheMapping(tmdbId, type, cacheKey); } /** Recherche et cache le mapping TMDB → PurStream */ async function fetchAndCacheMapping(tmdbId, type, cacheKey) { const tmdbData = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, type, 'fr-FR'); if (!tmdbData) { console.warn(`[PURSTREAM] TMDB ${type}:${tmdbId} introuvable`); await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return null; } const tmdbTitle = type === 'movie' ? tmdbData.title : tmdbData.name; const tmdbOriginalTitle = type === 'movie' ? tmdbData.original_title : tmdbData.original_name; if (!tmdbTitle) { console.warn(`[PURSTREAM] TMDB ${type}:${tmdbId} n'a pas de titre`); await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return null; } // Collecter TOUS les posters TMDB (toutes langues, cache Redis intégré) const tmdbPosters = new Set(); if (tmdbData.poster_path) tmdbPosters.add(tmdbData.poster_path); if (tmdbData.backdrop_path) tmdbPosters.add(tmdbData.backdrop_path); const imagesData = await fetchTmdbImages(TMDB_API_URL, TMDB_API_KEY, tmdbId, type); if (imagesData?.posters) { for (const p of imagesData.posters) { if (p.file_path) tmdbPosters.add(p.file_path); } } if (imagesData?.backdrops) { for (const b of imagesData.backdrops) { if (b.file_path) tmdbPosters.add(b.file_path); } } // Chercher sur PurStream (titre FR puis titre original si différent) const searchQueries = [tmdbTitle]; if (tmdbOriginalTitle && tmdbOriginalTitle !== tmdbTitle) { searchQueries.push(tmdbOriginalTitle); } let allItems = []; let searchError = false; for (const query of searchQueries) { try { const response = await purstreamRequest(`/search-bar/search/${encodeURIComponent(query)}`); if (response.data?.type === 'success') { const items = response.data.data?.items?.movies?.items || []; for (const item of items) { if (!allItems.some(existing => existing.id === item.id)) { allItems.push(item); } } } } catch (err) { searchError = true; console.warn(`[PURSTREAM] Recherche échouée pour "${query}": ${err.response?.status || err.message}`); } } // Erreur réseau/429/5xx → ne PAS cacher (résultat temporaire) if (allItems.length === 0 && searchError) { return null; } if (allItems.length === 0) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return null; } // Extraire posters PurStream const extractPosterPath = (url) => { if (!url) return ''; const m = url.match(/\/([^/]+\.jpg)$/); return m ? `/${m[1]}` : ''; }; // Matcher strictement par type + poster TMDB const getPurstreamPosters = (item) => { const posters = new Set(); const p1 = extractPosterPath(item.large_poster_path); const p2 = extractPosterPath(item.small_poster_path); const p3 = extractPosterPath(item.wallpaper_poster_path); if (p1) posters.add(p1); if (p2) posters.add(p2); if (p3) posters.add(p3); return posters; }; const posterMatch = (item) => { if (tmdbPosters.size === 0) return false; const purPosters = getPurstreamPosters(item); for (const p of purPosters) { if (tmdbPosters.has(p)) return true; } return false; }; const best = allItems.find(item => item.type === type && posterMatch(item)); if (!best) { console.warn(`[PURSTREAM] Aucun match pour ${type}:${tmdbId} "${tmdbTitle}"`); await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return null; } const result = { purstream_id: best.id, title: best.title, type: best.type }; await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, result); return result; } /** Revalidation background du mapping — ne jamais écraser un mapping valide par un échec */ async function backgroundUpdateMapping(tmdbId, type, cacheKey) { const existing = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); const hasValidMapping = existing && !existing.__not_found && existing.purstream_id; try { const result = await fetchAndCacheMapping(tmdbId, type, cacheKey); if (!result && hasValidMapping) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, existing); } } catch (err) { console.warn(`[PURSTREAM] BG mapping ${type}:${tmdbId} erreur: ${err.message}`); if (hasValidMapping) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, existing); } } } /** Fetch stream depuis PurStream, retourne les sources ou marqueur erreur */ async function fetchStream(purstreamId, urlPath) { try { const response = await purstreamRequest(urlPath); if (response.data?.type !== 'success') return null; return response.data.data?.items || null; } catch (err) { console.warn(`[PURSTREAM] Erreur fetch stream purstream:${purstreamId}: ${err.response?.status || err.message}`); return { __error: true, status: err.response?.status || 0 }; } } // --------------------------------------------------------------------------- // GET /api/purstream/movie/:tmdbId/stream // --------------------------------------------------------------------------- router.get('/movie/:tmdbId/stream', async (req, res) => { try { const { tmdbId } = req.params; if (!tmdbId || isNaN(tmdbId)) return res.status(400).json({ error: 'TMDB ID invalide' }); // Vérifier VIP pour proxifier les URLs const accessKey = req.headers['x-access-key'] || null; const vipStatus = verifyAccessKey ? await verifyAccessKey(accessKey) : { vip: false }; const isVip = vipStatus.vip; const cacheKey = generateCacheKey(`purstream_stream_movie_${tmdbId}`); const cached = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); if (cached) { if (cached.__not_found) { const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) backgroundUpdateStreamMovie(tmdbId, cacheKey).catch(() => {}); return res.status(404).json({ error: 'Film non trouvé sur PurStream' }); } const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) backgroundUpdateStreamMovie(tmdbId, cacheKey).catch(() => {}); // Appliquer le proxy VIP au moment de la réponse (le cache stocke les URLs brutes) const response = { ...cached, sources: cached.sources.map(s => ({ ...s, url: wrapSourceUrl(s.url, isVip) })) }; return res.json(response); } const mapping = await resolvePurstreamId(tmdbId, 'movie'); if (!mapping) { return res.status(404).json({ error: 'Film non trouvé sur PurStream' }); } const streamData = await fetchStream(mapping.purstream_id, `/stream/${mapping.purstream_id}`); if (streamData?.__error) { if (streamData.status === 404) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return res.status(404).json({ error: 'Film non trouvé sur PurStream' }); } return res.status(502).json({ error: 'Erreur temporaire PurStream' }); } if (!streamData) { return res.status(502).json({ error: 'Réponse invalide de PurStream' }); } const sources = streamData.sources || []; const result = { purstream_id: mapping.purstream_id, sources: sources.map(s => ({ url: s.stream_url, name: s.source_name, format: s.format })) }; if (sources.length === 0) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return res.status(404).json({ error: 'Aucun stream disponible pour ce film' }); } await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, result); // Retourner avec URLs proxifiées si VIP const response = { ...result, sources: result.sources.map(s => ({ ...s, url: wrapSourceUrl(s.url, isVip) })) }; res.json(response); } catch (error) { console.error('[PURSTREAM] Erreur stream film:', error.message); res.status(502).json({ error: 'Erreur lors de la récupération du stream' }); } }); // --------------------------------------------------------------------------- // GET /api/purstream/tv/:tmdbId/stream?season=X&episode=Y // --------------------------------------------------------------------------- router.get('/tv/:tmdbId/stream', async (req, res) => { try { const { tmdbId } = req.params; const { season, episode } = req.query; if (!tmdbId || isNaN(tmdbId)) return res.status(400).json({ error: 'TMDB ID invalide' }); if (!season || isNaN(season)) return res.status(400).json({ error: 'Le paramètre season est requis' }); if (!episode || isNaN(episode)) return res.status(400).json({ error: 'Le paramètre episode est requis' }); // Vérifier VIP pour proxifier les URLs const accessKey = req.headers['x-access-key'] || null; const vipStatus = verifyAccessKey ? await verifyAccessKey(accessKey) : { vip: false }; const isVip = vipStatus.vip; const cacheKey = generateCacheKey(`purstream_stream_tv_${tmdbId}_s${season}e${episode}`); const cached = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); if (cached) { if (cached.__not_found) { const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) backgroundUpdateStreamTv(tmdbId, season, episode, cacheKey).catch(() => {}); return res.status(404).json({ error: 'Épisode non trouvé sur PurStream' }); } const stale = await shouldUpdateCache(PURSTREAM_CACHE_DIR, cacheKey); if (stale) backgroundUpdateStreamTv(tmdbId, season, episode, cacheKey).catch(() => {}); const response = { ...cached, sources: cached.sources.map(s => ({ ...s, url: wrapSourceUrl(s.url, isVip) })) }; return res.json(response); } const mapping = await resolvePurstreamId(tmdbId, 'tv'); if (!mapping) { return res.status(404).json({ error: 'Série non trouvée sur PurStream' }); } const streamData = await fetchStream(mapping.purstream_id, `/stream/${mapping.purstream_id}/episode?season=${Number(season)}&episode=${Number(episode)}`); if (streamData?.__error) { if (streamData.status === 404) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return res.status(404).json({ error: 'Épisode non trouvé sur PurStream' }); } return res.status(502).json({ error: 'Erreur temporaire PurStream' }); } if (!streamData) { return res.status(502).json({ error: 'Réponse invalide de PurStream' }); } const sources = streamData.sources || []; const result = { purstream_id: mapping.purstream_id, season: streamData.season || Number(season), episode: streamData.episode || Number(episode), sources: sources.map(s => ({ url: s.stream_url, name: s.source_name, format: s.format })) }; if (sources.length === 0) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return res.status(404).json({ error: 'Aucun stream disponible pour cet épisode' }); } await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, result); const response = { ...result, sources: result.sources.map(s => ({ ...s, url: wrapSourceUrl(s.url, isVip) })) }; res.json(response); } catch (error) { console.error('[PURSTREAM] Erreur stream série:', error.message); res.status(502).json({ error: 'Erreur lors de la récupération du stream' }); } }); // --------------------------------------------------------------------------- // Background updates — ne jamais écraser un cache valide par un échec // --------------------------------------------------------------------------- async function backgroundUpdateStreamMovie(tmdbId, cacheKey) { const existing = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); const hasValidCache = existing && !existing.__not_found && existing.sources?.length > 0; try { const mapping = await resolvePurstreamId(tmdbId, 'movie'); if (!mapping) return; const streamData = await fetchStream(mapping.purstream_id, `/stream/${mapping.purstream_id}`); if (!streamData || streamData.__error) return; const sources = streamData.sources || []; if (sources.length === 0 && hasValidCache) return; if (sources.length === 0) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return; } const result = { purstream_id: mapping.purstream_id, sources: sources.map(s => ({ url: s.stream_url, name: s.source_name, format: s.format })) }; await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, result); } catch (err) { // Silent fail — cache existant reste intact } } async function backgroundUpdateStreamTv(tmdbId, season, episode, cacheKey) { const existing = await getFromCacheNoExpiration(PURSTREAM_CACHE_DIR, cacheKey); const hasValidCache = existing && !existing.__not_found && existing.sources?.length > 0; try { const mapping = await resolvePurstreamId(tmdbId, 'tv'); if (!mapping) return; const streamData = await fetchStream(mapping.purstream_id, `/stream/${mapping.purstream_id}/episode?season=${Number(season)}&episode=${Number(episode)}`); if (!streamData || streamData.__error) return; const sources = streamData.sources || []; if (sources.length === 0 && hasValidCache) return; if (sources.length === 0) { await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, NOT_FOUND_MARKER); return; } const result = { purstream_id: mapping.purstream_id, season: streamData.season || Number(season), episode: streamData.episode || Number(episode), sources: sources.map(s => ({ url: s.stream_url, name: s.source_name, format: s.format })) }; await saveToCache(PURSTREAM_CACHE_DIR, cacheKey, result); } catch (err) { // Silent fail — cache existant reste intact } } module.exports = router; module.exports.configure = configure;