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.
329 lines
16 KiB
JavaScript
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);
|
|
}
|