/** * 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= 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, views: 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', /** * 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)); // 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) VODs : par broadcaster (pertinence requête) + par jeu (thèmes). 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) => { try { const data = await helixGet( '/videos', new URLSearchParams({ user_id: String(uid), first: '4', 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 }, ); 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 {} // 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) => { try { const data = await helixGet( '/clips', new URLSearchParams({ broadcaster_id: String(uid), first: '3', started_at: since }), 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 {} // 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, 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 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;