/** * Twitch provider using Helix API. * * Fix (2026-09): the App Access Token is now cached process-wide (they last * ~60 days) instead of being fetched on every search, and both the token * request and the Helix call retry once with a short backoff — the earlier * single-shot behaviour made the provider intermittently return [] when the * token endpoint or Helix hiccuped (observed on container cold starts). * Missing TWITCH_CLIENT_ID/SECRET now fail fast with a clear error instead of * calling the OAuth endpoint with undefined credentials. */ // Process-wide app access token cache 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) { // 400/403 = bad credentials: do not retry 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, // refresh 24h before actual expiry 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'); } async function helixSearchChannels(q, first, after, token, clientId) { let lastErr = null; for (let attempt = 0; attempt < 3; attempt++) { try { const params = new URLSearchParams({ query: q, first: String(first) }); if (after) params.set('after', after); const response = await fetchWithTimeout( `https://api.twitch.tv/helix/search/channels?${params.toString()}`, { headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${token}`, }, }, ); if (response.status === 401) { // token expired/revoked server-side: force refresh once, then retry const fresh = await getAppAccessToken(true); return { data: [], cursor: null, token: fresh.token, retry: true }; } if (!response.ok) throw new Error(`twitch_api_error_${response.status}`); const data = await response.json(); return { data: Array.isArray(data.data) ? data.data : [], cursor: data?.pagination?.cursor || null }; } catch (e) { lastErr = e; if (attempt < 2) await sleep(400 * (attempt + 1)); } } throw lastErr || new Error('twitch_api_failed'); } const handler = { id: 'tw', label: 'Twitch', /** * @param {string} q * @param {{ limit: number, page?: number }} opts * @returns {Promise>} */ async search(q, opts) { const { limit = 10, page = 1 } = opts || {}; const query = String(q || '').trim(); if (!query) return []; try { const appToken = await getAppAccessToken(); const perPage = Math.min(Math.max(1, Number(limit || 10)), 100); const targetPage = Math.max(1, Number(page || 1)); let after = ''; let currentPage = 1; let lastData = []; while (currentPage <= targetPage) { let res = await helixSearchChannels(query, perPage, after, appToken.token, appToken.clientId); // One forced retry with a fresh token when Helix rejects the cached one if (res.retry) { res = await helixSearchChannels(query, perPage, after, res.token, appToken.clientId); } if (currentPage === targetPage) { lastData = res.data; break; } const nextCursor = res.cursor; if (!nextCursor) { lastData = []; break; } after = String(nextCursor); currentPage++; } // NOTE: helix/search/channels ne retourne QUE des chaînes (jamais des VODs). // Les chaînes hors-ligne étaient étiquetées type 'video', ce qui faisait // croire au frontend à une VOD jouable via ?video= -> erreur player. // On les étiquette 'live' / 'channel' pour forcer l'embed ?channel=. return lastData.map((item) => { const login = item.broadcaster_login || item.user_login || undefined; const thumb = item.thumbnail_url || ''; return { title: item.title || item.display_name, id: item.id, url: login ? `https://www.twitch.tv/${login}` : undefined, thumbnail: thumb ? thumb.replace('{width}x{height}', '440x248') : undefined, uploaderName: item.display_name, channelId: item.id || undefined, channelHandle: login, channelExternalId: login, uploaderAvatar: thumb ? thumb.replace('{width}x{height}', '70x70') : undefined, type: item.is_live ? 'live' : 'channel', isLive: Boolean(item.is_live), }; }); } catch (error) { console.error('Twitch search error:', error?.message || error); return []; } }, }; export default handler;