feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+149 -51
View File
@@ -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);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Phase 6 — identité de chaîne normalisée (`channelRef`).
*
* AVANT, trois conventions d'identifiant de chaîne cohabitaient, chacune
* implicite, codée à 4 endroits différents :
*
* 1. YouTube : `UC…` (dans `channelExternalId`)
* 2. Dailymotion: identifiant numérique d'utilisateur (`owner.id`)
* 3. Twitch : `login`
* 4. PeerTube : `instance|channel` (construit par le FRONT depuis l'URL vidéo)
* 5. Odysee : claim LBRY brut, avec ou sans `@` selon la source
* 6. Rumble : slug `/c/<slug>`
*
* Aucun de ces formats ne disait « ceci est un identifiant de chaîne » : la même
* chaîne pouvait être stockée sous deux formes (`yt` et `@MaChaine#a` pour Odysee,
* `channel` seul et `instance|channel` pour PeerTube) et rien ne les réconciliait.
*
* `channelRef` rend l'imbrication explicite : le `scheme` DIT comment lire le
* `value`. Le but n'est pas de supprimer les champs legacy (transition non
* cassante, cf. phase 6.2) mais d'avoir une forme unique, sans perte et
* réversible.
*
* SOURCE UNIQUE côté serveur. Le miroir TypeScript est
* `src/app/shared/providers/channel-ref.ts`, et `npm run test:channelref`
* vérifie que les deux listes de schemes ne divergent pas.
*/
/** Schemes par provider (id court). L'ordre suit `ALL_PROVIDER_IDS`. */
export const CHANNEL_REF_SCHEMES = Object.freeze({
yt: 'yt-uc',
dm: 'dm-user',
tw: 'tw-login',
pt: 'pt-composite',
od: 'od-claim',
ru: 'ru-slug',
});
/**
* Normalise un claim LBRY (Odysee) : un seul `@` en tête.
*
* Odysee est le seul provider où la même chaîne arrivait avec ET sans le `@`
* selon le chemin (`channel` dans la réponse `claim_search`, `short_url` dans
* la résolution, `externalId` en base) — d'où les deux normalisations
* divergentes (`startsWith('@') ? … : '@'+…` au POST de resolve,
* `replace(/^@/,'')` pour l'URL). La forme canonique porte le `@`.
*
* @param {unknown} value
* @returns {string|undefined} claim canonique, ou undefined si vide
*/
export function normalizeOdyseeClaim(value) {
const raw = String(value ?? '').trim();
if (!raw) return undefined;
return raw.startsWith('@') ? raw : `@${raw}`;
}
/** Un claim LBRY pour une URL publique : `https://odysee.com/@x` -> `x`. */
export function odyseeClaimToSlug(value) {
const claim = normalizeOdyseeClaim(value);
return claim ? claim.slice(1) : undefined;
}
/** Un claim LBRY pour l'appel `resolve`, qui exige le `@`. */
export function odyseeClaimToResolveArg(value) {
return normalizeOdyseeClaim(value);
}
/**
* Découpe un identifiant PeerTube composite `instance|channel`.
* Rétrocompatible avec l'ancien cas « channel seul » (instance inconnue).
* @param {unknown} value
*/
export function parsePeerTubeComposite(value) {
const raw = String(value ?? '').trim();
if (!raw) return { instance: undefined, channel: undefined };
const sep = raw.indexOf('|');
if (sep < 0) return { instance: undefined, channel: raw };
const instance = raw.slice(0, sep) || undefined;
const channel = raw.slice(sep + 1) || undefined;
return { instance, channel };
}
/**
* Construit `instance|channel` à partir de l'URL vidéo et du channelId.
*
* L'instance N'EST PAS optionnelle : sans elle, `parsePeerTubeComposite`
* renverrait `{instance: undefined}` et `fetchPeerTubeChannel` construirait
* `https://<channel>` — une URL fausse. On renvoie donc `undefined` plutôt
* qu'un composite bancal. C'est aussi ce que faisait le front (`pt.ts` exigeait
* `host && channelId`), et le critère d'acceptation 8.2 impose la parité.
*/
export function buildPeerTubeComposite(url, channelId) {
const channel = String(channelId ?? '').trim();
if (!channel) return undefined;
let host = '';
try {
if (url) host = new URL(String(url)).hostname || '';
} catch {}
return host ? `${host}|${channel}` : undefined;
}
const firstString = (...values) => {
for (const v of values) {
const s = String(v ?? '').trim();
if (s) return s;
}
return undefined;
};
/**
* Construit le `channelRef` d'une Suggestion, à partir des champs **legacy**
* déjà présents. N'invente jamais de donnée : si aucun identifiant n'est
* disponible, renvoie `undefined` (jamais un objet à moitié rempli, jamais de
* valeur inventée).
*
* Chaque règle reproduit à l'identique ce que le front calcule aujourd'hui dans
* ses 6 adaptateurs — c'est le critère d'acceptation 8.2.
*
* @param {string} providerId id court ('yt', 'dm', …)
* @param {object} item Suggestion
* @returns {{provider: string, scheme: string, value: string}|undefined}
*/
export function buildChannelRef(providerId, item) {
const pid = String(providerId ?? '').trim().toLowerCase();
const scheme = CHANNEL_REF_SCHEMES[pid];
if (!scheme || !item || typeof item !== 'object') return undefined;
let value;
switch (pid) {
case 'yt':
// `yt.ts:32` : channelExternalId || channelId
value = firstString(item.channelExternalId, item.channelId);
break;
case 'dm':
// `dm.ts:30` : channelId (l'API ne donne que `owner.id`)
value = firstString(item.channelId, item.channelExternalId);
break;
case 'tw':
// `tw.ts:24` : channelExternalId || channelHandle (le login)
value = firstString(item.channelExternalId, item.channelHandle);
break;
case 'pt':
// `pt.ts:25-27` : hostname(url) + '|' + channelId
value = buildPeerTubeComposite(item.url, item.channelId);
break;
case 'od':
// `od.ts:40` : le claim brut, ici canonique (un seul `@`).
value = normalizeOdyseeClaim(firstString(item.channel, item.uploaderName, item.channelHandle));
break;
case 'ru':
// Phase 3.5 : slug extrait de `/c/<slug>` par l'adaptateur rumble.
value = firstString(item.channelExternalId, item.channelId);
break;
default:
value = undefined;
}
if (!value) return undefined;
return { provider: pid, scheme, value };
}
/**
* Annote une liste de Suggestions avec leur `channelRef`.
* Mutates les objets d'origine : c'est le même contrat que `type`/`isShort`,
* ajoutés par les adaptateurs. Les objets sans identité restent inchangés
* (pas de clé `channelRef` à `undefined`).
*
* @template T
* @param {string} providerId
* @param {T[]} items
* @returns {T[]}
*/
export function withChannelRefs(providerId, items) {
if (!Array.isArray(items)) return items;
for (const item of items) {
const ref = buildChannelRef(providerId, item);
if (ref) item.channelRef = ref;
}
return items;
}
+120 -20
View File
@@ -1,3 +1,9 @@
import {
parsePeerTubeComposite,
odyseeClaimToResolveArg,
odyseeClaimToSlug,
} from './channel-ref.mjs';
const DEFAULT_TIMEOUT_MS = Number(process.env.CHANNEL_FETCH_TIMEOUT_MS || 6000);
// Fournisseur de token Twitch branché par server/index.mjs (qui gère le cache
@@ -32,6 +38,29 @@ async function fetchWithTimeout(url, options = {}) {
}
}
/** N'accepte qu'une URL http(s) absolue — bloque `javascript:` et `data:`. */
function sanitizeImageUrl(raw) {
if (typeof raw !== 'string') return undefined;
const url = raw.trim();
if (!/^https?:\/\//i.test(url)) return undefined;
return url;
}
/**
* Phase 7.7 — descriptions : texte brut, souvent multi-lignes et souvent
* énorme (YouTube renvoie plusieurs kilo-octets). On normalise les espaces et on
* plafonne : une description de 10 Ko dans une page chaîne saccade le rendu et
* écrase le contenu. Tronquée = information partielle mais honnête ; l'absence
* de plafond = page cassée.
*/
const DESCRIPTION_MAX = 600;
function cleanDescription(raw) {
if (typeof raw !== 'string') return undefined;
const text = raw.replace(/\s+/g, ' ').trim();
if (!text) return undefined;
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…` : text;
}
function safeMeta(meta = {}, fallback = {}) {
return {
provider: fallback.provider,
@@ -39,6 +68,10 @@ function safeMeta(meta = {}, fallback = {}) {
title: meta.title ?? fallback.title,
handle: meta.handle ?? fallback.handle,
avatarUrl: meta.avatarUrl ?? fallback.avatarUrl,
// Phase 7.7 : bannière. URL seulement — un `javascript:` glissé dans une
// bannière deviendrait un vecteur XSS au moment de l'afficher.
bannerUrl: sanitizeImageUrl(meta.bannerUrl) ?? sanitizeImageUrl(fallback.bannerUrl),
description: cleanDescription(meta.description) ?? cleanDescription(fallback.description),
url: meta.url ?? fallback.url,
subsCount: typeof meta.subsCount === 'number' ? meta.subsCount : fallback.subsCount,
verified: typeof meta.verified === 'boolean'
@@ -69,16 +102,19 @@ async function fetchYoutubeChannel(externalId) {
const url = branding.channel?.customUrl
? `https://www.youtube.com/${branding.channel.customUrl}`
: `https://www.youtube.com/channel/${externalId}`;
return {
provider: 'yt',
externalId,
return safeMeta({
title: snippet.title || branding.channel?.title,
handle: snippet.customUrl ? `@${snippet.customUrl.replace(/^@/, '')}` : undefined,
avatarUrl: avatars,
// Phase 7.7 — `brandingSettings` est DÉJÀ demandé (pour l'URL), il contient
// aussi la bannière : aucun appel réseau supplémentaire.
bannerUrl: branding.image?.bannerImageUrl
|| (Array.isArray(branding.image?.thumbnails) && branding.image.thumbnails.at(-1)?.url),
description: snippet.description,
url,
subsCount: stats.subscriberCount ? Number(stats.subscriberCount) : undefined,
verified: Array.isArray(snippet.badges) ? snippet.badges.includes('verified') : undefined,
};
}, { provider: 'yt', externalId, url });
} catch {
return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` };
}
@@ -89,7 +125,7 @@ async function fetchDailymotionChannel(externalId) {
// NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur
// toute la requête) — seuls avatar_720_url / avatar_medium_url le sont.
const params = new URLSearchParams({
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified',
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,cover_url,description,url,followers_total,verified',
});
const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`);
const username = data.username || externalId;
@@ -97,6 +133,9 @@ async function fetchDailymotionChannel(externalId) {
title: data.screenname || data.username,
handle: data.username ? `@${data.username}` : undefined,
avatarUrl: data.avatar_720_url || data.avatar_medium_url,
// Phase 7.7 — `cover_url` est la bannière (l'API n'a pas de champ « banner »).
bannerUrl: data.cover_url,
description: data.description,
url: `https://www.dailymotion.com/user/${username}`,
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
verified: Boolean(data.verified),
@@ -129,6 +168,11 @@ async function fetchTwitchChannel(externalId) {
title: user.display_name,
handle: `@${user.login}`,
avatarUrl: user.profile_image_url,
// Phase 7.7 — Helix les expose dans le même `/users` : rien à demander
// de plus. `banner_image_url` est absent tant que l'utilisateur n'a pas
// de bannière : on ne fabrique pas d'URL de substitution.
bannerUrl: user.banner_image_url,
description: user.description,
url: `https://www.twitch.tv/${user.login}`,
subsCount: typeof user.view_count === 'number' ? user.view_count : undefined,
}, { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` });
@@ -138,11 +182,11 @@ async function fetchTwitchChannel(externalId) {
return { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` };
}
function parsePeerTubeExternalId(externalId) {
const [instance, channel] = String(externalId).split('|');
if (!channel) return { instance: null, channel: externalId };
return { instance, channel };
}
/**
* Phase 6.4 — délègue à la source unique (`channel-ref.mjs`) au lieu de
* redécouper `instance|channel` localement.
*/
const parsePeerTubeExternalId = parsePeerTubeComposite;
async function fetchPeerTubeChannel(externalId) {
const { instance, channel } = parsePeerTubeExternalId(externalId);
@@ -155,6 +199,11 @@ async function fetchPeerTubeChannel(externalId) {
title: data.displayName || data.name,
handle: data.host ? `@${data.name}@${data.host}` : undefined,
avatarUrl: data?.avatar?.path ? `https://${instance}${data.avatar.path}` : undefined,
// Phase 7.7 — l'API vidéo-channels expose déjà `banners` et `description`.
bannerUrl: (Array.isArray(data?.banners) && data.banners.at(-1)?.path)
? `https://${instance}${data.banners.at(-1).path}`
: (data?.banner?.path ? `https://${instance}${data.banner.path}` : undefined),
description: data.description,
url: data?.url || `https://${instance}/video-channels/${channel}`,
subsCount: typeof data.followersCount === 'number' ? data.followersCount : undefined,
verified: Boolean(data.ownerAccount?.verified)
@@ -164,12 +213,32 @@ async function fetchPeerTubeChannel(externalId) {
}
}
/**
* Phase 7.7 — plus grande vignette LBRY disponible.
* Le CDN Odysee sert la même image à plusieurs tailles via un paramètre `?size=` ;
* on élargit donc la vignette de la chaîne, ce qui reste une VRAIE bannière.
* À défaut de paramètre de taille, on rend l'URL telle quelle.
*/
function odyseeBannerUrl(value) {
const base = value?.thumbnail?.url || value?.thumbnail;
if (typeof base !== 'string' || !/^https?:\/\//i.test(base)) return undefined;
if (/[?&]size=/i.test(base)) return base;
return `${base}${base.includes('?') ? '&' : '?'}size=1200x600`;
}
async function fetchOdyseeChannel(externalId) {
try {
// Phase 6.4 : la normalisation du claim passe par la source unique
// (`channel-ref.mjs`). Avant, deux règles cohabitaient dans CE fichier
// (`startsWith('@') ? … : '@'+…` ici, `replace(/^@/,'')` pour l'URL 20 lignes
// plus bas) et une troisième vivait côté front — la forme canonique pouvait
// donc être `@x` ici et `x` là-bas.
const claim = odyseeClaimToResolveArg(externalId);
if (!claim) return { provider: 'od', externalId, url: 'https://odysee.com/' };
const body = {
jsonrpc: '2.0',
method: 'resolve',
params: { urls: [externalId.startsWith('@') ? externalId : `@${externalId}`] },
params: { urls: [claim] },
id: 1
};
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', {
@@ -188,12 +257,19 @@ async function fetchOdyseeChannel(externalId) {
title: value?.title,
handle: meta.short_url ? meta.short_url.replace('https://odysee.com/', '') : undefined,
avatarUrl: meta?.thumbnail?.url,
// Phase 7.7 — LBRY n'a pas de « bannière » distincte : la vignette du
// claim EST l'image de la chaîne. On demande la plus grande taille
// servie par le CDN plutôt que d'inventer une URL qui n'existerait pas.
bannerUrl: odyseeBannerUrl(value),
description: value?.description || value?.tagged_description || meta?.description,
url: meta.short_url,
subsCount: typeof meta?.meta?.effective_amount === 'number' ? meta.meta.effective_amount : undefined,
}, { provider: 'od', externalId, url: meta.short_url });
}
} catch {}
return { provider: 'od', externalId, url: `https://odysee.com/${externalId.replace(/^@/, '')}` };
// L'URL publique ne porte pas le `@` — d'où la conversion explicite plutôt
// qu'un `replace` local.
return { provider: 'od', externalId, url: `https://odysee.com/${odyseeClaimToSlug(externalId) || ''}` };
}
async function fetchRumbleChannel(externalId) {
@@ -203,10 +279,19 @@ async function fetchRumbleChannel(externalId) {
});
const titleMatch = /<title>([^<]+)<\/title>/i.exec(data);
const avatarMatch = /property="og:image" content="([^"]+)"/i.exec(data);
// Phase 7.7 — `og:description` est déjà dans la page : aucun appel en plus.
// `content=` en PREMIER attribut, comme pour og:image : sur Rumble l'ordre
// n'est pas garanti et une regex trop rigide ne renvoie jamais rien.
const descMatch = /<meta[^>]+property="og:description"[^>]+content="([^"]*)"/i.exec(data)
|| /<meta[^>]+content="([^"]*)"[^>]+property="og:description"/i.exec(data);
const name = titleMatch ? titleMatch[1].replace(/ on Rumble.*$/i, '').trim() : undefined;
return safeMeta({
title: name,
avatarUrl: avatarMatch ? avatarMatch[1] : undefined,
// Pas de bannière dédiée côté Rumble : `og:image` est déjà la meilleure
// image disponible, mais le doublonner avec l'avatar n'apporte rien —
// on laisse donc `bannerUrl` vide et l'UI masquera la bandeau.
description: descMatch ? descMatch[1] : undefined,
url: `https://rumble.com/${externalId}`,
}, { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` });
} catch {
@@ -214,17 +299,32 @@ async function fetchRumbleChannel(externalId) {
}
}
export const channelRegistry = {
yt: { fetchChannelById: fetchYoutubeChannel },
dm: { fetchChannelById: fetchDailymotionChannel },
tw: { fetchChannelById: fetchTwitchChannel },
pt: { fetchChannelById: fetchPeerTubeChannel },
od: { fetchChannelById: fetchOdyseeChannel },
ru: { fetchChannelById: fetchRumbleChannel },
/**
* Phase 8.1 — la table par provider devient la SOURCE : `channelMetaByProvider`
* indexe les collecteurs de métadonnées par id. `channelRegistry` et
* `getChannelAdapter` restent exportés pour compatibilité (tests, historique),
* mais ne font qu'aliasser cette table — l'adaptateur unifié (`providerAdapters`)
* consomme `channelMetaByProvider` et n'a pas besoin de savoir qu'une autre
* table a existé.
*/
export const channelMetaByProvider = {
yt: fetchYoutubeChannel,
dm: fetchDailymotionChannel,
tw: fetchTwitchChannel,
pt: fetchPeerTubeChannel,
od: fetchOdyseeChannel,
ru: fetchRumbleChannel,
};
// Compatibilité phase 0/… : `channelRegistry.yt.fetchChannelById(...)` a été
// utilisé par des tests et l'historique. On le DÉRIVE de la table source plutôt
// que de le dupliquer.
export const channelRegistry = Object.fromEntries(
Object.entries(channelMetaByProvider).map(([provider, fetchMeta]) => [provider, { fetchChannelById: fetchMeta }]),
);
export function getChannelAdapter(provider) {
return channelRegistry[provider];
return channelMetaByProvider[provider];
}
export default channelRegistry;
+97
View File
@@ -0,0 +1,97 @@
/**
* Phase 8.3 — Feature flags par provider (`FF_<PROVIDER>`).
*
* Motivation : certain upstreams sont fragiles hors de notre contrôle. Rumble
* est derrière Cloudflare et peut casser du jour au lendemain ; Odysee dépend
* d'un backend NA. Désactiver un provider ne doit pas exiger un redéploiement
* ni un revert de code.
*
* Trois états, pas deux — c'est la distinction qui compte en exploitation :
* - unset : le provider est actif (comportement historique) ;
* - truthy : actif ;
* - falsy : DÉSACTIVÉ, exclu du fan-out et signalé `errors.<id> = disabled_by_ff`.
*
* Un flag falsy **signale** au lieu de disparaître en silence : une colonne vide
* sans explication ressemble à « aucune correspondance », ce qui envoie l'utilisateur
* (et le support) chercher au mauvais endroit.
*
* Convention de nommage : `FF_YT`, `FF_DM`, `FF_TW`, `FF_PT`, `FF_OD`, `FF_RU`
* (ids courts, cf. `provider-ids.ts`).
*/
/** Valeurs explicitement fausses. Tout le reste est vrai (unset inclus). */
const FALSY = new Set(['0', 'false', 'off', 'no', 'disabled']);
/**
* Lit un flag depuis l'environnement, sans cache : le lire à chaque appel permet
* de changer une variable sans redémarrer (utile en local, et ça évite un cache
* qui masquerait un changement en CI).
*
* @param {string} key
* @returns {{ set: boolean, enabled: boolean, raw: string|null }}
*/
function readFlag(key) {
const raw = process.env[key];
if (raw === undefined || raw === null || String(raw).trim() === '') {
// Unset = actif, et on le dit explicitement pour ne pas confondre
// « pas configuré » et « désactivé » dans les logs.
return { set: false, enabled: true, raw: null };
}
return { set: true, enabled: !FALSY.has(String(raw).trim().toLowerCase()), raw: String(raw) };
}
/**
* État d'un provider au regard des feature flags.
* @param {string} providerId id court ('yt', 'ru', …)
*/
export function providerFlag(providerId) {
const id = String(providerId || '').trim().toLowerCase();
const key = `FF_${id.toUpperCase()}`;
const { set, enabled, raw } = readFlag(key);
return { provider: id, flag: key, set, enabled, raw };
}
/** `true` si le provider est désactivé par un feature flag. */
export function isProviderDisabled(providerId) {
return !providerFlag(providerId).enabled;
}
/**
* Filtre une liste de providers, en retirant les désactivés.
*
* @template {string} T
* @param {T[]} providerIds
* @returns {{ enabled: T[], disabled: Array<{provider: string, flag: string}> }}
*/
export function partitionEnabledProviders(providerIds) {
const list = Array.isArray(providerIds) ? providerIds : [];
const enabled = [];
const disabled = [];
for (const id of list) {
const flag = providerFlag(id);
if (flag.enabled) enabled.push(id);
else disabled.push({ provider: flag.provider, flag: flag.flag });
}
return { enabled, disabled };
}
/**
* Applique les flags à une liste déjà validée, et renvoie les `errors` à fusionner
* dans la réponse de fan-out. Utilisé par `/api/search` ET `/api/suggest` pour
* qu'un provider désactivé se comporte pareil partout.
*
* @template {string} T
* @param {T[]} providerIds
* @returns {{ providerIds: T[], errors: Record<string, {message: string, code: string}> }}
*/
export function applyProviderFlags(providerIds) {
const { enabled, disabled } = partitionEnabledProviders(providerIds);
const errors = {};
for (const d of disabled) {
errors[d.provider] = {
message: `Provider désactivé par le feature flag ${d.flag}`,
code: 'disabled_by_ff',
};
}
return { providerIds: enabled, errors };
}
+13 -1
View File
@@ -18,7 +18,7 @@ const handler = {
s: q,
size: perPage.toString(),
from: ((pageNum - 1) * perPage).toString(),
include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name',
include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name,video',
mediaType: 'video'
});
@@ -44,6 +44,14 @@ const handler = {
// lighthouse les expose — lecture défensive, aucun appel en plus.
const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width);
const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height);
// Phase 1.2 - `release_time` est un timestamp unix (secondes) et etait
// deja demande dans `include` mais jamais mappe : `publishedAt` etait donc
// TOUJOURS undefined, ce qui rendait le filtre `period=` inoperant sur Odysee.
const release = Number(item.release_time);
// Phase 1.1 - `video.view_count` remonte par lighthouse quand `video`
// est inclus. L'`effective_amount` est un montant LBC (bid), PAS un
// nombre de vues : on ne l'utilise jamais comme compteur.
const views = Number(item.video?.view_count ?? item.video?.views);
return {
title: item.title || name,
@@ -55,6 +63,10 @@ const handler = {
duration: typeof item.duration === 'number' && item.duration > 0
? Math.round(item.duration)
: (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined),
...(Number.isFinite(release) && release > 0
? { publishedAt: new Date(release * 1000).toISOString() }
: {}),
...(Number.isFinite(views) && views >= 0 ? { views: Math.round(views) } : {}),
...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}),
...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}),
};
+27
View File
@@ -58,8 +58,35 @@ const handler = {
url: item.url,
thumbnail,
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
// Phase 1.8 - les avatars de compte ET de chaine sont exposes par
// l'API sepiasearch mais n'etaient jamais mappes : la page chaine
// affichait un avatar generique sur toutes les chaines PeerTube.
uploaderAvatar: item.account?.avatars?.[0]?.path || item.account?.avatarUrl || undefined,
channelAvatarUrl: item.channel?.avatars?.[0]?.path || item.channel?.avatarUrl || undefined,
channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined,
// Phase 1.7 - champs deja presents dans la reponse, jamais mappes.
// `views` est le nom canonique du contrat (les 5 autres adaptateurs
// l'emettaient) ; `viewCount` reste en alias pour ne pas casser un
// consommateur existant. Trouve en gelant la sortie reelle (phase 8.4) :
// le front lisait `views`, le serveur n'envoyait que `viewCount`, et les
// compteurs PeerTube n'atteignaient donc jamais l'UI.
views: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined,
viewCount: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined,
likes: Number.isFinite(Number(item.likes)) ? Number(item.likes) : undefined,
publishedAt: item.publishedAt || item.createdAt || undefined,
// L'API PeerTube renvoie `language` comme OBJET `{ id, label }` (et non
// `{ code }` comme le laissait croire l'ancienne garde) : sans ce
// tri, l'objet entier partait dans le contrat, où `language` est
// déclaré `string`. Le front recevait `{id: null, label: 'Unknown'}`
// au lieu d'un code ISO. Vu en gelant la sortie réelle (phase 8.4).
// `label` est volontairement exclu : c'est une étiquette lisible
// ("Unknown") et non un code — la publier produirait une langue
// inventée, ce que la règle « absent = undefined » interdit.
language: typeof item.language === 'string' ? item.language
: (item.language?.code || item.language?.id || undefined),
hasSubtitles: Array.isArray(item.subtitleFiles) ? item.subtitleFiles.length > 0 : undefined,
type: 'video',
kind: 'vod',
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined,
...(fw > 0 ? { width: fw } : {}),
...(fh > 0 ? { height: fh } : {}),
+276 -7
View File
@@ -5,7 +5,7 @@
* @typedef {Object} Suggestion
* @property {string} title
* @property {string} id
* @property {number=} duration
* @property {number=} duration (secondes ; > 0. NE JAMAIS 0 : absence = undefined)
* @property {boolean=} isShort
* @property {string=} url
* @property {string=} thumbnail
@@ -13,21 +13,103 @@
* @property {string=} type
* @property {number=} width (largeur vidéo en px, quand le provider l'expose)
* @property {number=} height (hauteur vidéo en px, quand le provider l'expose)
*
* ── v2 (additif, phase 0 §2.4) ────────────────────────────────────────────────
* Tous les champs ci-dessous sont OPTIONNELS et absents quand le provider ne les
* fournit pas. `test:contract` (`server/tests/provider-contract.test.mjs`) les
* vérifie à chaque build : c'est ce qui empêche le retour des champs «erts puis
* perdus » (anomalies #3, #7).
* Règle non négociable : une métadonnée absente reste `undefined`. Jamais de 0,
* jamais de chaîne vide, jamais de date/devinette.
*
* @property {number=} views compteur de vues (> 0)
* @property {number=} likes compteur de likes
* @property {number=} dislikes
* @property {string=} publishedAt ISO 8601 — jamais un libellé relatif
* @property {string=} channelId id de chaîne chez le provider
* @property {string=} channelExternalId
* @property {string=} channelHandle
* @property {string=} channelUrl
* @property {string=} uploaderAvatar avatar de l'auteur
* @property {string=} channelAvatarUrl
* @property {string=} slug
* @property {string=} channel
* @property {string=} kind 'vod' | 'live' | 'clip' (Twitch)
* @property {string=} game catégorie / jeu
* @property {string=} language BCP-47
* @property {boolean=} hasSubtitles
* @property {boolean=} isLive
* @property {boolean=} embeddable
* @property {string=} description
* @property {string[]} tags
* @property {string=} durationRaw libellé brut, pour l'affichage d'incertitude
* @property {string=} viewCountRaw
* @property {{provider: ProviderId, scheme: string, value: string}=} channelRef
* Phase 6 — identité de chaîne normalisée. Redondant avec
* `channelExternalId` (transition non cassante) mais EXPLICITE : le
* `scheme` dit comment lire `value` ('yt-uc' = `UC…`, 'pt-composite' =
* `instance|channel`, 'od-claim' = claim LBRY…). Voir `channel-ref.mjs`.
*/
/** Version du contrat `Suggestion` émise par `/api/search`. */
export const SUGGESTION_CONTRACT_VERSION = 2;
/** Champs optionnels introduits en v2 (pour le test de contrat et le debug ?v=2). */
export const SUGGESTION_V2_FIELDS = Object.freeze([
'views', 'viewers', 'likes', 'dislikes', 'publishedAt', 'channelId', 'channelExternalId',
'channelHandle', 'channelUrl', 'uploaderAvatar', 'channelAvatarUrl', 'slug',
'channel', 'kind', 'game', 'language', 'hasSubtitles', 'isLive', 'embeddable',
'description', 'tags', 'durationRaw', 'viewCountRaw', 'channelRef',
// Phase 7.3 : provenance. `capturedAt` (epoch ms, instant réel de la collecte)
// et `source` (`innertube` / `scrape` / `api` / `cache`) permettent d'afficher
// « capturé il y a 4 min » au lieu d'un « à l'instant » trompeur sur un cache hit.
'capturedAt', 'source',
]);
/** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */
import channelRegistry from './channel-registry.mjs';
import { channelMetaByProvider } from './channel-registry.mjs';
import { channelContentByProvider } from './channel-content.mjs';
import { withChannelRefs } from './channel-ref.mjs';
import { hashSearchKey } from './youtube-common.mjs';
/**
* Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`.
*
* Chaque provider est une extension, pas un câblage : l'objet exporté satisfait
* intégralement cette forme (les membres optionnels absents sont simplement
* `undefined`, mais la CLÉ existe), et tout consommateur (routes, wrappers,
* tests de contrat) passe par `providerAdapters` — jamais par des tables
* parallèles (`channelRegistry`, `channelContentByProvider`) qui restent des
* implémentations internes.
*
* @typedef {Object} ProviderAdapter
* @property {ProviderId} id
* @property {string} label
* @property {(q: string, opts: { limit: number, page?: number, sort?: string }) => Promise<Suggestion[]>} search
* @property {(q: string, opts: { limit: number, page?: number, sort?: string, filters?: Object }) => Promise<Suggestion[]>} search
* @property {(q: string, opts?: { limit?: number }) => Promise<string[]>} [suggest]
* @property {(externalId: string, ctx?: any) => Promise<any=} } [fetchChannelById]
* @property {(externalId: string, opts?: Object, ctx?: Object) => Promise<{ items: Suggestion[], nextPage?: number|null, total?: number|null, nextCursor?: string }>} channelContent
* @property {(externalId: string, ctx?: Object) => Promise<Object>} channelMeta
* @property {Object} capabilities
*/
/**
* Phase 8.1 — capacités déclarées par provider (source serveur du contrat).
* Vérité comportementale : ce que le collecteur fait réellement (types de
* contenu gérés, suggestion native, direct). Vérifié par le test de contrat.
*/
export const PROVIDER_CAPABILITIES = {
yt: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'shorts', 'playlists', 'live'] },
dm: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] },
tw: { suggest: false, live: true, channelMeta: true, channelContent: ['videos', 'live'] },
pt: { suggest: false, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] },
od: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] },
ru: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] },
};
/** Ordre canonique des providers (un unique endroit, hors route). */
export const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
/** @type {Record<ProviderId, ProviderAdapter>} */
export const providerRegistry = {
/** @type {any} */ yt: (await import('./youtube.mjs')).default,
@@ -39,10 +121,197 @@ export const providerRegistry = {
};
for (const [pid, adapter] of Object.entries(providerRegistry)) {
const channelAdapter = channelRegistry[/** @type {ProviderId} */(pid)];
if (channelAdapter && typeof channelAdapter.fetchChannelById === 'function' && !adapter.fetchChannelById) {
adapter.fetchChannelById = channelAdapter.fetchChannelById;
const meta = channelMetaByProvider[/** @type {ProviderId} */(pid)];
if (meta && !adapter.fetchChannelById) adapter.fetchChannelById = meta;
}
/**
* Phase 4.1 — cache de recherche générique, appliqué ici plutôt que dans chaque
* adaptateur : un seul point d'entrée, donc aucun fournisseur ne peut l'oublier
* (c'est exactement le problème des tables dupliquées que la phase 0 a supprimé).
*
* YouTube est **exclu** : `youtube.mjs` a son propre cache à deux niveaux
* (memoire + SQLite) et l'annoit en `source` different selon la voie utilisee
* (`innertube` / `scrape` / `api`). Un second cache ici le neutraliserait et
* masquerait les fallbacks dans les metriques.
*
* Le cache ne doit jamais faire echouer une recherche : toute erreur de base est
* absorbee et la requete amont est relancee.
*/
const CACHE_WRAPPED_PROVIDERS = new Set(['dm', 'tw', 'pt', 'od', 'ru']);
/**
* Phase 7.3 — provenance (`capturedAt` / `source`) de chaque suggestion.
*
* Posée ici, au registre, et pas dans les 6 adaptateurs : c'est le même
* raisonnement que le cache et que `channelRef` (un seul point d'entrée, donc
* aucun fournisseur ne peut l'oublier) — et surtout c'est le seul endroit où
* l'on sait si la réponse vient du réseau ou d'une entrée de cache.
*
* `source` distingue les voies de collecte réelles : `innertube` / `scrape` /
* `api` côté YouTube (déjà posés par `youtube.mjs`), `cache` pour un hit.
*
* Exportée pour être testée directement : ses règles d'honnêteté (un `0` n'est
* pas une date, une source vide n'est pas une source) sont le cœur de la
* tâche, et il est impossible de les atteindre par le HTTP sans écrire un
* adaptateur factice qui court-circuiterait justement le wrapper.
*
* @param {any[]} items
* @param {string} source voie de collecte à appliquer si l'item n'en porte pas
* @param {number} [capturedAt] instant de capture (défaut : maintenant)
* @returns {any[]} les mêmes items, stampés sur place
*/
export function stampProvenance(items, source, capturedAt) {
if (!Array.isArray(items)) return items;
const at = Number.isFinite(Number(capturedAt)) && Number(capturedAt) > 0
? Number(capturedAt)
: Date.now();
for (const item of items) {
if (!item || typeof item !== 'object') continue;
// Un adaptateur qui connaît SA propre voie de collecte (YouTube) reste
// prioritaire : on ne réécrit pas `innertube` en `api`.
//
// `||` seul ne suffit PAS : `-1`, `NaN` ou `" "` sont « présents » au sens
// de `||` pour les uns et rejettent pour les autres, et finissent affichés
// comme une date absurde (« il y a -1 s »). On teste donc la valeur.
const own = Number(item.capturedAt);
item.capturedAt = Number.isFinite(own) && own > 0 ? own : at;
const ownSource = String(item.source || '').trim();
item.source = ownSource || source;
}
return items;
}
/** Voie de collecte annoncée par l'adaptateur, sinon `api` (appel officiel). */
const DEFAULT_LIVE_SOURCE = 'api';
async function withSearchCache(adapter, providerId, q, opts) {
const { getCachedSearch, setCachedSearch } = await import('../db.mjs').catch(() => ({}));
if (!getCachedSearch || !setCachedSearch) return adapter.search(q, opts);
const limit = Math.min(Math.max(1, Number(opts?.limit || 10)), 100);
const page = Math.max(1, Number(opts?.page || 1));
const sort = String(opts?.sort || 'relevance');
// La signature des filtres entre dans la cle : deux recherches identiques avec
// des filtres differents ne doivent pas se partager de cache (meme regle que YT).
const filterSig = opts?.filters ? JSON.stringify(opts.filters, Object.keys(opts.filters).sort()) : '';
const key = `${providerId}|${hashSearchKey(`${String(q).toLowerCase().trim()}|${limit}|${page}|${sort}|${filterSig}`)}`;
const cached = getCachedSearch(providerId, key);
if (cached?.items) {
// Phase 7.3 : la provenance voyage DANS le payload mis en cache, donc un
// hit restitue spontanément la VRAIE voie de collecte (`api`, `innertube`…)
// et l'instant réel de capture — pas `Date.now()`, qui ferait croire à une
// fraîcheur de façade sur une réponse vieille de 4 min. `stampProvenance`
// ne remplit donc que ce qui manque : une ligne écrite par un serveur plus
// ancien (avant la phase 7.3) récupère ici son `createdAt` en base.
stampProvenance(cached.items, cached.source || DEFAULT_LIVE_SOURCE, cached.createdAt);
return cached.items;
}
// Phase 4.3 : on ne compte que les appels **amont** reels, pas les hits cache
// (sinon le taux d'echec apparent disparaitrait sous l'effet du cache).
const t0 = Date.now();
const { incProviderMetrics } = await import('../db.mjs').catch(() => ({}));
try {
const items = await adapter.search(q, opts);
incProviderMetrics?.(providerId, { ok: true, latencyMs: Date.now() - t0 });
// Phase 7.3 : la provenance est posée AVANT la mise en cache, pour qu'un hit
// ultérieur la transporte sans avoir à la deviner.
stampProvenance(items, DEFAULT_LIVE_SOURCE);
// Jamais de resultat vide persiste (regle phase 2.2 / 4.1) : une page vide
// transitoire ne doit pas bloquer les requetes suivantes pendant 5 min.
if (Array.isArray(items) && items.length > 0) setCachedSearch(providerId, key, q, items, 'api');
return items;
} catch (e) {
incProviderMetrics?.(providerId, { ok: false, latencyMs: Date.now() - t0, error: e?.message || e });
throw e;
}
}
for (const pid of CACHE_WRAPPED_PROVIDERS) {
const adapter = providerRegistry[/** @type {ProviderId} */(pid)];
if (!adapter || typeof adapter.search !== 'function') continue;
const raw = adapter.search.bind(adapter);
adapter.search = (q, opts) => withSearchCache({ search: raw }, pid, q, opts);
}
/**
* Phase 6.2 — `channelRef` sur les 6 providers.
*
* Volontairement appliqué ICI, au niveau du registre, et pas dans les 6
* adaptateurs : c'est le même raisonnement que le cache (un seul point d'entrée,
* donc aucun fournisseur ne peut l'oublier). Il est posé **au-dessus** du cache
* pour que la réponse soit identique que les items viennent du réseau ou d'une
* entrée de cache écrite par une version antérieure (qui n'avait pas le champ).
*
* Les champs legacy (`channelExternalId`…) sont laissés intacts : c'est une
* transition non cassante, pas un remplacement.
*/
for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) {
const adapter = providerRegistry[pid];
if (!adapter || typeof adapter.search !== 'function') continue;
const raw = adapter.search.bind(adapter);
adapter.search = async (q, opts) => withChannelRefs(pid, await raw(q, opts));
}
/**
* Phase 7.3 — `capturedAt` / `source` sur les 6 providers, y compris YouTube
* qui est EXCLU du cache (il a son propre cache à deux niveaux) : sans cette
* passe, YouTube n'aurait jamais de provenance — et c'est précisément le
* provider dont la voie de collecte (`innertube` vs `scrape` vs `api`) est la
* plus utile à afficher.
*
* Posée AU-DESSUS de `withChannelRefs` : la provenance décrit la donnée
* collectée, pas sa forme normalisée.
*/
for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) {
const adapter = providerRegistry[pid];
if (!adapter || typeof adapter.search !== 'function') continue;
const raw = adapter.search.bind(adapter);
adapter.search = async (q, opts) => {
const items = await raw(q, opts);
// Remplissage seulement : les items déjà stampés par le cache ou par
// l'adaptateur (YouTube connaît sa voie) gardent leur valeur d'origine.
if (Array.isArray(items) && items.length > 0) stampProvenance(items, DEFAULT_LIVE_SOURCE);
return items;
};
}
/**
* Phase 8.1 — l'adaptateur UNIFIÉ. `providerAdapters[pid]` EST le point de
* contact unique (recherche déjà enveloppée cache → channelRef → provenance)
* enrichi des deux volets chaîne (contenu / métadonnées) et de ses capacités.
* Positionné APRÈS les wrappers : les consommateurs (routes, tests de contrat)
* traitent tous par cet accès, jamais par les tables internes.
*
* `channelContent` délègue au collecteur de phase : la signature est
* `(externalId, opts, ctx)`, `ctx.searchRegistry` étant utilisé par `ru`.
*/
export const providerAdapters = /** @type {Record<ProviderId, ProviderAdapter>} */ (Object.fromEntries(
PROVIDER_IDS.map((pid) => {
const mod = providerRegistry[pid];
const content = channelContentByProvider[pid];
const meta = channelMetaByProvider[pid];
return [pid, {
id: pid,
label: mod?.label || pid,
search: mod?.search,
suggest: typeof mod?.suggest === 'function' ? mod.suggest.bind(mod) : undefined,
channelContent: content
? (externalId, opts, ctx) => content(externalId, opts || {}, ctx || {})
: undefined,
channelMeta: meta
? (externalId, ctx) => meta(externalId, ctx || {})
: undefined,
capabilities: PROVIDER_CAPABILITIES[pid],
}];
}),
));
/** Point d'accès unique d'un adaptateur par id (compat : un seul chemin). */
export function getProviderAdapter(provider) {
return providerAdapters[/** @type {ProviderId} */(provider)] || providerRegistry[/** @type {ProviderId} */(provider)];
}
/**
+185 -3
View File
@@ -132,6 +132,39 @@ function isChallenge(html) {
return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000));
}
/**
* Phase 3.3 — cache négatif court.
* Sans lui, chaque recherche Rumble déclenchait 2 fetch Node + 2 fetch Python
* (12-20 s) pour finir sur un challenge Cloudflare, à chaque frappe utilisateur.
* TTL volontairement court (5 min) : un challenge se lève vite, et on ne veut
* pas figer une panne de plus de quelques minutes.
* @type {Map<string, number>}
*/
const NEGATIVE_CACHE_TTL_MS = Number(process.env.RUMBLE_NEGATIVE_CACHE_TTL_MS || 5 * 60 * 1000);
const negativeCache = new Map();
function negativeCacheKey(q, page) { return `search:vide:${page}:${String(q || '').trim().toLowerCase()}`; }
function isNegativelyCached(key) {
const ts = negativeCache.get(key);
if (!ts) return false;
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; }
return true;
}
function markNegative(key) {
// Garde-fou mémoire : le cache ne doit pas grossir sans borne.
if (negativeCache.size >= 200) {
for (const [k, ts] of negativeCache) {
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) negativeCache.delete(k);
if (negativeCache.size < 150) break;
}
}
negativeCache.set(key, Date.now());
}
export function resetRumbleNegativeCache() { negativeCache.clear(); }
/* --------------------------------- parsing -------------------------------- */
function parseDurationToSeconds(raw) {
@@ -180,8 +213,101 @@ function normalizeThumb(raw) {
return t.startsWith('//') ? `https:${t}` : t;
}
/**
* Compteur de vues localized : "1,2K views", "1.2M views", "3 456", "12,345".
* L'ancien code faisait `.replace(/[^\d]/g, '')` : le suffixe disparaissait et
* "1,2K" devenait 12 (au lieu de 1 200). Les vues étaient donc affichées
* fausses sur la quasi-totalité des cartes Rumble au-delà de 999.
*/
export function parseRumbleViews(raw) {
const s = String(raw ?? '').trim();
if (!s) return undefined;
const m = s.match(/([\d][\d\s\u00a0.,]*)\s*([KMBkmb])?/);
if (!m) return undefined;
// 1 234 -> "1234" ; 1,2 / 1.2 -> "1.2" (décimal) ; 1,234 -> "1234" (milliers)
let digits = m[1].replace(/[\s\u00a0]/g, '');
if (/^\d{1,3},\d{3}$/.test(digits) || /^\d{1,3}\.\d{3}$/.test(digits)) digits = digits.replace(/[.,]/g, '');
else digits = digits.replace(',', '.');
const num = Number(digits);
if (!Number.isFinite(num) || num < 0) return undefined;
const suffix = (m[2] || '').toLowerCase();
const mult = suffix === 'b' ? 1e9 : suffix === 'm' ? 1e6 : suffix === 'k' ? 1e3 : 1;
const total = Math.round(num * mult);
return total > 0 ? total : undefined;
}
/** Clé de rapprochement stable : dernier segment d'URL, sans extension ni tracking. */
function urlKey(u) {
const s = String(u || '').split('?')[0].replace(/\.html$/i, '').replace(/\/$/, '');
return (s.split('/').filter(Boolean).pop() || '').toLowerCase();
}
/**
* Phase 3.1 — extraction du JSON-LD (`application/ld+json`).
* Plus stable que le DOM : quand le balisage change, le JSON-LD reste. On
* accepte `VideoObject`, `ItemList` et `@graph`, et on n'indexe QUE ce qui est
* explicitement présent (aucune valeur déduite).
* @returns {Map<string, { publishedAt?: string, views?: number, thumbnail?: string, duration?: number, channelId?: string }>}
*/
export function parseJsonLd(html) {
/** @type {Map<string, any>} */
const out = new Map();
const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(String(html || ''))) !== null) {
let data;
try { data = JSON.parse(m[1].trim()); } catch { continue; }
const nodes = Array.isArray(data) ? data
: Array.isArray(data?.['@graph']) ? data['@graph']
: data ? [data] : [];
for (const node of nodes) visit(node, out, 0);
}
return out;
}
function visit(node, out, depth) {
if (!node || typeof node !== 'object' || depth > 4) return;
const list = Array.isArray(node['@graph'])
? node['@graph']
: (Array.isArray(node.itemListElement) ? node.itemListElement.map((e) => e?.item ?? e) : null);
if (list) { for (const c of list) visit(c, out, depth + 1); }
const type = String(node['@type'] || '').toLowerCase();
if (!type.includes('video')) return;
const key = urlKey(node.url || node.embedUrl || node.contentUrl || node['@id']);
if (!key) return;
const stats = node.interactionStatistic;
const statList = Array.isArray(stats) ? stats : (stats ? [stats] : []);
let views;
for (const st of statList) {
const t = String(st?.interactionType || '').toLowerCase();
if (!t.includes('watch') && !t.includes('view')) continue;
const n = Number(st?.userInteractionCount);
if (Number.isFinite(n) && n >= 0) { views = Math.round(n); break; }
}
const dateRaw = node.uploadDate || node.datePublished;
const parsed = dateRaw ? Date.parse(dateRaw) : NaN;
const publishedAt = Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : undefined;
const duration = parseDurationToSeconds(node.duration);
const thumbRaw = Array.isArray(node.thumbnailUrl)
? node.thumbnailUrl[0]
: (typeof node.thumbnailUrl === 'string' ? node.thumbnailUrl : node.thumbnailUrl?.url);
const authorUrl = typeof node.author === 'string' ? node.author : node.author?.url;
const channelId = (String(authorUrl || '').match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
out.set(key, {
...(publishedAt ? { publishedAt } : {}),
...(views !== undefined && views > 0 ? { views } : {}),
...(duration !== undefined ? { duration } : {}),
...(thumbRaw ? { thumbnail: normalizeThumb(thumbRaw) } : {}),
...(channelId ? { channelId } : {}),
});
}
/** Classic parser: li.video-listing-entry cards. */
function parseSearchHtml(html, { limit = 50 } = {}) {
export function parseSearchHtml(html, { limit = 50 } = {}) {
const $ = load(html);
const items = [];
$('li.video-listing-entry').each((_idx, el) => {
@@ -220,13 +346,35 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
}
const viewsText = $el.find('.video-item--views').first().text().trim();
const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined;
const views = parseRumbleViews(viewsText);
// Phase 1.4 - date de publication. L'attribut `datetime` du <time> est la
// vraie date ISO ; son texte est un libelle relatif ("2 months ago") que
// Date.parse() ne sait pas lire, d'ou l'absence historique de publishedAt.
const timeEl = $el.find('.video-item--meta time, time.video-item--date, time').first();
const publishedRaw = (timeEl.attr('datetime') || timeEl.attr('data-datetime') || '').trim();
const publishedMs = publishedRaw ? Date.parse(publishedRaw) : NaN;
const publishedAt = Number.isFinite(publishedMs) && publishedMs > 0
? new Date(publishedMs).toISOString()
: undefined;
// Phase 1.5 - identifiant de chaine. L'URL porte soit /c/<username>/,
// soit (en fallback) le sous-domaine ; l'avatar est sur l'image du by-line.
const channelLink = $el.find('a.video-item--channel-link, a[href*="/c/"]').first();
const channelHref = (channelLink.attr('href') || '').trim();
const channelId = (channelHref.match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
const avatarEl = $el.find('img.video-item--channel-thumb, .video-item--by-line img, a.video-item--channel-link img').first();
const uploaderAvatar = normalizeThumb(avatarEl.attr('src') || avatarEl.attr('data-src') || '');
items.push({
title: title || url,
id,
url,
thumbnail: normalizeThumb(rawThumbnail),
uploaderName: uploaderName || undefined,
// Phase 1.4 / 1.5 / 1.6 - les trois champs etaient presents dans le HTML
// mais jamais extraits, ce qui vidait la page chaine de sa date, de son
// lien "voir la chaine" et de son avatar.
publishedAt,
channelId,
uploaderAvatar,
views,
type: 'video',
duration: durationSeconds,
@@ -234,6 +382,23 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
// durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions.
});
});
// Phase 3.1 — surcouche JSON-LD : complète ce que le DOM n'expose pas
// (date de publication, vues, auteur) SANS jamais écraser une valeur réellement
// présente dans la liste. Le DOM reste la source primaire, le JSON-LD le filet.
try {
const ld = parseJsonLd(html);
if (ld.size > 0) {
for (const it of items) {
const patch = ld.get(urlKey(it.url)) || ld.get(urlKey(it.id));
if (!patch) continue;
if (it.publishedAt === undefined && patch.publishedAt) it.publishedAt = patch.publishedAt;
if (it.views === undefined && patch.views) it.views = patch.views;
if (it.duration === undefined && patch.duration) it.duration = patch.duration;
if (it.thumbnail === undefined && patch.thumbnail) it.thumbnail = patch.thumbnail;
if (it.channelId === undefined && patch.channelId) it.channelId = patch.channelId;
}
}
} catch { /* la surcouche est un bonus, jamais un motif d'échec */ }
return items;
}
@@ -254,6 +419,16 @@ const handler = {
const query = String(q || '').trim();
if (!query) return [];
// Phase 3.3 — short-circuit si l'échec vient d'être constaté.
const cacheKey = negativeCacheKey(query, pageNum);
if (isNegativelyCached(cacheKey)) {
// Phase 3.4 — un échec est une ERREUR, pas un résultat vide : le front
// peut ainsi afficher un bandeau au lieu d'un « aucun résultat » trompeur.
throw Object.assign(new Error('rumble_cloudflare_challenge'), {
code: 'rumble_cloudflare_challenge', rumbleChallenged: true,
});
}
// --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) ---
try {
const params = new URLSearchParams({ q: query });
@@ -276,7 +451,14 @@ const handler = {
}
} catch { /* give up */ }
return [];
// Phase 3.4 — on distingue « échec » de « vide ». Une recherche Rumble qui
// renvoie 0 résultat à travers deux chemins est presque toujours un blocage
// Cloudflare, pas une absence de contenu : on le remonte, on met en cache
// négatif, et le groupe ne passe pas pour un recherche normale sans résultat.
markNegative(cacheKey);
throw Object.assign(new Error('rumble_unavailable'), {
code: 'rumble_unavailable', rumbleChallenged: true,
});
},
};
+112 -72
View File
@@ -172,7 +172,10 @@ function mapStream(s) {
uploaderAvatar: thumbStream(s.thumbnail_url, 70, 70),
type: 'live',
isLive: true,
views: typeof s.viewer_count === 'number' ? s.viewer_count : undefined,
// Phase 3.8 / 7.3 : `viewer_count` n'est PAS un compteur de vues. Il est
// expose dans `viewers` (joueurs en direct). Le mettre dans `views` faisait
// afficher "1 234 vues" sur un live qui n'a jamais ete vu.
viewers: typeof s.viewer_count === 'number' ? s.viewer_count : undefined,
publishedAt: s.started_at || undefined,
game: s.game_name || undefined,
language: s.language || undefined,
@@ -336,6 +339,29 @@ const handler = {
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
const targetPage = Math.max(1, Number(page || 1));
// Phase 3.10 — budget de section configurable.
//
// Helix compte 800 points/minute : chaque recherche Twitch déclenche une
// dizaine d'appels, et le budget influe directement sur le nombre de
// requêtes Helix (une requête = 1 point, `first` ne change pas le coût).
// Le défaut reprend EXACTEMENT l'historique (`max(4, ceil(perPage/3))` et
// `max(6, ceil(perPage/2))` pour les VODs) : sans variable d'environnement,
// le comportement ne bouge pas d'un iota.
//
// `TWITCH_SECTION_BUDGET` = budget uniforme en résultats (une seule valeur,
// lisible) ; les budgets par section restent réglables séparément.
const budgetOverride = Number(process.env.TWITCH_SECTION_BUDGET);
const budgetLives = Number.isFinite(budgetOverride) && budgetOverride > 0
? Math.floor(budgetOverride)
: Math.max(4, Math.ceil(perPage / 3));
const budgetVods = Number.isFinite(budgetOverride) && budgetOverride > 0
? Math.floor(budgetOverride)
: Math.max(6, Math.ceil(perPage / 2));
// `first` par broadcaster : borné par Helix (100) et par le budget, sinon
// on paie le transport pour des résultats que l'on jetterait au budget.
const vodFirst = Math.min(100, Math.max(1, budgetVods));
const clipFirst = Math.min(100, Math.max(1, budgetLives));
// 1) Chaînes (paginées : la pagination repose sur ce curseur).
// Helix ne matche pas les longues phrases : on cherche la requête
// complète puis chaque mot-clé (ex. "cooking" pour "cooking food...").
@@ -436,87 +462,101 @@ const handler = {
// Tri des lives : vues décroissantes quand sort=views, sinon pertinence (ordre Helix).
if (sort === 'views') lives = [...lives].sort((a, b) => Number(b.views || 0) - Number(a.views || 0));
// 3) VODs : par broadcaster (pertinence requête) + par jeu (thèmes).
// 3) + 4) VODs et Clips EN PARALLÈLE (phase 3.9).
//
// Le plan evaluait un risque de « casser l'ordre des sections » : il n'y a
// pas de risque ici, parce que les deux blocs écrivent dans deux variables
// distinctes et que l'ordre final est explicite à l'assemblage
// (`[...takeLives, ...takeVods, ...takeClips, ...takeChannels]`). Les
// sections restent donc rendues dans le même ordre, mais on économise une
// latence complète de Helix : ces blocs font chacun 5-6 requêtes, et
// Helix compte 800 points/min — le gaspillage était mesurable.
const vodSort = sort === 'views' ? 'views' : sort === 'date' ? 'time' : 'trending';
let vods = [];
try {
const perUser = userIds.slice(0, 6);
const lists = await Promise.all(
perUser.map(async (uid) => {
const clipSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
/** Ajoute `extra` à `list` sans doublon (le set est reconstruit à chaque fois : listes courtes). */
const mergeUnique = (list, extra) => {
const seen = new Set(list.map((v) => String(v.id)));
for (const v of extra) {
if (!seen.has(String(v.id))) { seen.add(String(v.id)); list.push(v); }
}
return list;
};
const loadVods = async () => {
let vods = [];
try {
const perUser = userIds.slice(0, 6);
const lists = await Promise.all(
perUser.map(async (uid) => {
try {
const data = await helixGet(
'/videos',
new URLSearchParams({ user_id: String(uid), first: String(vodFirst), type: 'archive', sort: vodSort }),
auth,
{ retries: 1 },
);
return Array.isArray(data?.data) ? data.data : [];
} catch {
return [];
}
}),
);
vods = lists.flat().map(mapVod);
if (targetPage === 1 && categoryId) {
try {
const data = await helixGet(
'/videos',
new URLSearchParams({ user_id: String(uid), first: '4', type: 'archive', sort: vodSort }),
new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }),
auth,
{ retries: 1 },
);
return Array.isArray(data?.data) ? data.data : [];
} catch {
return [];
}
}),
);
vods = lists.flat().map(mapVod);
if (targetPage === 1 && categoryId) {
try {
const data = await helixGet(
'/videos',
new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }),
auth,
{ retries: 1 },
);
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapVod);
const seen = new Set(vods.map((v) => String(v.id)));
for (const v of extra) {
if (!seen.has(String(v.id))) {
seen.add(String(v.id));
vods.push(v);
}
}
} catch {}
}
} catch {}
mergeUnique(vods, (Array.isArray(data?.data) ? data.data : []).map(mapVod));
} catch {}
}
} catch {}
return vods;
};
// 4) Clips : par broadcaster (30 derniers jours) + par jeu.
let clips = [];
try {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const perUser = userIds.slice(0, 5);
const lists = await Promise.all(
perUser.map(async (uid) => {
const loadClips = async () => {
let clips = [];
try {
const perUser = userIds.slice(0, 5);
const lists = await Promise.all(
perUser.map(async (uid) => {
try {
const data = await helixGet(
'/clips',
new URLSearchParams({ broadcaster_id: String(uid), first: String(clipFirst), started_at: clipSince }),
auth,
{ retries: 1 },
);
return Array.isArray(data?.data) ? data.data : [];
} catch {
return [];
}
}),
);
clips = lists.flat().map(mapClip);
if (targetPage === 1 && categoryId) {
try {
const data = await helixGet(
'/clips',
new URLSearchParams({ broadcaster_id: String(uid), first: '3', started_at: since }),
new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: clipSince }),
auth,
{ retries: 1 },
);
return Array.isArray(data?.data) ? data.data : [];
} catch {
return [];
}
}),
);
clips = lists.flat().map(mapClip);
if (targetPage === 1 && categoryId) {
try {
const data = await helixGet(
'/clips',
new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: since }),
auth,
{ retries: 1 },
);
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapClip);
const seen = new Set(clips.map((v) => String(v.id)));
for (const v of extra) {
if (!seen.has(String(v.id))) {
seen.add(String(v.id));
clips.push(v);
}
}
} catch {}
}
} catch {}
mergeUnique(clips, (Array.isArray(data?.data) ? data.data : []).map(mapClip));
} catch {}
}
} catch {}
return clips;
};
// `allSettled` et non `all` : chaque bloc avale déjà ses propres erreurs,
// mais un rejet inattendu ne doit pas faire tomber la section voisine.
const [vods, clips] = await Promise.allSettled([loadVods(), loadClips()])
.then((rs) => rs.map((r) => (r.status === 'fulfilled' ? r.value : [])));
// 5) Assemblage façon directory : lives, vidéos, clips, chaînes (hors-ligne).
const channels = channelsData.map(mapChannel);
@@ -535,10 +575,10 @@ const handler = {
}
// Budgets : on remplit chaque section sans noyer les autres.
const takeLives = allLives.slice(0, Math.max(4, Math.ceil(perPage / 3)));
const takeVods = vods.slice(0, Math.max(6, Math.ceil(perPage / 2)));
const takeClips = clips.slice(0, Math.max(4, Math.ceil(perPage / 3)));
const takeChannels = offlineChannels.slice(0, Math.max(4, Math.ceil(perPage / 3)));
const takeLives = allLives.slice(0, budgetLives);
const takeVods = vods.slice(0, budgetVods);
const takeClips = clips.slice(0, budgetLives);
const takeChannels = offlineChannels.slice(0, budgetLives);
const merged = [...takeLives, ...takeVods, ...takeClips, ...takeChannels];
// Déduplique par id (les lives login vs chaînes id numérique ne collisionnent pas).
+78
View File
@@ -47,6 +47,84 @@ export function getSearchMode() {
return 'innertube-first';
}
// --- Phase 4.4 : bascule automatique InnerTube -> scrape --------------------
/**
* InnerTube casse plus souvent que la Data API (changement de format, bot-check).
* On mesure le taux d'echec InnerTube sur une fenetre glissante et on bascule
* temporairement sur `scrape-first`, avec retour automatique et cooldown.
*
* Pourquoi cet etat est dans le process et pas en base : une bascule doit
* survivre a un redemarrage (les bases survivent au redemarrage, l'etat non),
* et `provider_metrics` est de toute facon purge au-dela de 24 h.
*/
const failover = {
/** null = bascule inactive, sinon { until, previousMode, reason }. */
active: null,
lastToggleAt: 0,
};
/** Seuil de declenchement, 0.2 = 20 % d'echecs InnerTube sur 1 h. */
function failoverThreshold() {
const v = Number(process.env.YT_INNERTUBE_AUTO_FAILOVER);
return Number.isFinite(v) && v > 0 && v < 1 ? v : 0.2;
}
/** Cooldown apres un retour au mode normal, pour eviter l'oscillation. */
function failoverCooldownMs() {
const v = Number(process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS);
return Number.isFinite(v) && v > 0 ? v : 15 * 60 * 1000;
}
/**
* A appeler apres chaque tentative InnerTube.
* @param {boolean} ok
* @param {object} snapshot `providerMetricsSnapshot({ hours: 1 })`
*/
export function recordInnerTubeOutcome(ok, snapshot) {
try {
if (!ok) ytMetrics.innertubeErrors++;
const now = Date.now();
// Retour au mode normal des que le taux repasse sous le seuil, en respectant
// le cooldown anti-oscillation.
if (failover.active && now < failover.active.until) return failover.active;
if (failover.active && now - failover.lastToggleAt < failoverCooldownMs()) return failover.active;
if (!Array.isArray(snapshot) || snapshot.length === 0) return failover.active;
const row = snapshot.find((r) => r.provider === 'yt');
if (!row || !row.calls || row.calls < 3) return failover.active; // pas assez d'echantillons
if (row.errorRate >= failoverThreshold()) {
const previousMode = getSearchMode();
failover.active = { until: now + failoverCooldownMs(), previousMode, reason: `errorRate=${row.errorRate.toFixed(2)} calls=${row.calls}` };
failover.lastToggleAt = now;
console.warn(`[YT failover] InnerTube dégradé (${failover.active.reason}) -> scrape-first pendant ${Math.round(failoverCooldownMs() / 60000)} min`);
return failover.active;
}
if (failover.active) {
console.log(`[YT failover] InnerTube rétabli (errorRate=${(row.errorRate || 0).toFixed(2)}) -> retour ${failover.active.previousMode}`);
failover.active = null;
failover.lastToggleAt = now;
}
return failover.active;
} catch { return failover.active; }
}
export function getFailoverState() {
return failover.active ? { ...failover.active, mode: 'scrape-first' } : null;
}
/**
* Mode **effectif** : le mode configure, force sur `scrape-first` pendant un
* failover actif. `getSearchMode()` reste la lecture de l'intention (env) ;
* les appelants doivent utiliser cette fonction.
*/
export function getEffectiveSearchMode() {
if (failover.active && Date.now() < failover.active.until) {
const base = getSearchMode();
// On n'ecrase que les modes qui dependent d'InnerTube.
return base.startsWith('innertube') ? 'scrape-first' : base;
}
return getSearchMode();
}
export function getScrapeTtlMs() {
const v = Number(process.env.YT_SCRAPE_TTL_MS || 30 * 60 * 1000);
return Number.isFinite(v) && v > 0 ? v : 30 * 60 * 1000;
+45 -1
View File
@@ -75,6 +75,45 @@ export function parseDurationLabel(label) {
} catch { return undefined; }
}
/** "il y a 2 mois" / "Streamed 3 weeks ago" / "2 days ago" -> ISO, ou undefined. */
export function parseRelativeDate(text, nowMs = Date.now()) {
try {
const s = String(text || '').toLowerCase().trim();
if (!s) return undefined;
// Une date absolue ("17 janv. 2024", "2024-01-17") passe deja par Date.parse.
if (/\d{4}-\d{2}-\d{2}/.test(s)) {
const abs = Date.parse(s);
if (Number.isFinite(abs) && abs > 0 && abs < 8.64e15) return new Date(abs).toISOString();
}
// Fenetre temporelle : YouTube annonce "il y a 2 heures", "3 weeks ago", ...
const re = /(\d+)\s*(second|minute|hour|day|week|month|year|seconde|heure|jour|semaine|mois|an|annee|année)/;
const m = s.match(re);
if (!m) {
// "hier" / "yesterday" / "aujourd'hui" / "today" : cas sans quantite.
if (/\b(yesterday|hier)\b/.test(s)) return new Date(nowMs - 864e5).toISOString();
if (/\b(today|aujourd'hui)\b/.test(s)) return new Date(nowMs).toISOString();
if (/\bjust now|just now|maintenant|venir de\b/.test(s)) return new Date(nowMs).toISOString();
return undefined;
}
const n = Math.max(0, Number(m[1]));
if (!Number.isFinite(n)) return undefined;
const unit = m[2];
const table = {
second: 1e3, seconde: 1e3,
minute: 6e4,
hour: 36e5, heure: 36e5,
day: 864e5, jour: 864e5,
week: 6048e5, semaine: 6048e5,
month: 26298e6, mois: 26298e6,
year: 315576e5, an: 315576e5, annee: 315576e5, année: 315576e5,
};
const mult = table[unit];
if (!mult) return undefined;
const ms = nowMs - (n * mult);
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : undefined;
} catch { return undefined; }
}
function bestThumb(thumbs) {
try {
const arr = Array.isArray(thumbs) ? thumbs.filter((t) => t?.url) : [];
@@ -151,7 +190,12 @@ export function mapVideoNode(n) {
type: 'video',
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
...(views !== undefined ? { views } : {}),
...(n.published?.text ? { publishedAt: String(n.published.text) } : {}),
// Phase 1.9 - `n.published.text` est un LIBELLE RELATIF ("il y a 2 mois"),
// que Date.parse() renvoie en NaN. On le convertit en ISO : sans cela le
// filtre `period=week` etait inoperant sur YouTube et `sort=date` triait
// sur NaN. Si le libelle est incomprehensible, publishedAt reste absent
// (jamais de date inventee).
...(n.published?.text ? { publishedAt: parseRelativeDate(n.published.text) } : {}),
...(authorId ? { channelId: authorId, channelExternalId: authorId, channelUrl: `https://www.youtube.com/channel/${authorId}` } : {}),
...(authorName ? { channelHandle: String(authorName) } : {}),
...(n.is_live ? { isLive: true } : {}),
+116 -13
View File
@@ -10,7 +10,7 @@
* @property {string=} type
*/
import {
getYouTubeKeys, isKeyFailure, getSearchMode, getScrapeTtlMs,
getYouTubeKeys, isKeyFailure, getEffectiveSearchMode, getScrapeTtlMs,
hashSearchKey, ytMetrics,
} from './youtube-common.mjs';
import { searchViaScrape } from './youtube-scrape.mjs';
@@ -109,6 +109,24 @@ export function ytScrapeCacheStats() {
return { memEntries: memCache.size, memMax: MEM_MAX };
}
/**
* Phase 3.6 — cache des `pageToken` YouTube.
* Avant : atteindre la page N exigeait de rejouer les pages 1..N à chaque visite
* (`while (currentPage <= targetPage)`), soit jusqu'à 100 unités de quota pour un
* simple « page 5 ». La Data API est paginée par jeton, pas par offset : on mémorise
* donc la chaîne de jetons et chaque page thereafter coûte 1 appel.
* TTL 15 min, borné à 200 entrées par instance.
* @type {Map<string, { ts: number, tokens: string[] }>}
*/
const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000;
const pageTokenCache = new Map();
function pageTokenCacheKey(q, order, perPage, extra) {
return `ytpage|${hashSearchKey(`${String(q).toLowerCase().trim()}|${order}|${perPage}|${JSON.stringify(extra || {})}`)}`;
}
export function clearYouTubePageTokenCache() { pageTokenCache.clear(); }
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) {
const keys = getYouTubeKeys();
if (!keys.length) {
@@ -121,23 +139,42 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
const extra = apiSearchParams(filters);
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
const targetPage = Math.max(1, Number(page || 1));
let pageToken = '';
let currentPage = 1;
const cacheKey = pageTokenCacheKey(q, order, perPage, extra);
const hit = pageTokenCache.get(cacheKey);
const entry = (hit && (Date.now() - hit.ts) < PAGE_TOKEN_TTL_MS) ? hit : null;
if (!entry && hit) pageTokenCache.delete(cacheKey);
if (pageTokenCache.size >= 200) {
for (const [k, v] of pageTokenCache) {
if ((Date.now() - v.ts) >= PAGE_TOKEN_TTL_MS) pageTokenCache.delete(k);
if (pageTokenCache.size < 150) break;
}
}
/** `tokens[i]` = pageToken permettant de fetch la page i+2. Doit être contigu depuis 0. */
const tokens = entry ? entry.tokens : [];
const buildParams = (pageToken) => ({
part: 'snippet', q, maxResults: String(perPage), order,
videoEmbeddable: 'true', safeSearch: 'moderate',
...extra,
...(pageToken ? { pageToken } : {}),
});
// Si le jeton de la page cible est déjà connu, on saute directement dessus :
// 1 appel au lieu de N. Sinon on rejoue les pages manquantes à partir d'où
// la chaîne s'arrête (jamais depuis le début si le cache est partiel).
const startPage = Math.min(targetPage, tokens.length + 1);
let pageToken = startPage >= 2 ? (tokens[startPage - 2] || '') : '';
let lastItems = [];
while (currentPage <= targetPage) {
const params = {
part: 'snippet', q, maxResults: String(perPage), order,
videoEmbeddable: 'true', safeSearch: 'moderate',
...extra,
};
if (pageToken) params.pageToken = pageToken;
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
for (let currentPage = startPage; currentPage <= targetPage; currentPage++) {
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', buildParams(pageToken));
if (currentPage === targetPage) { lastItems = Array.isArray(data.items) ? data.items : []; break; }
const next = data.nextPageToken;
if (!next) { lastItems = []; break; }
tokens[currentPage - 1] = String(next);
pageToken = String(next);
currentPage++;
}
pageTokenCache.set(cacheKey, { ts: Date.now(), tokens });
const videoIds = (lastItems || []).map((item) => item?.id?.videoId).filter(Boolean);
const detailsMap = new Map();
if (videoIds.length > 0) {
@@ -150,6 +187,31 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e);
}
}
// Phase 1.7 - `search.list` ne renvoie QUE la vignette de la video, jamais
// celle de la chaine. Un seul appel `channels.list` par page suffit ahydrater
// `uploaderAvatar` pour tous les resultats. Degradation silencieuse : si aucune
// cle n'est disponible ou si l'appel echoue, on rend la main sans avatar
// (jamais d'image inventee).
const channelAvatars = new Map();
try {
const channelIds = Array.from(new Set(
(lastItems || [])
.map((i) => i?.snippet?.channelId || i?.id?.channelId)
.filter(Boolean),
)).slice(0, 50);
if (channelIds.length > 0) {
const chData = await ytFetchJson('https://www.googleapis.com/youtube/v3/channels', {
part: 'snippet', id: channelIds.join(','),
});
for (const ch of chData?.items || []) {
const t = ch?.snippet?.thumbnails;
const url = t?.high?.url || t?.medium?.url || t?.default?.url;
if (ch?.id && url) channelAvatars.set(ch.id, url);
}
}
} catch (e) {
console.warn('[YouTube] channel avatars fetch failed, continuing without:', e?.message || e);
}
return (lastItems || []).map((item) => {
const videoId = item?.id?.videoId;
// Une recherche de type `channel` renvoie des channelId, pas des videoId.
@@ -169,8 +231,18 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
? `https://www.youtube.com/channel/${rawId}`
: `https://www.youtube.com/watch?v=${rawId}`,
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined,
// Phase 1.7 - avatar de la chaine (absent = pas d'avatar, pas de repli casse).
...(channelId && channelAvatars.get(channelId)
? { uploaderAvatar: channelAvatars.get(channelId), channelAvatarUrl: channelAvatars.get(channelId) }
: {}),
type: isChannel ? 'channel' : 'video',
duration: duration > 0 ? duration : undefined, views,
// Phase 1.7bis - `likeCount` est gratuit sur le meme appel
// `videos?part=statistics` que la duree : autant le remonter pendant qu'on y est.
...(details?.statistics?.likeCount != null ? { likes: Number(details.statistics.likeCount) } : {}),
// `liveBroadcastContent` vient du snippet de la recherche (live/upcoming/none).
...(snippet?.liveBroadcastContent === 'live' ? { isLive: true, type: 'live' } : {}),
...(snippet?.liveBroadcastContent === 'upcoming' && !isChannel ? { type: 'upcoming' } : {}),
publishedAt: snippet.publishedAt || undefined, channelId,
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
@@ -191,7 +263,11 @@ const handler = {
label: 'YouTube',
async search(q, opts) {
const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {};
const mode = getSearchMode();
// Phase 4.4 : on branche sur le mode **effectif** (eventuellement force sur
// scrape-first par la bascule automatique), pas sur l'intention. La cle de
// cache inclut ce mode : sinon une bascule ferait servir des resultats
// InnerTube comme s'ils venaient du scrape.
const mode = getEffectiveSearchMode();
const ttl = getScrapeTtlMs();
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
// La signature des filtres entre dans la clé de cache : deux recherches
@@ -224,6 +300,30 @@ const handler = {
return items;
};
const t0 = Date.now();
/**
* Phase 4.4 — alimente la bascule automatique.
*
* Deux choses indispensables, et la premiere etait absente :
* 1. l'appel InnerTube est un VRAI appel sortant, il doit etre mesure dans
* `provider_metrics` sous le provider `yt` ;
* 2. le snapshot est lu APRES cet increment, sinon la tentative courante
* n'est pas comptee.
* Sans (1), la ligne `yt` restait absente de `provider_metrics` (le registre
* met en cache les autres fournisseurs, pas YouTube), donc
* `recordInnerTubeOutcome` voyait toujours `calls === 0`, la condition
* `row.calls < 3` sortait toujours, et le failover ne pouvait jamais
* se declencher en production — uniquement dans les tests, qui injectaient
* un snapshot synthetique.
*/
const noteInnerTube = async (ok, latencyMs, error) => {
try {
const [{ providerMetricsSnapshot, incProviderMetrics }, common] = await Promise.all([
import('../db.mjs'), import('./youtube-common.mjs'),
]);
incProviderMetrics('yt', { ok, latencyMs, error: error || null });
common.recordInnerTubeOutcome(ok, providerMetricsSnapshot({ hours: 1 }));
} catch {}
};
const tryScrape = async () => {
ytMetrics.scrapeCalls++;
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
@@ -292,14 +392,17 @@ const handler = {
// Chaque couche ne fait jamais échouer la recherche à elle seule.
const errors = [];
try {
const tIt = Date.now();
const items = await tryInnerTube();
log('innertube', items.length);
await noteInnerTube(true, Date.now() - tIt);
return persist(items, 'innertube');
} catch (itErr) {
ytMetrics.innertubeErrors = (ytMetrics.innertubeErrors || 0) + 1;
ytMetrics.fallbacks++;
errors.push(`innertube=${itErr?.message}`);
console.warn(`[YT search] innertube failed (${itErr?.code || 'unknown'}), fallback to scrape`);
await noteInnerTube(false, Date.now() - tIt, itErr?.code || itErr?.message);
}
try {
const items = await tryScrape();