CI / build-and-test (push) Successful in 14m56s
#1 Live - PeerTube: API native ?isLive=true (recherche sepiasearch + videos de chaine), mapPeertubeItem exporte la regle live (type/kind/isLive, viewers, jamais de duree), caps pt live:true, themes « En direct » branches pt/ru - Rumble: mode 'live' de scrapeRumbleList (rumble.com/browse/live, SSR), items type:live + extraction channelId (/c/<slug>), route GET /api/rumble/live, onglet chaine live filtre par channelId (plafond page 1 = 50 directs) - Odysee: N/A prouve (proxy na-backend sous jeton, probe 2026-10), caps inchangees - Tests: npm run test:live (25 assertions), assertion adapter_contract mise a jour (seuls tw/pt/ru declarent live) #2 Playlists natives - Twitch: NON FAISABLE — probe avec token app valide: GET/POST /helix/playlists et /helix/playlists/{id}/videos -> 404 (controle /helix/users -> 400). Matrice README: Twitch/Odysee/Rumble en N/A avec preuves #3 Quick wins - Page Administration /#/admin (entree sidebar Informations + i18n nav.admin FR/EN): healthz + providers/metrics rendus (statut, yt-dlp, clés actives/ bannies, anti-ban, cache par provider, compteurs 1h, quota du jour) ; healthz sert aussi sous /proxy/api/healthz - TTL par provider: server/env-ttl.mjs (<BASE>_<P> -> <BASE> -> defaut, jamais nul), branche sur les caches details et transcripts ; suggest reste global (cle agregee multi-providers) ; tests npm run test:cache - Import/Export playlists JSON: GET /playlists/export (newtube-playlists-v1, avant /playlists/:id) + POST /playlists/import (dedoublonnage par titre), boutons [Exporter] [Importer] sur la page listes de lecture README: matrice fournisseurs (Live pt/ru OK, od N/A, playlists N/A prouves), roadmap deplace en ✅ (Live, import/export, Admin, TTL) + 🟡 mis a jour.
210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
import express from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import { getProviderAdapter } from './providers/registry.mjs';
|
|
import {
|
|
normalizeRumbleId,
|
|
scrapeRumbleList,
|
|
scrapeRumbleVideo,
|
|
} from './providers/rumble.mjs';
|
|
|
|
/**
|
|
* Routes HTTP Rumble — COUCHE MINCE uniquement.
|
|
*
|
|
* Tout le scraping (fetch Cloudflare à 3 niveaux, cookie jar __cf_bm, cooldown
|
|
* global, negative cache, parsing DOM/JSON-LD, vues K/M/B) vit dans
|
|
* `server/providers/rumble.mjs`, le cœur partagé avec la recherche unifiée :
|
|
* aucune logique de fetch ni de parse dans ce fichier (l'implémentation
|
|
* dupliquée — axios + spawn python + parseur de cartes, vues « 1,2K » lues en
|
|
* 12 — a été supprimée ici). `/search` passe par le registre de providers :
|
|
* cache SQLite, negative cache et channelRef sont partagés avec `/api/search`.
|
|
*/
|
|
|
|
const router = express.Router();
|
|
|
|
/* ----------------------------- Rate limiting ----------------------------- */
|
|
// 20/min : la valeur vivante historique (l'ancien rumbleLimiter 10/min
|
|
// d'index.mjs n'était jamais monté — supprimé avec le code mort).
|
|
const rumbleLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 20,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many requests to Rumble. Please try again later.' }
|
|
});
|
|
router.use(rumbleLimiter);
|
|
|
|
/* --------------------------------- Cache -------------------------------- */
|
|
// Cache positif court : les pages Rumble changent peu et chaque miss coûte un
|
|
// fetch (+ éventuellement un spawn python). Les ÉCHECS ne sont pas cachés ici :
|
|
// c'est le negative cache + le cooldown du cœur qui les portent.
|
|
const cache = new Map();
|
|
const TTL_MS = 60 * 1000; // 60s
|
|
|
|
function cacheKey(path, params) {
|
|
return `${path}?${new URLSearchParams(params).toString()}`;
|
|
}
|
|
function setCache(key, data) {
|
|
cache.set(key, { data, expires: Date.now() + TTL_MS });
|
|
}
|
|
function getCache(key) {
|
|
const hit = cache.get(key);
|
|
if (!hit) return null;
|
|
if (Date.now() > hit.expires) { cache.delete(key); return null; }
|
|
return hit.data;
|
|
}
|
|
|
|
/**
|
|
* Contrat d'erreur : un blocage Cloudflare est un 503 typé, JAMAIS un 404
|
|
* (le 404 ferait retirer la vidéo du flux Shorts — « introuvable de façon
|
|
* certaine ») et jamais un 200 vide sans diagnostic. Les erreurs sans statut
|
|
* (bug inattendu) restent en 500 typé.
|
|
*/
|
|
function sendError(res, e) {
|
|
const status = Number(e?.status) || 500;
|
|
return res.status(status).json({ error: e?.code || 'rumble_failed' });
|
|
}
|
|
|
|
/* --------------------------------- Routes -------------------------------- */
|
|
|
|
// Tendances / classements : https://rumble.com/videos?sort=…
|
|
router.get('/browse', async (req, res) => {
|
|
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
|
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
|
|
const sort = String(req.query.sort || 'viral');
|
|
|
|
const key = cacheKey('/browse', { page, limit, sort });
|
|
const cached = getCache(key);
|
|
if (cached) return res.json(cached);
|
|
|
|
try {
|
|
const data = await scrapeRumbleList({ page, limit, sort });
|
|
setCache(key, data);
|
|
return res.json(data);
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/rumble/shorts?page=&limit=
|
|
* Flux natif des Shorts Rumble (rumble.com/shorts, vertical ≤ 90 s) :
|
|
* IDs /vXXXX propres, compatibles avec l'embed officiel /embed/vXXXX/.
|
|
*/
|
|
router.get('/shorts', async (req, res) => {
|
|
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
|
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
|
|
|
|
const key = cacheKey('/shorts', { page, limit });
|
|
const cached = getCache(key);
|
|
if (cached) return res.json(cached);
|
|
|
|
try {
|
|
const data = await scrapeRumbleList({ page, limit, mode: 'shorts' });
|
|
setCache(key, data);
|
|
return res.json(data);
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/rumble/live?page=&limit=
|
|
* Directs en cours (rumble.com/browse/live, SSR) : IDs /vXXXX propres,
|
|
* items marqués `type:'live'` par `parseCardsHtml` (mode live).
|
|
*/
|
|
router.get('/live', async (req, res) => {
|
|
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
|
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
|
|
|
|
const key = cacheKey('/live', { page, limit });
|
|
const cached = getCache(key);
|
|
if (cached) return res.json(cached);
|
|
|
|
try {
|
|
const data = await scrapeRumbleList({ page, limit, mode: 'live' });
|
|
setCache(key, data);
|
|
return res.json(data);
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
// Recherche : même adaptateur (et donc même cache SQLite, negative cache et
|
|
// channelRef) que le groupe `ru` de la recherche unifiée.
|
|
router.get('/search', async (req, res) => {
|
|
const q = String(req.query.q || '').trim();
|
|
if (!q) return res.status(400).json({ error: 'Query parameter required' });
|
|
|
|
const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || '24'), 10) || 24));
|
|
const page = (() => {
|
|
if (req.query.offset != null) {
|
|
const offset = parseInt(String(req.query.offset), 10) || 0;
|
|
return Math.floor(offset / limit) + 1;
|
|
}
|
|
return Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
|
})();
|
|
|
|
const key = cacheKey('/search', { q, page, limit });
|
|
const cached = getCache(key);
|
|
if (cached) return res.json(cached);
|
|
|
|
try {
|
|
const items = await getProviderAdapter('ru').search(q, { limit, page });
|
|
const data = {
|
|
items,
|
|
total: items.length,
|
|
page,
|
|
limit,
|
|
nextCursor: items.length === limit ? String(page + 1) : null,
|
|
};
|
|
setCache(key, data);
|
|
return res.json(data);
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
// Détails d'une vidéo (résolution d'embed fiable pour /watch et /shorts).
|
|
// Accepte :videoId pouvant être "vXXXX" OU "video/123..." — l'ancien pattern
|
|
// :videoId(*) est conservé tel quel (comportement de route inchangé).
|
|
router.get('/video/:videoId(*)', async (req, res) => {
|
|
try {
|
|
const raw = String(req.params.videoId);
|
|
const key = cacheKey('/video', { videoId: raw });
|
|
const cached = getCache(key);
|
|
if (cached) return res.json(cached);
|
|
|
|
const norm = normalizeRumbleId(raw) || { urlCanonique: `https://rumble.com/${raw}` };
|
|
const data = await scrapeRumbleVideo(norm.id || norm.urlCanonique);
|
|
setCache(key, data);
|
|
return res.json(data);
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Option : « prélecteur » sans pub (non-embed).
|
|
* On NE désactive PAS les pubs côté Rumble (pas de paramètre officiel fiable) :
|
|
* cette route renvoie juste les métadonnées du composant prélecteur
|
|
* (miniature/titre + lien d'ouverture fournisseur, iframe en dernier recours).
|
|
*/
|
|
router.get('/video/:videoId/preplay', async (req, res) => {
|
|
try {
|
|
const raw = String(req.params.videoId);
|
|
const norm = normalizeRumbleId(raw) || { urlCanonique: `https://rumble.com/${raw}` };
|
|
const data = await scrapeRumbleVideo(norm.id || norm.urlCanonique);
|
|
return res.json({
|
|
videoId: data.videoId,
|
|
title: data.title,
|
|
thumbnail: data.thumbnail,
|
|
rumbleUrl: data.url, // bouton "Ouvrir sur le site du fournisseur"
|
|
embedUrl: data.embedUrl // injection différée si l'utilisateur insiste
|
|
});
|
|
} catch (e) {
|
|
return sendError(res, e);
|
|
}
|
|
});
|
|
|
|
export default router;
|