MovixOpenSource/API/Mainapi/routes/topstream.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

486 lines
18 KiB
JavaScript

/**
* Top-Stream routes module.
* Scrapes top-stream.plus for movie/series search and embed extraction.
*
* Mounted at /api/topstream (paths below are relative to that prefix).
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fsp = require('fs').promises;
const axios = require('axios');
const cheerio = require('cheerio');
const { generateCacheKey } = require('../utils/cacheManager');
const { fetchTmdbDetails } = require('../utils/tmdbCache');
const TOPSTREAM_BASE_URL = 'https://top-stream.plus';
const BYPASS403_SERVER_URL = process.env.BYPASS403_SERVER_URL || '';
const topstreamHeaders = {
'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': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': TOPSTREAM_BASE_URL,
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
};
// ---------------------------------------------------------------------------
// Dependencies injected via configure()
// ---------------------------------------------------------------------------
let TMDB_API_KEY;
let TMDB_API_URL;
let getFromCacheNoExpiration;
let saveToCache;
let shouldUpdateCache24h;
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.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration;
if (deps.saveToCache) saveToCache = deps.saveToCache;
if (deps.shouldUpdateCache24h) shouldUpdateCache24h = deps.shouldUpdateCache24h;
}
// ---------------------------------------------------------------------------
// Helper: build proxied embed URL
// ---------------------------------------------------------------------------
function proxyEmbedUrl(originalLink) {
const topstreamMatch = originalLink.match(/https?:\/\/(?:www\.)?top-stream\.plus(\/embed\/\d+)/);
if (topstreamMatch && BYPASS403_SERVER_URL) {
return `${BYPASS403_SERVER_URL}/proxy/https://top-stream.plus${topstreamMatch[1]}`;
}
return originalLink;
}
// ---------------------------------------------------------------------------
// Helper: fetch a page from top-stream
// ---------------------------------------------------------------------------
async function fetchPage(url) {
const response = await axios.get(url, {
headers: topstreamHeaders,
timeout: 15000,
decompress: true
});
if (response.status === 403) {
console.error('[TOPSTREAM] 403 Forbidden:', url);
}
return response.data;
}
// ---------------------------------------------------------------------------
// Helper: resolve TMDB ID to title
// ---------------------------------------------------------------------------
async function getTmdbTitle(tmdbId, mediaType) {
const endpoint = mediaType === 'tv' ? 'tv' : 'movie';
const data = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, endpoint, 'fr-FR');
if (!data) return { title: '', originalTitle: '', year: '' };
const title = data.title || data.name || '';
const originalTitle = data.original_title || data.original_name || '';
const year = (data.release_date || data.first_air_date || '').substring(0, 4);
return { title, originalTitle, year };
}
// ---------------------------------------------------------------------------
// searchTopStream -- search for movies/series
// ---------------------------------------------------------------------------
async function searchTopStream(query) {
const searchUrl = `${TOPSTREAM_BASE_URL}/search/${encodeURIComponent(query)}`;
const html = await fetchPage(searchUrl);
const $ = cheerio.load(html);
const results = [];
$('div.relative.group.overflow-hidden').each((_, el) => {
const card = $(el);
const link = card.find('a').first();
const href = link.attr('href') || '';
let type = 'movie';
let slug = '';
if (href.includes('/movie/')) {
type = 'movie';
slug = href.split('/movie/').pop();
} else if (href.includes('/tv-show/')) {
type = 'tv';
slug = href.split('/tv-show/').pop();
} else {
return;
}
const img = card.find('img').first();
const image = img.attr('src') || img.attr('data-src') || '';
const title = img.attr('alt') || '';
const ratingEl = card.find('.absolute.right-3.top-3 span.text-xs');
const rating = ratingEl.text().trim() || null;
const infoDiv = card.find('.pt-4');
const allSpans = infoDiv.find('span');
let duration = null;
let year = null;
let genre = null;
let typeLabel = null;
allSpans.each((_, span) => {
const text = $(span).text().trim();
if (text.match(/\d+h\d+|min/)) {
duration = text;
} else if (text.match(/^\d{4}$/)) {
year = parseInt(text);
} else if ($(span).hasClass('text-xxs')) {
typeLabel = text;
} else if (!duration && !year && text.length > 0) {
if (!genre) genre = text;
}
});
const h3Title = infoDiv.find('h3').text().trim() || title;
results.push({
title: h3Title,
slug,
type,
image,
rating: rating ? parseFloat(rating) : null,
year,
duration,
genre,
typeLabel
});
});
return results;
}
// ---------------------------------------------------------------------------
// getTopStreamEmbed -- extract embed URL from movie or episode page
// ---------------------------------------------------------------------------
async function getTopStreamEmbed(pageUrl) {
const html = await fetchPage(pageUrl);
const $ = cheerio.load(html);
const videos = [];
let watchComponentFound = false;
let cover = null;
$('*').each((_, el) => {
if (watchComponentFound) return;
const snapshot = $(el).attr('wire:snapshot');
if (!snapshot) return;
try {
const data = JSON.parse(snapshot);
if (data.memo && data.memo.name === 'watch-component') {
watchComponentFound = true;
cover = data.data?.cover || null;
const videosData = data.data?.videos;
if (Array.isArray(videosData)) {
const extractLinks = (obj) => {
if (!obj) return;
if (Array.isArray(obj)) {
obj.forEach(item => extractLinks(item));
} else if (typeof obj === 'object' && obj.link) {
videos.push({
label: obj.label || 'Stream',
type: obj.type || 'mp4',
link: proxyEmbedUrl(obj.link)
});
}
};
extractLinks(videosData);
}
}
} catch (err) {
// skip
}
});
return { videos, cover };
}
// ---------------------------------------------------------------------------
// findAndGetEmbed -- search TopStream by title, find best match, get embed
// ---------------------------------------------------------------------------
async function findAndGetEmbed(tmdbId, mediaType, season, episode) {
const { title, originalTitle, year } = await getTmdbTitle(tmdbId, mediaType);
if (!title && !originalTitle) return null;
const searchType = mediaType === 'tv' ? 'tv' : 'movie';
let results = await searchTopStream(title);
let matchResults = results.filter(r => r.type === searchType);
if (matchResults.length === 0 && originalTitle && originalTitle !== title) {
results = await searchTopStream(originalTitle);
matchResults = results.filter(r => r.type === searchType);
}
if (matchResults.length === 0) return null;
let bestMatch = matchResults[0];
if (year) {
const yearNum = parseInt(year);
const yearMatch = matchResults.find(r => r.year === yearNum);
if (yearMatch) bestMatch = yearMatch;
}
let pageUrl;
if (mediaType === 'tv') {
pageUrl = `${TOPSTREAM_BASE_URL}/episode/${bestMatch.slug}/${season}-${episode}`;
} else {
pageUrl = `${TOPSTREAM_BASE_URL}/movie/${bestMatch.slug}`;
}
const embedResult = await getTopStreamEmbed(pageUrl);
return {
...embedResult,
slug: bestMatch.slug,
matchedTitle: bestMatch.title,
matchedYear: bestMatch.year
};
}
// ---------------------------------------------------------------------------
// GET /film/:tmdbId
// ---------------------------------------------------------------------------
router.get('/film/:tmdbId', async (req, res) => {
const { tmdbId } = req.params;
const cacheKey = generateCacheKey(`topstream_film_${tmdbId}`);
const cacheDir = path.join(__dirname, '..', 'cache', 'topstream');
try {
await fsp.mkdir(cacheDir, { recursive: true });
const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (cachedData) {
res.json(cachedData);
const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey);
if (shouldUpdate) {
(async () => {
try {
const result = await findAndGetEmbed(tmdbId, 'movie');
if (result) {
await saveToCache(cacheDir, cacheKey, { success: true, tmdbId, type: 'movie', ...result });
}
} catch (err) {
if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background film update:', tmdbId);
}
})();
}
return;
}
const result = await findAndGetEmbed(tmdbId, 'movie');
if (!result || result.videos.length === 0) {
const data = { success: false, tmdbId, error: 'Film non trouvé sur TopStream' };
await saveToCache(cacheDir, cacheKey, data);
return res.status(404).json(data);
}
const data = { success: true, tmdbId, type: 'movie', ...result };
await saveToCache(cacheDir, cacheKey, data);
res.json(data);
} catch (error) {
if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden film:', tmdbId);
else console.error('[TOPSTREAM] Film error:', tmdbId, error.message);
res.status(500).json({ success: false, error: 'Erreur lors de la récupération du film', details: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /tv/:tmdbId?season=X&episode=Y
// ---------------------------------------------------------------------------
router.get('/tv/:tmdbId', async (req, res) => {
const { tmdbId } = req.params;
const season = parseInt(req.query.season);
const episode = parseInt(req.query.episode);
if (!season || !episode) {
return res.status(400).json({ success: false, error: 'Les paramètres ?season= et ?episode= sont requis.' });
}
const cacheKey = generateCacheKey(`topstream_tv_${tmdbId}_${season}_${episode}`);
const cacheDir = path.join(__dirname, '..', 'cache', 'topstream');
try {
await fsp.mkdir(cacheDir, { recursive: true });
const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (cachedData) {
res.json(cachedData);
const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey);
if (shouldUpdate) {
(async () => {
try {
const result = await findAndGetEmbed(tmdbId, 'tv', season, episode);
if (result) {
await saveToCache(cacheDir, cacheKey, { success: true, tmdbId, type: 'tv', season, episode, ...result });
}
} catch (err) {
if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background TV update:', tmdbId, 'S' + season + 'E' + episode);
}
})();
}
return;
}
const result = await findAndGetEmbed(tmdbId, 'tv', season, episode);
if (!result || result.videos.length === 0) {
const data = { success: false, tmdbId, season, episode, error: 'Épisode non trouvé sur TopStream' };
await saveToCache(cacheDir, cacheKey, data);
return res.status(404).json(data);
}
const data = { success: true, tmdbId, type: 'tv', season, episode, ...result };
await saveToCache(cacheDir, cacheKey, data);
res.json(data);
} catch (error) {
if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden TV:', tmdbId, 'S' + season + 'E' + episode);
else console.error('[TOPSTREAM] TV error:', tmdbId, error.message);
res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'épisode', details: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /search?q=... (conservé pour usage direct)
// ---------------------------------------------------------------------------
router.get('/search', async (req, res) => {
const query = req.query.q;
if (!query) {
return res.status(400).json({ success: false, error: 'Le paramètre ?q= est requis.' });
}
const cacheKey = generateCacheKey(`topstream_search_${query.toLowerCase().trim()}`);
const cacheDir = path.join(__dirname, '..', 'cache', 'topstream');
try {
await fsp.mkdir(cacheDir, { recursive: true });
const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (cachedData) {
res.json(cachedData);
const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey);
if (shouldUpdate) {
(async () => {
try {
const results = await searchTopStream(query);
await saveToCache(cacheDir, cacheKey, { success: true, results });
} catch (err) {
if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background search:', query);
}
})();
}
return;
}
const results = await searchTopStream(query);
const data = { success: true, results };
await saveToCache(cacheDir, cacheKey, data);
res.json(data);
} catch (error) {
if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden search:', query);
else console.error('[TOPSTREAM] Search error:', query, error.message);
res.status(500).json({ success: false, error: 'Erreur lors de la recherche', details: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /movie/:slug (conservé pour usage direct par slug)
// ---------------------------------------------------------------------------
router.get('/movie/:slug', async (req, res) => {
const { slug } = req.params;
const cacheKey = generateCacheKey(`topstream_movie_${slug}`);
const cacheDir = path.join(__dirname, '..', 'cache', 'topstream');
try {
await fsp.mkdir(cacheDir, { recursive: true });
const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (cachedData) {
res.json(cachedData);
const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey);
if (shouldUpdate) {
(async () => {
try {
const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/movie/${slug}`);
await saveToCache(cacheDir, cacheKey, { success: true, slug, type: 'movie', ...result });
} catch (err) {
if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background movie:', slug);
}
})();
}
return;
}
const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/movie/${slug}`);
const data = { success: true, slug, type: 'movie', ...result };
await saveToCache(cacheDir, cacheKey, data);
res.json(data);
} catch (error) {
if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden movie:', slug);
else console.error('[TOPSTREAM] Movie error:', slug, error.message);
res.status(500).json({ success: false, error: 'Erreur lors de la récupération du film', details: error.message });
}
});
// ---------------------------------------------------------------------------
// GET /episode/:slug/:season-:episode (conservé pour usage direct par slug)
// ---------------------------------------------------------------------------
router.get('/episode/:slug/:seasonEpisode', async (req, res) => {
const { slug, seasonEpisode } = req.params;
const match = seasonEpisode.match(/^(\d+)-(\d+)$/);
if (!match) {
return res.status(400).json({ success: false, error: 'Format invalide. Utilisez :season-:episode (ex: 5-1).' });
}
const season = parseInt(match[1]);
const episode = parseInt(match[2]);
const cacheKey = generateCacheKey(`topstream_episode_${slug}_${season}_${episode}`);
const cacheDir = path.join(__dirname, '..', 'cache', 'topstream');
try {
await fsp.mkdir(cacheDir, { recursive: true });
const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (cachedData) {
res.json(cachedData);
const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey);
if (shouldUpdate) {
(async () => {
try {
const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/episode/${slug}/${season}-${episode}`);
await saveToCache(cacheDir, cacheKey, { success: true, slug, type: 'tv', season, episode, ...result });
} catch (err) {
if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background episode:', slug, 'S' + season + 'E' + episode);
}
})();
}
return;
}
const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/episode/${slug}/${season}-${episode}`);
const data = { success: true, slug, type: 'tv', season, episode, ...result };
await saveToCache(cacheDir, cacheKey, data);
res.json(data);
} catch (error) {
if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden episode:', slug, 'S' + season + 'E' + episode);
else console.error('[TOPSTREAM] Episode error:', slug, error.message);
res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'épisode', details: error.message });
}
});
module.exports = router;
module.exports.configure = configure;