Files
NewTube/server/rumble.mjs
T
bruno 326455adfe
CI / build-and-test (push) Successful in 14m56s
feat: lives PeerTube/Rumble, page Admin, TTL par provider, import/export playlists
#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.
2026-10-02 20:25:08 -04:00

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;