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:
@@ -397,18 +397,29 @@ async function ptContent(externalId, { type, page, limit, sort, q }) {
|
||||
nextPage: (data?.data || []).length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null,
|
||||
};
|
||||
}
|
||||
if (type === 'live' || type === 'shorts') return { items: [], nextPage: null };
|
||||
if (type === 'shorts') return { items: [], nextPage: null };
|
||||
// Live : l'API accepte `?isLive=true` sur les vidéos de chaîne (probe
|
||||
// 2026-10 : reconnu — isLive=false → 35 vidéos, true → 0 sur une chaîne
|
||||
// hors direct). Sans ce paramètre l'onglet Live retournait toujours [].
|
||||
const isLive = type === 'live';
|
||||
const qs = new URLSearchParams({ start: String(start), count: String(perPage), sort: sort === 'popular' ? '-views' : '-publishedAt' });
|
||||
if (isLive) qs.set('isLive', 'true');
|
||||
if (q) qs.set('search', q);
|
||||
const data = await readJson(await fetchWithTimeout(`https://${instance}/api/v1/video-channels/${encodeURIComponent(channel)}/videos?${qs}`));
|
||||
const items = (data?.data || []).map((v) => ({
|
||||
id: String(v.uuid || v.id), title: v.name, thumbnail: v?.thumbnailPath ? `https://${instance}${v.thumbnailPath}` : undefined,
|
||||
url: v?.url, uploaderName: v?.channel?.displayName || channel,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
// Phase 2.2 - idem : jamais de 0 pour une durée/vues inconnues.
|
||||
...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}),
|
||||
...(Number(v.views) > 0 ? { views: Number(v.views) } : {}), publishedAt: v.publishedAt, type: 'video',
|
||||
}));
|
||||
const items = (data?.data || []).map((v) => {
|
||||
const live = v.isLive === true;
|
||||
return {
|
||||
id: String(v.uuid || v.id), title: v.name, thumbnail: v?.thumbnailPath ? `https://${instance}${v.thumbnailPath}` : undefined,
|
||||
url: v?.url, uploaderName: v?.channel?.displayName || channel,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
// Phase 2.2 - idem : jamais de 0 pour une durée/vues inconnues.
|
||||
// Direct : durée non émise (temps écoulé), spectateurs via `viewers`.
|
||||
...(!live && Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}),
|
||||
...(Number(v.views) > 0 ? { views: Number(v.views) } : {}),
|
||||
publishedAt: v.publishedAt, type: live ? 'live' : 'video',
|
||||
...(live ? { kind: 'live', isLive: true, ...(Number(v.viewers) > 0 ? { viewers: Number(v.viewers) } : {}) } : {}),
|
||||
};
|
||||
});
|
||||
return { items, nextPage: items.length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null };
|
||||
}
|
||||
|
||||
@@ -445,20 +456,33 @@ async function odContent(externalId, { type, page, limit, sort, q }) {
|
||||
|
||||
// ---- Rumble : pas d'API publique -> recherche unifiée filtrée par chaîne ----
|
||||
async function ruContent(externalId, { type, page, limit, q }, ctx) {
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
// Predicate partagé vidéos / live : channelId (slug /c/<slug>) en priorité,
|
||||
// sinon acheminement large sur nom + chaîne + URL.
|
||||
const matchChannel = (r) => {
|
||||
if (r?.channelId && needle && String(r.channelId).toLowerCase() === needle) return true;
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
};
|
||||
if (type === 'live') {
|
||||
// Directs Rumble : la seule source publique est la page SSR
|
||||
// rumble.com/browse/live (le fetch /live de rumble.com est muré login).
|
||||
// ponytail: plafond — page 1 bornée à 50 directs ; une chaîne live mais
|
||||
// hors des 50 premières n'apparaît pas, monter la pagination si ça compte.
|
||||
const { scrapeRumbleList } = await import('./rumble.mjs');
|
||||
const list = await scrapeRumbleList({ page: 1, limit: 50, mode: 'live' });
|
||||
const items = (list?.items || []).filter(matchChannel).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
if (type !== 'videos') return { items: [], nextPage: null };
|
||||
const searchRegistry = ctx?.searchRegistry || ctx;
|
||||
const mod = searchRegistry?.ru;
|
||||
if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null };
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
// Phase 3.5 - filtre sur `channelId` en priorité : c'est l'identifiant
|
||||
// canonique extrait en phase 1.5, donc plus fiable qu'une comparaison de
|
||||
// sous-chaîne sur le nom ou l'URL.
|
||||
const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 });
|
||||
const items = (results || []).filter((r) => {
|
||||
if (r?.channelId && needle && String(r.channelId).toLowerCase() === needle) return true;
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
}).slice(0, Number(limit || 24));
|
||||
const items = (results || []).filter(matchChannel).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user