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
228 lines
8.8 KiB
JavaScript
228 lines
8.8 KiB
JavaScript
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 {
|
|
const params = new URLSearchParams({
|
|
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,avatar_url,url,followers_total,verified',
|
|
});
|
|
const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`);
|
|
return safeMeta({
|
|
title: data.screenname || data.username,
|
|
handle: data.username ? `@${data.username}` : undefined,
|
|
avatarUrl: data.avatar_720_url || data.avatar_medium_url || data.avatar_url,
|
|
url: data.url,
|
|
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
|
|
verified: Boolean(data.verified),
|
|
}, { provider: 'dm', externalId, url: `https://www.dailymotion.com/${externalId}` });
|
|
} catch {
|
|
return { provider: 'dm', externalId, url: `https://www.dailymotion.com/${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 = /<title>([^<]+)<\/title>/i.exec(data);
|
|
const avatarMatch = /property="og:image" content="([^"]+)"/i.exec(data);
|
|
const name = titleMatch ? titleMatch[1].replace(/ on Rumble.*$/i, '').trim() : undefined;
|
|
return safeMeta({
|
|
title: name,
|
|
avatarUrl: avatarMatch ? avatarMatch[1] : undefined,
|
|
url: `https://rumble.com/${externalId}`,
|
|
}, { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` });
|
|
} catch {
|
|
return { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` };
|
|
}
|
|
}
|
|
|
|
export const channelRegistry = {
|
|
yt: { fetchChannelById: fetchYoutubeChannel },
|
|
dm: { fetchChannelById: fetchDailymotionChannel },
|
|
tw: { fetchChannelById: fetchTwitchChannel },
|
|
pt: { fetchChannelById: fetchPeerTubeChannel },
|
|
od: { fetchChannelById: fetchOdyseeChannel },
|
|
ru: { fetchChannelById: fetchRumbleChannel },
|
|
};
|
|
|
|
export function getChannelAdapter(provider) {
|
|
return channelRegistry[provider];
|
|
}
|
|
|
|
export default channelRegistry;
|