feat: lives PeerTube/Rumble, page Admin, TTL par provider, import/export playlists
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.
This commit is contained in:
2026-10-02 20:25:08 -04:00
parent 5f1bb78c3b
commit 326455adfe
22 changed files with 850 additions and 117 deletions
+89 -3
View File
@@ -19,6 +19,7 @@ import { applyProviderFlags, providerFlag } from './providers/feature-flags.mjs'
import { dedupeSuggestGroups } from './suggest.mjs';
import { buildDebugPayload, APPLICATION_NDJSON } from './search-transport.mjs';
import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs';
import { perProviderTtl } from './env-ttl.mjs';
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
import {
@@ -1538,7 +1539,10 @@ function detailsCacheGet(key) {
// Récence = LRU naïf (même modèle que `ytCache`).
detailsCache.delete(key);
detailsCache.set(key, hit);
if ((Date.now() - hit.ts) >= DETAILS_CACHE_TTL_MS) { detailsCache.delete(key); return null; }
// TTL par provider (`DETAILS_CACHE_TTL_MS_<P>`) puis global, cf. env-ttl.mjs.
// La clé est `${provider}:…` : le provider est le premier segment.
const ttl = perProviderTtl('DETAILS_CACHE_TTL_MS', String(key).split(':')[0], DETAILS_CACHE_TTL_MS);
if ((Date.now() - hit.ts) >= ttl) { detailsCache.delete(key); return null; }
return hit.data;
}
function detailsCacheSet(key, data) {
@@ -2855,7 +2859,9 @@ app.use('/api', r);
// Health endpoint for container checks
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
app.get(['/healthz', '/api/healthz'], async (_req, res) => {
// Trois chemins, comme le router `r` : le front appelle `/proxy/api` en prod
// (voir apiBase()), sans quoi healthz tomberait sur le fallback SPA → HTML.
app.get(['/healthz', '/api/healthz', '/proxy/api/healthz'], async (_req, res) => {
try {
const [{ getYoutubeMetricsToday, countYoutubeCacheRows, searchCacheStats, providerMetricsSnapshot, countSearchCacheRows }, common] =
await Promise.all([import('./db.mjs'), import('./providers/youtube-common.mjs')]);
@@ -3345,7 +3351,10 @@ const transcriptCache = new Map();
function transcriptCacheGet(key) {
const hit = transcriptCache.get(key);
if (!hit) return null;
if ((Date.now() - hit.ts) >= TRANSCRIPT_CACHE_TTL_MS) {
// TTL par provider (`TRANSCRIPT_CACHE_TTL_<P>`) puis global, cf. env-ttl.mjs.
// Clé `transcript:<provider>:…` : le provider est le 2e segment.
const ttl = perProviderTtl('TRANSCRIPT_CACHE_TTL', String(key).split(':')[1], TRANSCRIPT_CACHE_TTL_MS);
if ((Date.now() - hit.ts) >= ttl) {
transcriptCache.delete(key);
return null;
}
@@ -3853,6 +3862,83 @@ r.get('/playlists', authMiddlewareCookieAware, (req, res) => {
}
});
// Export JSON des playlists de l'utilisateur (+ items).
// DÉCLARÉ AVANT `/playlists/:id` : sinon « export » est avalé par le paramètre
// :id et répond 404 not_found (matching Express séquentiel).
r.get('/playlists/export', authMiddlewareCookieAware, (req, res) => {
try {
const rows = listPlaylists({ userId: req.user.id, limit: 1000, offset: 0 });
const playlists = (rows || []).map((pl) => ({
title: pl.title,
description: pl.description || undefined,
thumbnail: pl.thumbnail || undefined,
isPrivate: !!pl.isPrivate,
items: (listPlaylistItems({ playlistId: pl.id, limit: 5000, offset: 0 }) || []).map((it) => ({
provider: it.provider,
videoId: it.videoId,
title: it.title || undefined,
thumbnail: it.thumbnail || undefined,
})),
}));
return res.json({
app: 'newtube',
format: 'newtube-playlists-v1',
exportedAt: new Date().toISOString(),
playlists,
});
} catch (e) {
return res.status(500).json({ error: 'export_failed', details: String(e?.message || e) });
}
});
// Import JSON : crée les listes manquantes (même titre = déjà importée, non
// recréée — réimporter le même fichier ne duplique rien), ajoute les vidéos.
r.post('/playlists/import', authMiddlewareCookieAware, (req, res) => {
try {
const list = req.body?.playlists;
if (!Array.isArray(list)) return res.status(400).json({ error: 'playlists_required' });
if (list.length > 200) return res.status(400).json({ error: 'too_many_playlists' });
const existing = new Set(listPlaylists({ userId: req.user.id, limit: 1000, offset: 0 }).map((p) => String(p.title || '').trim()));
let created = 0, skippedLists = 0, itemsAdded = 0, itemsSkipped = 0;
for (const raw of list) {
const title = String(raw?.title || '').trim();
if (!title) { skippedLists++; continue; }
if (existing.has(title)) { skippedLists++; continue; } // déjà présente : pas de doublon au réimport
let pl;
try {
pl = createPlaylist({
userId: req.user.id,
title,
description: typeof raw?.description === 'string' ? raw.description : undefined,
thumbnail: typeof raw?.thumbnail === 'string' ? raw.thumbnail : undefined,
isPrivate: raw?.isPrivate !== false, // défaut privé (comme l'UI)
});
} catch { skippedLists++; continue; }
existing.add(title);
created++;
const items = Array.isArray(raw?.items) ? raw.items.slice(0, 5000) : [];
for (const it of items) {
const provider = String(it?.provider || '').trim();
const videoId = String(it?.videoId || '').trim();
if (!provider || !videoId) { itemsSkipped++; continue; }
const row = addPlaylistVideo({
userId: req.user.id,
playlistId: pl.id,
provider,
videoId,
title: typeof it?.title === 'string' ? it.title : undefined,
thumbnail: typeof it?.thumbnail === 'string' ? it.thumbnail : undefined,
});
if (row === 'not_found' || row === 'forbidden') itemsSkipped++;
else itemsAdded++;
}
}
return res.status(201).json({ created, skippedLists, itemsAdded, itemsSkipped });
} catch (e) {
return res.status(500).json({ error: 'import_failed', details: String(e?.message || e) });
}
});
// Get playlist details (owner only for now)
r.get('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {