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.
611 lines
23 KiB
JavaScript
611 lines
23 KiB
JavaScript
/**
|
|
* Twitch provider using Helix API.
|
|
*
|
|
* Search mixte façon directory Twitch (category/: lives + videos + clips) :
|
|
* - chaînes via /helix/search/channels (type 'channel' hors-ligne, 'live' en direct)
|
|
* - lives via /helix/streams enrichis (viewer_count, game_name, started_at)
|
|
* - VODs via /helix/videos par broadcaster (+ par game_id quand la requête
|
|
* matche une catégorie Twitch, ex. "sports")
|
|
* - clips via /helix/clips par broadcaster (+ par game_id le cas échéant)
|
|
*
|
|
* Token applicatif caché process-wide + retries (cf. historique).
|
|
*/
|
|
|
|
let cachedToken = null; // { token, clientId, expiresAtMs }
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeoutMs = 10_000) {
|
|
const controller = new AbortController();
|
|
const tid = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...options, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(tid);
|
|
}
|
|
}
|
|
|
|
async function getAppAccessToken(force = false) {
|
|
const clientId = process.env.TWITCH_CLIENT_ID;
|
|
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
|
if (!clientId || !clientSecret) {
|
|
const err = new Error('twitch_not_configured: TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET missing');
|
|
err.code = 'NOT_CONFIGURED';
|
|
throw err;
|
|
}
|
|
if (!force && cachedToken && Date.now() < cachedToken.expiresAtMs) {
|
|
return cachedToken;
|
|
}
|
|
|
|
let lastErr = null;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
const authResponse = await fetchWithTimeout('https://id.twitch.tv/oauth2/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
grant_type: 'client_credentials',
|
|
}),
|
|
});
|
|
if (!authResponse.ok) {
|
|
if (authResponse.status === 400 || authResponse.status === 401 || authResponse.status === 403) {
|
|
throw Object.assign(new Error(`twitch_auth_rejected_${authResponse.status}`), { code: 'AUTH_REJECTED' });
|
|
}
|
|
throw new Error(`twitch_auth_error_${authResponse.status}`);
|
|
}
|
|
const { access_token, expires_in } = await authResponse.json();
|
|
if (!access_token) throw new Error('twitch_auth_no_token');
|
|
cachedToken = {
|
|
token: access_token,
|
|
clientId,
|
|
expiresAtMs: Date.now() + Math.max(3600, Number(expires_in || 3600) - 86_400) * 1000,
|
|
};
|
|
return cachedToken;
|
|
} catch (e) {
|
|
lastErr = e;
|
|
if (e && e.code === 'AUTH_REJECTED') throw e;
|
|
if (attempt < 2) await sleep(400 * (attempt + 1));
|
|
}
|
|
}
|
|
throw lastErr || new Error('twitch_auth_failed');
|
|
}
|
|
|
|
function helixHeaders(token, clientId) {
|
|
return { 'Client-ID': clientId, 'Authorization': `Bearer ${token}` };
|
|
}
|
|
|
|
/** GET Helix avec retry + refresh token sur 401. Retourne le JSON décodé. */
|
|
async function helixGet(path, params, auth, { retries = 2 } = {}) {
|
|
let { token, clientId } = auth;
|
|
let lastErr = null;
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
const qs = params ? `?${params.toString()}` : '';
|
|
const resp = await fetchWithTimeout(`https://api.twitch.tv/helix${path}${qs}`, {
|
|
headers: helixHeaders(token, clientId),
|
|
});
|
|
if (resp.status === 401 && attempt < retries) {
|
|
const fresh = await getAppAccessToken(true);
|
|
token = fresh.token;
|
|
clientId = fresh.clientId;
|
|
auth.token = token;
|
|
auth.clientId = clientId;
|
|
continue;
|
|
}
|
|
if (!resp.ok) throw new Error(`twitch_api_error_${resp.status}`);
|
|
return await resp.json();
|
|
} catch (e) {
|
|
lastErr = e;
|
|
if (attempt < retries) await sleep(350 * (attempt + 1));
|
|
}
|
|
}
|
|
throw lastErr || new Error('twitch_api_failed');
|
|
}
|
|
|
|
function thumbStream(raw, w = 640, h = 360) {
|
|
const s = String(raw || '');
|
|
if (!s) return 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
|
return s.replace('{width}x{height}', `${w}x${h}`).replace('{width}', String(w)).replace('{height}', String(h));
|
|
}
|
|
|
|
function thumbVod(raw) {
|
|
const s = String(raw || '');
|
|
if (!s) return undefined;
|
|
// VODs: motif "%{width}x%{height}"
|
|
return s.replace('%{width}', '640').replace('%{height}', '360');
|
|
}
|
|
|
|
function parseTwitchDurationToSeconds(raw) {
|
|
// Formats Helix videos : "2h13m5s" / "13m5s" / "45s"
|
|
const s = String(raw || '').trim();
|
|
if (!s) return 0;
|
|
if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s));
|
|
let total = 0;
|
|
const h = /(\d+)h/.exec(s);
|
|
const m = /(\d+)m/.exec(s);
|
|
const sec = /(\d+)s/.exec(s);
|
|
if (h) total += Number(h[1]) * 3600;
|
|
if (m) total += Number(m[1]) * 60;
|
|
if (sec) total += Number(sec[1]);
|
|
return total;
|
|
}
|
|
|
|
function mapChannel(item) {
|
|
const login = item.broadcaster_login || item.user_login || undefined;
|
|
const live = Boolean(item.is_live);
|
|
const thumb = String(item.thumbnail_url || '');
|
|
const avatar = thumb || undefined;
|
|
return {
|
|
title: item.title || item.display_name || login || '',
|
|
id: item.id,
|
|
url: login ? `https://www.twitch.tv/${login}` : undefined,
|
|
thumbnail: thumb || undefined,
|
|
uploaderName: item.display_name || item.broadcaster_name || undefined,
|
|
channelId: item.id || undefined,
|
|
channelHandle: login,
|
|
channelExternalId: login,
|
|
uploaderAvatar: avatar,
|
|
type: live ? 'live' : 'channel',
|
|
isLive: live,
|
|
views: undefined,
|
|
publishedAt: item.started_at || undefined,
|
|
game: item.game_name || item.game || undefined,
|
|
};
|
|
}
|
|
|
|
function mapStream(s) {
|
|
const login = s.user_login || s.broadcaster_login || undefined;
|
|
return {
|
|
title: s.title || s.user_name || login || '',
|
|
// id = login : le player embed une chaîne via ?channel=<login>
|
|
id: login || s.id,
|
|
url: login ? `https://www.twitch.tv/${login}` : undefined,
|
|
thumbnail: thumbStream(s.thumbnail_url, 640, 360),
|
|
uploaderName: s.user_name || undefined,
|
|
channelId: s.user_id || undefined,
|
|
channelHandle: login,
|
|
channelExternalId: login,
|
|
uploaderAvatar: thumbStream(s.thumbnail_url, 70, 70),
|
|
type: 'live',
|
|
isLive: true,
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
function mapVod(v) {
|
|
const login = v.user_login || v.broadcaster_login || undefined;
|
|
return {
|
|
title: v.title || '',
|
|
id: v.id,
|
|
url: v.url || (v.id ? `https://www.twitch.tv/videos/${v.id}` : undefined),
|
|
thumbnail: thumbVod(v.thumbnail_url),
|
|
uploaderName: v.user_name || v.broadcaster_name || undefined,
|
|
channelId: v.user_id || undefined,
|
|
channelHandle: login,
|
|
channelExternalId: login,
|
|
uploaderAvatar: undefined,
|
|
type: 'video',
|
|
kind: 'vod',
|
|
isLive: false,
|
|
duration: parseTwitchDurationToSeconds(v.duration),
|
|
views: typeof v.view_count === 'number' ? v.view_count : undefined,
|
|
publishedAt: v.created_at || v.published_at || undefined,
|
|
game: v.game_name || undefined,
|
|
language: v.language || undefined,
|
|
};
|
|
}
|
|
|
|
function mapClip(c) {
|
|
return {
|
|
title: c.title || '',
|
|
id: c.id,
|
|
url: c.url || `https://clips.twitch.tv/${c.id}`,
|
|
thumbnail: thumbVod(c.thumbnail_url),
|
|
uploaderName: c.broadcaster_name || c.creator_name || undefined,
|
|
channelId: c.broadcaster_id || undefined,
|
|
channelHandle: undefined,
|
|
channelExternalId: undefined,
|
|
uploaderAvatar: undefined,
|
|
type: 'video',
|
|
kind: 'clip',
|
|
isLive: false,
|
|
duration: typeof c.duration === 'number' ? Math.round(c.duration) : Number(c.duration || 0) || undefined,
|
|
views: typeof c.view_count === 'number' ? c.view_count : undefined,
|
|
publishedAt: c.created_at || undefined,
|
|
game: undefined,
|
|
language: c.language || undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mots-clés unitaires exploitables par Helix : /search/channels et
|
|
* /search/categories ne matchent que des noms proches, jamais de longues
|
|
* phrases multi-mots (ex. "cooking food Food Cooking Recipe").
|
|
*/
|
|
function twitchKeywords(q, max = 3) {
|
|
const words = String(q || '')
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9à-ÿ]+/i)
|
|
.map((w) => w.trim())
|
|
.filter((w) => w.length >= 3);
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const w of words) {
|
|
if (seen.has(w)) continue;
|
|
seen.add(w);
|
|
out.push(w);
|
|
if (out.length >= max) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Catégorie Twitch la plus pertinente pour une requête (ex. "sports" -> Sports). */
|
|
async function findCategoryId(q, auth) {
|
|
const attempts = [String(q || '').trim(), ...twitchKeywords(q, 3)].filter(Boolean).slice(0, 4);
|
|
// Déduplique en conservant l'ordre (requête complète d'abord).
|
|
const seen = new Set();
|
|
const ordered = attempts.filter((a) => {
|
|
const k = String(a).toLowerCase();
|
|
if (seen.has(k)) return false;
|
|
seen.add(k);
|
|
return true;
|
|
});
|
|
for (const attempt of ordered) {
|
|
try {
|
|
const data = await helixGet('/search/categories', new URLSearchParams({ query: attempt, first: '5' }), auth, { retries: 1 });
|
|
const list = Array.isArray(data?.data) ? data.data : [];
|
|
if (!list.length) continue;
|
|
const needle = String(attempt || '').trim().toLowerCase();
|
|
const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle);
|
|
const best = exact || list[0];
|
|
if (best?.id) return best.id;
|
|
} catch {
|
|
// Essaie le mot-clé suivant.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Recherche de chaînes sur plusieurs mots-clés (fusion dédupliquée). */
|
|
async function searchChannelsMulti(queries, first, auth, after = '') {
|
|
const seen = new Set();
|
|
const merged = [];
|
|
let cursor = null;
|
|
for (let i = 0; i < queries.length; i++) {
|
|
try {
|
|
const params = new URLSearchParams({ query: queries[i], first: String(first) });
|
|
if (i === 0 && after) params.set('after', after);
|
|
const data = await helixGet('/search/channels', params, auth, { retries: 1 });
|
|
const list = Array.isArray(data?.data) ? data.data : [];
|
|
if (i === 0) cursor = data?.pagination?.cursor || null;
|
|
else if (!cursor && data?.pagination?.cursor) cursor = data.pagination.cursor;
|
|
for (const c of list) {
|
|
const key = String(c?.id || '');
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
merged.push(c);
|
|
}
|
|
// Assez de candidats : inutile de spammer Helix.
|
|
if (merged.length >= 10) break;
|
|
} catch {}
|
|
}
|
|
return { channels: merged, cursor };
|
|
}
|
|
|
|
const handler = {
|
|
id: 'tw',
|
|
label: 'Twitch',
|
|
/**
|
|
* Top streams via le token serveur (retries inclus).
|
|
* Utilisé par la route /api/twitch/top-streams : repli fiable quand les
|
|
* appels Helix directs du navigateur échouent (proxy, CORS, 429...).
|
|
* @param {number} limit
|
|
*/
|
|
async topStreams(limit = 24) {
|
|
try {
|
|
const appToken = await getAppAccessToken();
|
|
const auth = { token: appToken.token, clientId: appToken.clientId };
|
|
const first = String(Math.min(100, Math.max(1, Number(limit) || 24)));
|
|
const data = await helixGet('/streams', new URLSearchParams({ first }), auth, { retries: 2 });
|
|
return Array.isArray(data?.data) ? data.data.map(mapStream) : [];
|
|
} catch (e) {
|
|
console.error('Twitch topStreams error:', e?.message || e);
|
|
return [];
|
|
}
|
|
},
|
|
/**
|
|
* Recherche mixte : lives + VODs + clips + chaînes.
|
|
* @param {string} q
|
|
* @param {{ limit: number, page?: number, sort?: string }} opts
|
|
*/
|
|
async search(q, opts) {
|
|
const { limit = 24, page = 1, sort = 'relevance' } = opts || {};
|
|
const query = String(q || '').trim();
|
|
if (!query) return [];
|
|
|
|
try {
|
|
const appToken = await getAppAccessToken();
|
|
const auth = { token: appToken.token, clientId: appToken.clientId };
|
|
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...").
|
|
const channelQueries = [query, ...twitchKeywords(query, 3).filter((k) => k !== query.toLowerCase())].slice(0, 4);
|
|
let after = '';
|
|
let currentPage = 1;
|
|
let channelsData = [];
|
|
let channelsCursor = null;
|
|
while (currentPage <= targetPage) {
|
|
const { channels, cursor } = await searchChannelsMulti(
|
|
channelQueries,
|
|
Math.min(20, perPage),
|
|
auth,
|
|
after,
|
|
);
|
|
// Alternative brute si Helix a tout filtré : top streams comme base de chaînes.
|
|
let list = channels;
|
|
let cur = cursor;
|
|
if (!list.length && currentPage === 1 && targetPage === 1) {
|
|
try {
|
|
const top = await helixGet('/streams', new URLSearchParams({ first: '20' }), auth, { retries: 1 });
|
|
const streams = Array.isArray(top?.data) ? top.data : [];
|
|
list = streams.map((s) => ({
|
|
id: s.user_id,
|
|
broadcaster_login: s.user_login,
|
|
user_login: s.user_login,
|
|
display_name: s.user_name,
|
|
is_live: true,
|
|
title: s.title,
|
|
thumbnail_url: s.thumbnail_url,
|
|
started_at: s.started_at,
|
|
game_name: s.game_name,
|
|
}));
|
|
cur = top?.pagination?.cursor || null;
|
|
} catch {}
|
|
}
|
|
channelsCursor = cur;
|
|
if (currentPage === targetPage) {
|
|
channelsData = list;
|
|
break;
|
|
}
|
|
if (!channelsCursor) {
|
|
channelsData = [];
|
|
break;
|
|
}
|
|
after = String(channelsCursor);
|
|
currentPage++;
|
|
}
|
|
|
|
const liveChannels = channelsData.filter((c) => c?.is_live);
|
|
const userIds = [...new Set(channelsData.map((c) => c?.id).filter(Boolean))].slice(0, 8);
|
|
const logins = [...new Set(channelsData.map((c) => c?.broadcaster_login || c?.user_login).filter(Boolean))].slice(0, 20);
|
|
|
|
// 2) Catégorie éventuelle (thèmes génériques : sports, music...) + streams enrichis en parallèle.
|
|
const [categoryId, streamsByLogin] = await Promise.all([
|
|
targetPage === 1 ? findCategoryId(query, auth) : Promise.resolve(null),
|
|
(async () => {
|
|
if (!logins.length) return new Map();
|
|
try {
|
|
const params = new URLSearchParams();
|
|
logins.forEach((l) => params.append('user_login', l));
|
|
params.set('first', String(Math.min(100, logins.length)));
|
|
const data = await helixGet('/streams', params, auth, { retries: 1 });
|
|
const map = new Map();
|
|
for (const s of Array.isArray(data?.data) ? data.data : []) {
|
|
const key = String(s?.user_login || '').toLowerCase();
|
|
if (key) map.set(key, s);
|
|
}
|
|
return map;
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
})(),
|
|
]);
|
|
|
|
// Lives : streams enrichis des chaînes live + top streams de la catégorie si dispo.
|
|
let lives = [];
|
|
for (const c of liveChannels) {
|
|
const login = String(c?.broadcaster_login || c?.user_login || '').toLowerCase();
|
|
const s = login ? streamsByLogin.get(login) : null;
|
|
if (s) lives.push(mapStream(s));
|
|
else lives.push(mapChannel(c));
|
|
}
|
|
if (targetPage === 1 && categoryId) {
|
|
try {
|
|
const data = await helixGet('/streams', new URLSearchParams({ game_id: String(categoryId), first: '12' }), auth, { retries: 1 });
|
|
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapStream);
|
|
const seen = new Set(lives.map((v) => String(v.id).toLowerCase()));
|
|
for (const v of extra) {
|
|
if (!seen.has(String(v.id).toLowerCase())) {
|
|
seen.add(String(v.id).toLowerCase());
|
|
lives.push(v);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// 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) + 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';
|
|
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({ game_id: String(categoryId), first: '12', sort: vodSort }),
|
|
auth,
|
|
{ retries: 1 },
|
|
);
|
|
mergeUnique(vods, (Array.isArray(data?.data) ? data.data : []).map(mapVod));
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
return vods;
|
|
};
|
|
|
|
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({ game_id: String(categoryId), first: '10', started_at: clipSince }),
|
|
auth,
|
|
{ retries: 1 },
|
|
);
|
|
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);
|
|
const offlineChannels = channels.filter((c) => c.type === 'channel');
|
|
const liveFallbacks = channels.filter((c) => c.type === 'live' && !lives.some((l) => String(l.id).toLowerCase() === String(c.channelHandle || c.id).toLowerCase()));
|
|
const allLives = [...lives, ...liveFallbacks];
|
|
|
|
const byDateDesc = (a, b) => Date.parse(b.publishedAt || '') - Date.parse(a.publishedAt || '');
|
|
const byViewsDesc = (a, b) => Number(b.views || 0) - Number(a.views || 0);
|
|
if (sort === 'date') {
|
|
vods = [...vods].sort(byDateDesc);
|
|
clips = [...clips].sort(byDateDesc);
|
|
} else if (sort === 'views') {
|
|
vods = [...vods].sort(byViewsDesc);
|
|
clips = [...clips].sort(byViewsDesc);
|
|
}
|
|
|
|
// Budgets : on remplit chaque section sans noyer les autres.
|
|
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).
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const item of merged) {
|
|
const key = `${item.type}:${String(item.id)}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(item);
|
|
}
|
|
// Filet de sécurité : ne jamais renvoyer une page thème vide quand
|
|
// Twitch est configuré (thèmes sans chaîne ni catégorie correspondante).
|
|
if (!out.length && targetPage === 1) {
|
|
try {
|
|
const top = await helixGet('/streams', new URLSearchParams({ first: String(Math.min(20, perPage)) }), auth, { retries: 1 });
|
|
const fallback = (Array.isArray(top?.data) ? top.data : []).map(mapStream);
|
|
return fallback;
|
|
} catch {}
|
|
}
|
|
return out;
|
|
} catch (error) {
|
|
console.error('Twitch search error:', error?.message || error);
|
|
return [];
|
|
}
|
|
},
|
|
};
|
|
|
|
export default handler;
|