CI / build-and-test (push) Successful in 13m34s
- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts) - ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage - ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error - Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
180 lines
6.4 KiB
JavaScript
180 lines
6.4 KiB
JavaScript
/**
|
|
* 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<Array<any>>}
|
|
*/
|
|
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=<id> -> erreur player.
|
|
// On les étiquette 'live' / 'channel' pour forcer l'embed ?channel=<login>.
|
|
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;
|