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:
+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).
|
||||
|
||||
Reference in New Issue
Block a user