feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s
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
This commit is contained in:
+143
-55
@@ -1,6 +1,112 @@
|
||||
/**
|
||||
* Twitch provider using Helix API
|
||||
* 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',
|
||||
@@ -11,60 +117,29 @@ const handler = {
|
||||
*/
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1 } = opts || {};
|
||||
const query = String(q || '').trim();
|
||||
if (!query) return [];
|
||||
|
||||
try {
|
||||
// First, get OAuth token
|
||||
const authResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.TWITCH_CLIENT_ID,
|
||||
client_secret: process.env.TWITCH_CLIENT_SECRET,
|
||||
grant_type: 'client_credentials'
|
||||
})
|
||||
});
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new Error(`Twitch auth error: ${authResponse.status}`);
|
||||
}
|
||||
|
||||
const { access_token } = await authResponse.json();
|
||||
|
||||
// Then search channels with cursor-based pagination
|
||||
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) {
|
||||
const params = new URLSearchParams({
|
||||
query: q,
|
||||
first: String(perPage)
|
||||
});
|
||||
if (after) params.set('after', after);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.twitch.tv/helix/search/channels?` + params.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Client-ID': process.env.TWITCH_CLIENT_ID,
|
||||
'Authorization': `Bearer ${access_token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Twitch API error: ${response.status}`);
|
||||
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);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (currentPage === targetPage) {
|
||||
lastData = Array.isArray(data.data) ? data.data : [];
|
||||
lastData = res.data;
|
||||
break;
|
||||
}
|
||||
const nextCursor = data?.pagination?.cursor;
|
||||
const nextCursor = res.cursor;
|
||||
if (!nextCursor) {
|
||||
lastData = [];
|
||||
break;
|
||||
@@ -73,19 +148,32 @@ const handler = {
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
return lastData.map(item => ({
|
||||
title: item.title || item.display_name,
|
||||
id: item.id,
|
||||
url: `https://www.twitch.tv/${item.broadcaster_login}`,
|
||||
thumbnail: item.thumbnail_url?.replace('{width}x{height}', '440x248'),
|
||||
uploaderName: item.display_name,
|
||||
type: 'stream',
|
||||
isLive: item.is_live || item.started_at
|
||||
}));
|
||||
// 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);
|
||||
console.error('Twitch search error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
export default handler;
|
||||
|
||||
export default handler;
|
||||
|
||||
Reference in New Issue
Block a user