const DEFAULT_TIMEOUT_MS = Number(process.env.CHANNEL_FETCH_TIMEOUT_MS || 6000); // Fournisseur de token Twitch branché par server/index.mjs (qui gère le cache // et le renouvellement via TWITCH_CLIENT_ID/SECRET). Sans lui, les chaînes // Twitch retombent sur des métadonnées vides (titre/avatar null -> pas de logo). let twitchTokenProvider = null; export function setTwitchTokenProvider(fn) { twitchTokenProvider = typeof fn === 'function' ? fn : null; } async function getTwitchToken() { try { if (twitchTokenProvider) { const t = await twitchTokenProvider(); if (t) return t; } } catch {} return process.env.TWITCH_APP_ACCESS_TOKEN || null; } async function fetchWithTimeout(url, options = {}) { const { timeout = DEFAULT_TIMEOUT_MS, transform, ...init } = options || {}; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const resp = await fetch(url, { ...init, signal: controller.signal }); if (!resp.ok) throw new Error(`fetch_failed_${resp.status}`); const data = transform ? await transform(resp) : await resp.json(); return data; } finally { clearTimeout(timer); } } function safeMeta(meta = {}, fallback = {}) { return { provider: fallback.provider, externalId: fallback.externalId, title: meta.title ?? fallback.title, handle: meta.handle ?? fallback.handle, avatarUrl: meta.avatarUrl ?? fallback.avatarUrl, url: meta.url ?? fallback.url, subsCount: typeof meta.subsCount === 'number' ? meta.subsCount : fallback.subsCount, verified: typeof meta.verified === 'boolean' ? meta.verified : (typeof fallback.verified === 'boolean' ? fallback.verified : false), }; } async function fetchYoutubeChannel(externalId) { const key = process.env.YOUTUBE_API_KEY; if (!key) { return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` }; } try { const params = new URLSearchParams({ part: 'snippet,statistics,brandingSettings', id: externalId, key, }); const data = await fetchWithTimeout(`https://www.googleapis.com/youtube/v3/channels?${params.toString()}`); const item = data?.items?.[0]; if (!item) throw new Error('channel_not_found'); const snippet = item.snippet || {}; const thumbnails = snippet.thumbnails || {}; const stats = item.statistics || {}; const branding = item.brandingSettings || {}; const avatars = thumbnails.high?.url || thumbnails.medium?.url || thumbnails.default?.url; const url = branding.channel?.customUrl ? `https://www.youtube.com/${branding.channel.customUrl}` : `https://www.youtube.com/channel/${externalId}`; return { provider: 'yt', externalId, title: snippet.title || branding.channel?.title, handle: snippet.customUrl ? `@${snippet.customUrl.replace(/^@/, '')}` : undefined, avatarUrl: avatars, url, subsCount: stats.subscriberCount ? Number(stats.subscriberCount) : undefined, verified: Array.isArray(snippet.badges) ? snippet.badges.includes('verified') : undefined, }; } catch { return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` }; } } async function fetchDailymotionChannel(externalId) { try { // NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur // toute la requête) — seuls avatar_720_url / avatar_medium_url le sont. const params = new URLSearchParams({ fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified', }); const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`); const username = data.username || externalId; return safeMeta({ title: data.screenname || data.username, handle: data.username ? `@${data.username}` : undefined, avatarUrl: data.avatar_720_url || data.avatar_medium_url, url: `https://www.dailymotion.com/user/${username}`, subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined, verified: Boolean(data.verified), }, { provider: 'dm', externalId, url: `https://www.dailymotion.com/user/${externalId}` }); } catch { return { provider: 'dm', externalId, url: `https://www.dailymotion.com/user/${externalId}` }; } } async function fetchTwitchChannel(externalId) { try { const clientId = process.env.TWITCH_CLIENT_ID; const token = await getTwitchToken(); if (clientId && token) { // Les anciens abonnements peuvent stocker l'id numérique : Helix exige // ?id= dans ce cas (?login= ne matche que le login). const byId = /^\d+$/.test(String(externalId || '')); const data = await fetchWithTimeout( `https://api.twitch.tv/helix/users?${byId ? 'id' : 'login'}=${encodeURIComponent(externalId)}`, { headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${token}`, }, } ); const user = data?.data?.[0]; if (user) { return safeMeta({ title: user.display_name, handle: `@${user.login}`, avatarUrl: user.profile_image_url, url: `https://www.twitch.tv/${user.login}`, subsCount: typeof user.view_count === 'number' ? user.view_count : undefined, }, { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` }); } } } catch {} return { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` }; } function parsePeerTubeExternalId(externalId) { const [instance, channel] = String(externalId).split('|'); if (!channel) return { instance: null, channel: externalId }; return { instance, channel }; } async function fetchPeerTubeChannel(externalId) { const { instance, channel } = parsePeerTubeExternalId(externalId); if (!instance) { return { provider: 'pt', externalId, url: `https://${externalId}` }; } try { const data = await fetchWithTimeout(`https://${instance}/api/v1/video-channels/${encodeURIComponent(channel)}`); return safeMeta({ title: data.displayName || data.name, handle: data.host ? `@${data.name}@${data.host}` : undefined, avatarUrl: data?.avatar?.path ? `https://${instance}${data.avatar.path}` : undefined, url: data?.url || `https://${instance}/video-channels/${channel}`, subsCount: typeof data.followersCount === 'number' ? data.followersCount : undefined, verified: Boolean(data.ownerAccount?.verified) }, { provider: 'pt', externalId, url: `https://${instance}/video-channels/${channel}` }); } catch { return { provider: 'pt', externalId, url: instance ? `https://${instance}/video-channels/${channel}` : undefined }; } } async function fetchOdyseeChannel(externalId) { try { const body = { jsonrpc: '2.0', method: 'resolve', params: { urls: [externalId.startsWith('@') ? externalId : `@${externalId}`] }, id: 1 }; const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), transform: res => res.json(), timeout: DEFAULT_TIMEOUT_MS }); const result = resp?.result; const key = result ? Object.keys(result)[0] : null; const meta = key ? result[key] : null; if (meta?.value) { const value = meta.value; return safeMeta({ title: value?.title, handle: meta.short_url ? meta.short_url.replace('https://odysee.com/', '') : undefined, avatarUrl: meta?.thumbnail?.url, url: meta.short_url, subsCount: typeof meta?.meta?.effective_amount === 'number' ? meta.meta.effective_amount : undefined, }, { provider: 'od', externalId, url: meta.short_url }); } } catch {} return { provider: 'od', externalId, url: `https://odysee.com/${externalId.replace(/^@/, '')}` }; } async function fetchRumbleChannel(externalId) { try { const data = await fetchWithTimeout(`https://rumble.com/${externalId}`, { transform: async (res) => res.text() }); const titleMatch = /