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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user