Files
NewTube/server/providers/twitch.mjs
T
bruno 97e5de74f5
CI / build-and-test (push) Successful in 13m34s
feat(channel): page details chaine multi-providers avec onglets et infinite scroll
- 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
2026-09-25 10:15:12 -04:00

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;