feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
@@ -120,24 +120,74 @@ function ytSuggestion(item, details, channelId) {
|
||||
}
|
||||
|
||||
// L'API YouTube pagine avec des pageTokens opaques, pas des numéros de page.
|
||||
// Cache process-wide des tokens : clé requête -> tokens[page] (tokens[1] = token
|
||||
// pour charger la page 2). Sans token connu pour page > 1, on s'arrête (nextPage null)
|
||||
// au lieu de re-servir la page 1 en boucle.
|
||||
// Clé requête -> tokens[page] (tokens[1] = token pour charger la page 2). Sans
|
||||
// token connu pour page > 1, on s'arrête (nextPage null) au lieu de re-servir
|
||||
// la page 1 en boucle.
|
||||
//
|
||||
// Phase 3.12 — cache à DEUX NIVEAUX. Le L1 (Map process-local) reste pour la
|
||||
// vitesse ; le L2 (`search_cache` en base) survit au redémarrage et au scale
|
||||
// horizontal. Avant, le plafond de 500 clés faisait `clear()` et la pagination
|
||||
// repartait de la page 1 SANS SIGNE : le L2 comble ce trou, une éviction du L1
|
||||
// ne perd plus le jeton.
|
||||
const ytTokenCache = new Map();
|
||||
function ytTokenStore(key, page, nextToken) {
|
||||
const YT_TOKEN_L1_MAX = 500;
|
||||
|
||||
async function ytTokenL2Get(key) {
|
||||
try {
|
||||
const { getCachedPageTokens } = await import('../db.mjs');
|
||||
return getCachedPageTokens?.(key) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function ytTokenL2Set(key, arr) {
|
||||
try {
|
||||
const { setCachedPageTokens } = await import('../db.mjs');
|
||||
setCachedPageTokens?.(key, arr);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Écrit un jeton et propage la chaîne complète au L2 (les pages s'appuient les unes sur les autres). */
|
||||
async function ytTokenStore(key, page, nextToken) {
|
||||
if (!nextToken) return;
|
||||
let arr = ytTokenCache.get(key);
|
||||
let fromL2 = false;
|
||||
if (!arr) {
|
||||
if (ytTokenCache.size > 500) ytTokenCache.clear();
|
||||
arr = [];
|
||||
ytTokenCache.set(key, arr);
|
||||
// Page N-1 peut avoir été chargée dans un autre process : on relit le L2
|
||||
// avant d'écrire, sinon on écrase la chaîne avec un tableau à trous.
|
||||
arr = (await ytTokenL2Get(key)) || null;
|
||||
fromL2 = Array.isArray(arr);
|
||||
if (!arr) arr = [];
|
||||
}
|
||||
arr[page] = nextToken;
|
||||
if (!fromL2) {
|
||||
// Éviction LRU-lite au lieu d'un `clear()` global : on écarte la plus
|
||||
// ancienne entrée, pas tout le cache.
|
||||
if (ytTokenCache.size > YT_TOKEN_L1_MAX) {
|
||||
const oldest = ytTokenCache.keys().next().value;
|
||||
if (oldest !== undefined) ytTokenCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
ytTokenCache.set(key, arr);
|
||||
// `map`, PAS `filter` : les trous de l'indexation doivent survivre au round-trip
|
||||
// JSON. `filter(Boolean)` décalerait le jeton de la page 2 vers l'index 0 et la
|
||||
// page 3 renverrait le jeton de la page 4 — une pagination silencieusement
|
||||
// décalée. Les trous sont donc matérialisés par `''` (falsy : traité comme
|
||||
// absent à la lecture).
|
||||
await ytTokenL2Set(key, Array.from({ length: arr.length }, (_, i) => arr[i] || ''));
|
||||
}
|
||||
function ytTokenFor(key, page) {
|
||||
|
||||
async function ytTokenFor(key, page) {
|
||||
if (page <= 1) return '';
|
||||
const arr = ytTokenCache.get(key);
|
||||
return arr ? arr[page - 1] : undefined;
|
||||
const l1 = ytTokenCache.get(key);
|
||||
if (l1 && l1[page - 1]) return l1[page - 1];
|
||||
const l2 = await ytTokenL2Get(key);
|
||||
if (l2 && l2[page - 1]) {
|
||||
// Réhydratation du L1 : les pages suivantes de cette même chaîne évitent
|
||||
// alors l'accès L2.
|
||||
ytTokenCache.set(key, l2);
|
||||
return l2[page - 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
@@ -152,8 +202,8 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
// externalId brut (handle ou UC...) : le scrape gère les deux formes
|
||||
const scraped = await fetchChannelViaScrape(externalId, { type, page: pageNum, limit: perPage });
|
||||
if (Array.isArray(scraped?.items) && scraped.items.length) {
|
||||
if (mode === 'scrape-only') return { ...scraped, total: null };
|
||||
// scrape-first : retour direct si non vide
|
||||
// Phase 2.4 - branche morte supprimée : `scrape-only` et `scrape-first`
|
||||
// renvoyaient tous deux `{ ...scraped, total: null }` à l'identique.
|
||||
return { ...scraped, total: null };
|
||||
}
|
||||
// vide -> on tente l'API (chaîne à faible volume ou tab non supporté en scrape)
|
||||
@@ -169,7 +219,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
const channelId = await resolveYouTubeChannelId(externalId);
|
||||
if (type === 'playlists') {
|
||||
const key = ['pl', channelId, perPage].join('|');
|
||||
const token = ytTokenFor(key, pageNum);
|
||||
const token = await ytTokenFor(key, pageNum);
|
||||
if (token === undefined) return { items: [], nextPage: null };
|
||||
const params = { part: 'snippet,contentDetails', channelId, maxResults: String(perPage) };
|
||||
// Ne jamais envoyer pageToken=undefined (sérialisé en "undefined" -> 400).
|
||||
@@ -181,7 +231,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
console.warn('[channel-content/yt] playlists failed:', e?.message || e);
|
||||
return { items: [], nextPage: null };
|
||||
}
|
||||
ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
await ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
const items = (data?.items || []).map((pl) => ({
|
||||
id: pl?.id,
|
||||
title: pl?.snippet?.title || '',
|
||||
@@ -193,7 +243,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
}
|
||||
const order = sort === 'popular' ? 'viewCount' : sort === 'recent' ? 'date' : 'relevance';
|
||||
const key = ['search', channelId, type, order, q || '', perPage].join('|');
|
||||
const token = ytTokenFor(key, pageNum);
|
||||
const token = await ytTokenFor(key, pageNum);
|
||||
if (token === undefined) return { items: [], nextPage: null };
|
||||
const params = {
|
||||
part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order,
|
||||
@@ -204,7 +254,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
if (type === 'shorts') params.videoDuration = 'short';
|
||||
if (q) params.q = q;
|
||||
const data = await ytGet('search', params);
|
||||
ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
await ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean);
|
||||
const details = await ytVideoDetails(ids);
|
||||
let items = (data?.items || [])
|
||||
@@ -240,7 +290,11 @@ async function dmContent(externalId, { type, page, limit, sort, q }) {
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_720_url || v.thumbnail_480_url,
|
||||
url: `https://www.dailymotion.com/video/${v.id}`, uploaderName: v['owner.screenname'],
|
||||
channelId: v['owner.id'], channelExternalId: v['owner.id'] || user,
|
||||
duration: Number(v.duration || 0), views: Number(v.views_total || 0),
|
||||
// Phase 2.2 - `|| 0` transformait "inconnu" en "0 s" : la carte affichait
|
||||
// 0:00 et la règle « verticale sans durée connue => pas un short » ne
|
||||
// pouvait plus distinguer les deux cas. On n'émet le champ que s'il est > 0.
|
||||
...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}),
|
||||
...(Number(v.views_total) > 0 ? { views: Number(v.views_total) } : {}),
|
||||
publishedAt: v.created_time ? new Date(v.created_time * 1000).toISOString() : undefined, type: 'video',
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
@@ -270,7 +324,7 @@ async function twUser(externalId, headers) {
|
||||
return data?.data?.[0] || null;
|
||||
}
|
||||
|
||||
async function twContent(externalId, { type, page, limit, sort, q }) {
|
||||
async function twContent(externalId, { type, page, limit, sort, q, cursor }) {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const token = await twToken();
|
||||
if (!clientId || !token) return { items: [], nextPage: null };
|
||||
@@ -285,22 +339,44 @@ async function twContent(externalId, { type, page, limit, sort, q }) {
|
||||
const items = (data?.data || []).map((s) => ({
|
||||
id: s.id, title: s.title, thumbnail: String(s.thumbnail_url || '').replace('{width}', '1280').replace('{height}', '720'),
|
||||
url: `https://www.twitch.tv/${user.login}`, uploaderName: user.display_name,
|
||||
channelId: user.id, channelExternalId: user.id, views: Number(s.viewer_count || 0),
|
||||
publishedAt: s.started_at, type: 'video', kind: 'vod',
|
||||
channelId: user.id, channelExternalId: user.id,
|
||||
// Phase 3.2 - `viewer_count` est le nombre de spectateurs, PAS des vues.
|
||||
// Il sert a afficher « X spectateurs en direct », pas « X vues ».
|
||||
viewers: Number(s.viewer_count || 0) || undefined,
|
||||
// Phase 3.2 - anomalies #4/#5 : l'item live etait marque `type:'video'`
|
||||
// + `kind:'vod'`, donc `isLiveItem()` ne le classait jamais comme live et
|
||||
// l'onglet Live affichait une VOD. `type` doit valoir 'live' et `isLive`
|
||||
// etre pose — les deux, pour couvrir les deux règles de classification.
|
||||
isLive: true, type: 'live', kind: 'live',
|
||||
game: s.game_name || undefined, language: s.language || undefined,
|
||||
publishedAt: s.started_at, duration: undefined,
|
||||
uploaderAvatar: user.profile_image_url || undefined,
|
||||
channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`,
|
||||
}));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
if (type === 'playlists' || type === 'shorts') return { items: [], nextPage: null };
|
||||
const qs = new URLSearchParams({ user_id: user.id, first: String(perPage), type: 'archive', sort: sort === 'popular' ? 'views' : 'time' });
|
||||
// Phase 3.1 - le curseur Helix est enfin renvoyé au client au lieu d'etre jeté.
|
||||
if (cursor) qs.set('after', cursor);
|
||||
const data = await readJson(await fetchWithTimeout(`https://api.twitch.tv/helix/videos?${qs}`, { headers }));
|
||||
let items = (data?.data || []).map((v) => ({
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_url, url: v.url,
|
||||
uploaderName: user.display_name, channelId: user.id, channelExternalId: user.id,
|
||||
duration: undefined, views: Number(v.view_count || 0), publishedAt: v.created_at, type: 'video', kind: 'vod',
|
||||
// Helix `/videos` ne fournit pas la durée : absent reste absent (phase 2.2).
|
||||
duration: undefined,
|
||||
...(Number(v.view_count) > 0 ? { views: Number(v.view_count) } : {}),
|
||||
publishedAt: v.created_at, type: 'video', kind: 'vod',
|
||||
uploaderAvatar: user.profile_image_url || undefined,
|
||||
channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`,
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
const cursor = data?.pagination?.cursor;
|
||||
return { items, nextPage: cursor ? (page || 1) + 1 : null };
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); }
|
||||
const nextCursor = data?.pagination?.cursor || '';
|
||||
return {
|
||||
items,
|
||||
nextPage: nextCursor ? (page || 1) + 1 : null,
|
||||
...(nextCursor ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- PeerTube (externalId = instance|channel) ----
|
||||
@@ -329,7 +405,9 @@ async function ptContent(externalId, { type, page, limit, sort, q }) {
|
||||
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,
|
||||
duration: Number(v.duration || 0), views: Number(v.views || 0), publishedAt: v.publishedAt, type: 'video',
|
||||
// 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',
|
||||
}));
|
||||
return { items, nextPage: items.length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null };
|
||||
}
|
||||
@@ -343,15 +421,21 @@ async function odContent(externalId, { type, page, limit, sort, q }) {
|
||||
jsonrpc: '2.0', id: 1, method: 'claim_search',
|
||||
params: { channel: claim, page: Math.max(1, Number(page || 1)), page_size: perPage, claim_type: 'stream', order_by: sort === 'popular' ? ['effective_amount'] : ['release_time'] },
|
||||
};
|
||||
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', {
|
||||
// Phase 1.3 - on repasse par readJson() : le .catch(() => ({})) avalait les
|
||||
// 4xx/5xx et transformait une erreur reseau en "chaine vide" silencieuse.
|
||||
const data = await readJson(await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
})).catch((err) => { throw Object.assign(new Error(`odysee_channel_search_failed: ${err?.message || err}`), { status: 502 }); });
|
||||
let items = ((data?.result?.items) || []).map((c) => ({
|
||||
id: c.claim_id, title: c?.value?.title, thumbnail: c?.value?.thumbnail?.url,
|
||||
url: c.short_url || c.canonical_url, uploaderName: c?.signing_channel?.value?.title || claim,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
duration: Number(c?.value?.video?.duration || 0), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined,
|
||||
...(Number(c?.value?.video?.duration) > 0 ? { duration: Math.round(Number(c.value.video.duration)) } : {}), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined,
|
||||
// Phase 1.1 - claim_search expose `value.video.view_count` quand la colonne est
|
||||
// demandee. L'`effective_amount` reste un montant LBC : jamais un compteur.
|
||||
...(Number.isFinite(Number(c?.value?.video?.view_count)) && Number(c?.value?.video?.view_count) >= 0
|
||||
? { views: Math.round(Number(c.value.video.view_count)) }
|
||||
: {}),
|
||||
type: 'video', slug: (c.short_url || '').replace('https://odysee.com/', ''),
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); }
|
||||
@@ -360,30 +444,44 @@ 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 }, searchRegistry) {
|
||||
async function ruContent(externalId, { type, page, limit, q }, ctx) {
|
||||
if (type !== 'videos') return { items: [], nextPage: null };
|
||||
try {
|
||||
const mod = searchRegistry?.ru;
|
||||
if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null };
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 });
|
||||
const items = (results || []).filter((r) => {
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
}).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
} catch { 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));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 8.1 — chaque provider est une extension, pas une entrée de switch :
|
||||
* `channelContentByProvider` indexe les collecteurs par id. `fetchChannelContent`
|
||||
* reste une façade (appelée par la route) mais n'EST PLUS la source : elle
|
||||
* délègue à la table, la même que celle que l'adaptateur unifié (`providerAdapters`)
|
||||
* expose comme `channelContent`. Un provider de plus = une entrée de table, pas
|
||||
* un case de plus.
|
||||
*/
|
||||
export const channelContentByProvider = {
|
||||
yt: ytContent,
|
||||
dm: dmContent,
|
||||
tw: twContent,
|
||||
pt: ptContent,
|
||||
od: odContent,
|
||||
ru: ruContent,
|
||||
};
|
||||
|
||||
export async function fetchChannelContent(provider, externalId, opts = {}, ctx = {}) {
|
||||
const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '' } = opts || {};
|
||||
switch (provider) {
|
||||
case 'yt': return ytContent(externalId, { type, page, limit, sort, q });
|
||||
case 'dm': return dmContent(externalId, { type, page, limit, sort, q });
|
||||
case 'tw': return twContent(externalId, { type, page, limit, sort, q });
|
||||
case 'pt': return ptContent(externalId, { type, page, limit, sort, q });
|
||||
case 'od': return odContent(externalId, { type, page, limit, sort, q });
|
||||
case 'ru': return ruContent(externalId, { type, page, limit, q }, ctx.searchRegistry);
|
||||
default: throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||||
}
|
||||
const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '', cursor = '' } = opts || {};
|
||||
const worker = channelContentByProvider[/** @type {keyof typeof channelContentByProvider} */ (provider)];
|
||||
if (!worker) throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||||
return worker(externalId, { type, page, limit, sort, q, cursor }, ctx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user