mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-30 03:59:13 +00:00
Modification du Oauth : Pouvoir manager les apps depuis le panel admin Correction de l'affichage des images des admins Majs des urls extension, userscript et premid Correction d'un crash sur les commentaires Ajout d'un light mode sur le frontend
446 lines
17 KiB
JavaScript
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.tax/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;
|