Files
NewTube/server/providers/registry.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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.
2026-09-30 07:57:11 -04:00

329 lines
16 KiB
JavaScript

// Server-side provider registry for search fan-out
// Each provider module should export an object with a `search(q, opts)` function returning Promise<Suggestion[]>
/**
* @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<Suggestion[]>} search
* @property {(q: string, opts?: { limit?: number }) => Promise<string[]>} [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<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,
/** @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<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)];
}
/**
* 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);
}