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:
@@ -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)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user