// Server-side provider registry for search fan-out // Each provider module should export an object with a `search(q, opts)` function returning Promise /** * @typedef {Object} Suggestion * @property {string} title * @property {string} id * @property {number=} duration (secondes ; > 0. NE JAMAIS 0 : absence = undefined) * @property {boolean=} isShort * @property {string=} url * @property {string=} thumbnail * @property {string=} uploaderName * @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 { 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, filters?: Object }) => Promise} search * @property {(q: string, opts?: { limit?: number }) => Promise} [suggest] * @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} 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: true, channelMeta: true, channelContent: ['videos', 'playlists', 'live'] }, od: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] }, ru: { suggest: false, live: true, channelMeta: true, channelContent: ['videos', 'live'] }, }; /** Ordre canonique des providers (un unique endroit, hors route). */ export const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; /** @type {Record} */ export const providerRegistry = { /** @type {any} */ yt: (await import('./youtube.mjs')).default, /** @type {any} */ dm: (await import('./dailymotion.mjs')).default, /** @type {any} */ tw: (await import('./twitch.mjs')).default, /** @type {any} */ pt: (await import('./peertube.mjs')).default, /** @type {any} */ od: (await import('./odysee.mjs')).default, /** @type {any} */ ru: (await import('./rumble.mjs')).default, }; for (const [pid, adapter] of Object.entries(providerRegistry)) { 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} */ (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)]; } /** * Validate and normalize a comma separated providers list * @param {string} input * @returns {ProviderId[]} */ export function validateProviders(input) { const raw = String(input || '').split(',').map(s => s.trim()).filter(Boolean); const allowed = /** @type {ProviderId[]} */(['yt','dm','tw','pt','od','ru']); const filtered = raw.filter(p => allowed.includes(/** @type {any} */(p))); if (filtered.length === 0) return allowed; return /** @type {ProviderId[]} */(filtered); }