feat: lives PeerTube/Rumble, page Admin, TTL par provider, import/export playlists
CI / build-and-test (push) Successful in 14m56s
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:
+89
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user