MovixOpenSource/API/Mainapi/routes/purstream.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

446 lines
17 KiB
JavaScript

/**
* 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.cash/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;