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:
@@ -0,0 +1,319 @@
|
||||
// Contenu d'une chaîne par provider : videos | shorts | playlists | live
|
||||
// Chaque fonction retourne { items: Suggestion[], nextPage: number|null, total }
|
||||
// Suggestion suit le format de /api/search (id,title,thumbnail,url,uploaderName,
|
||||
// duration,views,publishedAt,channelId,channelExternalId...).
|
||||
|
||||
const TIMEOUT_MS = Number(process.env.CHANNEL_CONTENT_TIMEOUT_MS || 9000);
|
||||
|
||||
function fetchWithTimeout(url, options = {}, timeoutMs = TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const { timeout, ...init } = options || {};
|
||||
return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function readJson(resp) {
|
||||
if (!resp.ok) throw new Error(`upstream_${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function ytKeys() {
|
||||
const keys = [];
|
||||
try {
|
||||
const raw = process.env.YOUTUBE_API_KEYS;
|
||||
if (raw && String(raw).trim()) {
|
||||
const s = String(raw).trim();
|
||||
if (s.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(s);
|
||||
if (Array.isArray(arr)) keys.push(...arr.map((v) => String(v || '').trim()).filter(Boolean));
|
||||
} catch {}
|
||||
} else keys.push(...s.split(',').map((v) => String(v || '').trim()).filter(Boolean));
|
||||
}
|
||||
} catch {}
|
||||
if (process.env.YOUTUBE_API_KEY) keys.push(String(process.env.YOUTUBE_API_KEY).trim());
|
||||
return [...new Set(keys.filter(Boolean))];
|
||||
}
|
||||
|
||||
async function ytGet(path, params) {
|
||||
const keys = ytKeys();
|
||||
if (!keys.length) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
|
||||
let lastErr = null;
|
||||
for (const key of keys) {
|
||||
const qs = new URLSearchParams({ ...params, key });
|
||||
try {
|
||||
const resp = await fetchWithTimeout(`https://www.googleapis.com/youtube/v3/${path}?${qs.toString()}`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) return data;
|
||||
lastErr = new Error(`youtube_${resp.status}`);
|
||||
const reason = data?.error?.errors?.[0]?.reason || '';
|
||||
if (!/quota|rateLimit|API_KEY_INVALID|expired/i.test(`${reason} ${data?.error?.message || ''}`)) break;
|
||||
} catch (e) { lastErr = e; break; }
|
||||
}
|
||||
throw lastErr || new Error('youtube_failed');
|
||||
}
|
||||
|
||||
function parseISODuration(iso) {
|
||||
const m = String(iso || '').match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (!m) return 0;
|
||||
return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0);
|
||||
}
|
||||
|
||||
async function resolveYouTubeChannelId(externalId) {
|
||||
const raw = String(externalId || '').trim();
|
||||
if (/^UC[\w-]{20,}$/.test(raw)) return raw;
|
||||
const handle = raw.replace(/^@/, '');
|
||||
// 1) channels?forHandle (fonctionne encore pour beaucoup de chaînes)
|
||||
try {
|
||||
const data = await ytGet('channels', { part: 'id', forHandle: handle });
|
||||
const id = data?.items?.[0]?.id;
|
||||
if (id) return id;
|
||||
} catch {}
|
||||
// 2) search type=channel
|
||||
try {
|
||||
const data = await ytGet('search', { part: 'snippet', q: handle, type: 'channel', maxResults: '1' });
|
||||
const id = data?.items?.[0]?.id?.channelId;
|
||||
if (id) return id;
|
||||
} catch {}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function ytVideoDetails(videoIds) {
|
||||
const map = new Map();
|
||||
if (!videoIds.length) return map;
|
||||
try {
|
||||
const data = await ytGet('videos', { part: 'contentDetails,statistics,status', id: videoIds.join(',') });
|
||||
for (const v of data?.items || []) if (v?.id) map.set(v.id, v);
|
||||
} catch {}
|
||||
return map;
|
||||
}
|
||||
|
||||
function ytSuggestion(item, details, channelId) {
|
||||
const videoId = item?.id?.videoId || item?.id;
|
||||
const sn = item?.snippet || {};
|
||||
const d = videoId ? details.get(videoId) : null;
|
||||
const secs = parseISODuration(d?.contentDetails?.duration);
|
||||
return {
|
||||
id: videoId,
|
||||
title: sn.title || '',
|
||||
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
|
||||
thumbnail: sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url,
|
||||
uploaderName: sn.channelTitle,
|
||||
type: 'video',
|
||||
duration: secs > 0 ? secs : undefined,
|
||||
views: d?.statistics?.viewCount != null ? Number(d.statistics.viewCount) : undefined,
|
||||
publishedAt: sn.publishedAt,
|
||||
channelId,
|
||||
channelExternalId: channelId,
|
||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
const channelId = await resolveYouTubeChannelId(externalId);
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
|
||||
if (type === 'playlists') {
|
||||
const data = await ytGet('playlists', {
|
||||
part: 'snippet,contentDetails', channelId, maxResults: String(perPage),
|
||||
pageToken: page > 1 ? undefined : undefined,
|
||||
}).catch(() => ({ items: [] }));
|
||||
const items = (data?.items || []).map((pl) => ({
|
||||
id: pl?.id,
|
||||
title: pl?.snippet?.title || '',
|
||||
thumbnail: pl?.snippet?.thumbnails?.high?.url || pl?.snippet?.thumbnails?.medium?.url,
|
||||
videoCount: typeof pl?.contentDetails?.itemCount === 'number' ? pl.contentDetails.itemCount : null,
|
||||
updatedAt: pl?.snippet?.publishedAt || null,
|
||||
}));
|
||||
return { items, nextPage: data?.nextPageToken ? (page || 1) + 1 : null, total: data?.pageInfo?.totalResults ?? null };
|
||||
}
|
||||
const order = sort === 'popular' ? 'viewCount' : sort === 'recent' ? 'date' : 'relevance';
|
||||
const params = {
|
||||
part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
};
|
||||
if (type === 'live') params.eventType = 'live';
|
||||
if (type === 'shorts') params.videoDuration = 'short';
|
||||
if (q) params.q = q;
|
||||
const data = await ytGet('search', params);
|
||||
const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean);
|
||||
const details = await ytVideoDetails(ids);
|
||||
let items = (data?.items || [])
|
||||
.filter((i) => i?.id?.videoId)
|
||||
.map((i) => ytSuggestion(i, details, channelId));
|
||||
if (type === 'shorts') items = items.filter((s) => !s.duration || s.duration <= 70);
|
||||
if (sort === 'popular') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0));
|
||||
return { items, nextPage: data?.nextPageToken ? (page || 1) + 1 : null, total: data?.pageInfo?.totalResults ?? null };
|
||||
}
|
||||
|
||||
// ---- Dailymotion ----
|
||||
async function dmContent(externalId, { type, page, limit, sort, q }) {
|
||||
const user = String(externalId || '').replace(/^@/, '');
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 100);
|
||||
if (type === 'playlists') {
|
||||
const qs = new URLSearchParams({ fields: 'id,name,thumbnail_url,videos_total', limit: String(perPage), page: String(page || 1) });
|
||||
const data = await readJson(await fetchWithTimeout(`https://api.dailymotion.com/user/${encodeURIComponent(user)}/playlists?${qs}`));
|
||||
return {
|
||||
items: (data?.list || []).map((p) => ({ id: p.id, title: p.name, thumbnail: p.thumbnail_url, videoCount: p.videos_total ?? null })),
|
||||
nextPage: data?.has_more ? (page || 1) + 1 : null,
|
||||
total: data?.total ?? null,
|
||||
};
|
||||
}
|
||||
if (type === 'live' || type === 'shorts') return { items: [], nextPage: null };
|
||||
const qs = new URLSearchParams({
|
||||
fields: 'id,title,thumbnail_720_url,thumbnail_480_url,duration,views_total,owner.id,owner.screenname,created_time',
|
||||
limit: String(perPage), page: String(page || 1),
|
||||
sort: sort === 'popular' ? 'visited' : 'recent',
|
||||
});
|
||||
if (q) qs.set('search', q);
|
||||
const data = await readJson(await fetchWithTimeout(`https://api.dailymotion.com/user/${encodeURIComponent(user)}/videos?${qs}`));
|
||||
let items = (data?.list || []).map((v) => ({
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_720_url || v.thumbnail_480_url,
|
||||
url: `https://www.dailymotion.com/video/${v.id}`, uploaderName: v['owner.screenname'],
|
||||
channelId: v['owner.id'], channelExternalId: v['owner.id'] || user,
|
||||
duration: Number(v.duration || 0), views: Number(v.views_total || 0),
|
||||
publishedAt: v.created_time ? new Date(v.created_time * 1000).toISOString() : undefined, type: 'video',
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
return { items, nextPage: data?.has_more ? (page || 1) + 1 : null, total: data?.total ?? null };
|
||||
}
|
||||
|
||||
// ---- Twitch ----
|
||||
async function twToken() {
|
||||
const id = process.env.TWITCH_CLIENT_ID;
|
||||
const secret = process.env.TWITCH_CLIENT_SECRET;
|
||||
if (!id || !secret) return null;
|
||||
const resp = await fetchWithTimeout('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ client_id: id, client_secret: secret, grant_type: 'client_credentials' }),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
return data?.access_token || null;
|
||||
}
|
||||
|
||||
async function twUser(externalId, headers) {
|
||||
const byId = /^\d+$/.test(String(externalId || ''));
|
||||
const data = await readJson(await fetchWithTimeout(
|
||||
`https://api.twitch.tv/helix/users?${byId ? 'id' : 'login'}=${encodeURIComponent(String(externalId).replace(/^@/, '').toLowerCase())}`,
|
||||
{ headers },
|
||||
));
|
||||
return data?.data?.[0] || null;
|
||||
}
|
||||
|
||||
async function twContent(externalId, { type, page, limit, sort, q }) {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const token = await twToken();
|
||||
if (!clientId || !token) return { items: [], nextPage: null };
|
||||
const headers = { 'Client-ID': clientId, Authorization: `Bearer ${token}` };
|
||||
const user = await twUser(externalId, headers).catch(() => null);
|
||||
if (!user) return { items: [], nextPage: null };
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 100);
|
||||
if (type === 'live') {
|
||||
const data = await readJson(await fetchWithTimeout(
|
||||
`https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(user.login)}&first=1`, { headers },
|
||||
)).catch(() => ({ data: [] }));
|
||||
const items = (data?.data || []).map((s) => ({
|
||||
id: s.id, title: s.title, thumbnail: String(s.thumbnail_url || '').replace('{width}', '1280').replace('{height}', '720'),
|
||||
url: `https://www.twitch.tv/${user.login}`, uploaderName: user.display_name,
|
||||
channelId: user.id, channelExternalId: user.id, views: Number(s.viewer_count || 0),
|
||||
publishedAt: s.started_at, type: 'video', kind: 'vod',
|
||||
}));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
if (type === 'playlists' || type === 'shorts') return { items: [], nextPage: null };
|
||||
const qs = new URLSearchParams({ user_id: user.id, first: String(perPage), type: 'archive', sort: sort === 'popular' ? 'views' : 'time' });
|
||||
const data = await readJson(await fetchWithTimeout(`https://api.twitch.tv/helix/videos?${qs}`, { headers }));
|
||||
let items = (data?.data || []).map((v) => ({
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_url, url: v.url,
|
||||
uploaderName: user.display_name, channelId: user.id, channelExternalId: user.id,
|
||||
duration: undefined, views: Number(v.view_count || 0), publishedAt: v.created_at, type: 'video', kind: 'vod',
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
const cursor = data?.pagination?.cursor;
|
||||
return { items, nextPage: cursor ? (page || 1) + 1 : null };
|
||||
}
|
||||
|
||||
// ---- PeerTube (externalId = instance|channel) ----
|
||||
async function ptContent(externalId, { type, page, limit, sort, q }) {
|
||||
const [instance, channel] = String(externalId || '').split('|');
|
||||
if (!instance || !channel) return { items: [], nextPage: null };
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 100);
|
||||
const start = ((Math.max(1, Number(page || 1))) - 1) * perPage;
|
||||
if (type === 'playlists') {
|
||||
const qs = new URLSearchParams({ start: String(start), count: String(perPage), sort: '-updatedAt' });
|
||||
const data = await readJson(await fetchWithTimeout(`https://${instance}/api/v1/video-channels/${encodeURIComponent(channel)}/video-playlists?${qs}`));
|
||||
return {
|
||||
items: (data?.data || []).map((p) => ({
|
||||
id: String(p.uuid || p.id), title: p.displayName || p.name,
|
||||
thumbnail: p?.thumbnailPath ? `https://${instance}${p.thumbnailPath}` : null,
|
||||
videoCount: typeof p.videosLength === 'number' ? p.videosLength : null, updatedAt: p.updatedAt || null,
|
||||
})),
|
||||
nextPage: (data?.data || []).length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null,
|
||||
};
|
||||
}
|
||||
if (type === 'live' || type === 'shorts') return { items: [], nextPage: null };
|
||||
const qs = new URLSearchParams({ start: String(start), count: String(perPage), sort: sort === 'popular' ? '-views' : '-publishedAt' });
|
||||
if (q) qs.set('search', q);
|
||||
const data = await readJson(await fetchWithTimeout(`https://${instance}/api/v1/video-channels/${encodeURIComponent(channel)}/videos?${qs}`));
|
||||
const items = (data?.data || []).map((v) => ({
|
||||
id: String(v.uuid || v.id), title: v.name, thumbnail: v?.thumbnailPath ? `https://${instance}${v.thumbnailPath}` : undefined,
|
||||
url: v?.url, uploaderName: v?.channel?.displayName || channel,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
duration: Number(v.duration || 0), views: Number(v.views || 0), publishedAt: v.publishedAt, type: 'video',
|
||||
}));
|
||||
return { items, nextPage: items.length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null };
|
||||
}
|
||||
|
||||
// ---- Odysee ----
|
||||
async function odContent(externalId, { type, page, limit, sort, q }) {
|
||||
if (type !== 'videos') return { items: [], nextPage: null };
|
||||
const claim = String(externalId || '').startsWith('@') ? String(externalId) : `@${String(externalId).replace(/^@/, '')}`;
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
|
||||
const body = {
|
||||
jsonrpc: '2.0', id: 1, method: 'claim_search',
|
||||
params: { channel: claim, page: Math.max(1, Number(page || 1)), page_size: perPage, claim_type: 'stream', order_by: sort === 'popular' ? ['effective_amount'] : ['release_time'] },
|
||||
};
|
||||
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
let items = ((data?.result?.items) || []).map((c) => ({
|
||||
id: c.claim_id, title: c?.value?.title, thumbnail: c?.value?.thumbnail?.url,
|
||||
url: c.short_url || c.canonical_url, uploaderName: c?.signing_channel?.value?.title || claim,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
duration: Number(c?.value?.video?.duration || 0), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined,
|
||||
type: 'video', slug: (c.short_url || '').replace('https://odysee.com/', ''),
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); }
|
||||
const totalPages = data?.result?.total_pages;
|
||||
return { items, nextPage: totalPages && (page || 1) < totalPages ? (page || 1) + 1 : (items.length >= perPage ? (page || 1) + 1 : null) };
|
||||
}
|
||||
|
||||
// ---- Rumble : pas d'API publique -> recherche unifiée filtrée par chaîne ----
|
||||
async function ruContent(externalId, { type, page, limit, q }, searchRegistry) {
|
||||
if (type !== 'videos') return { items: [], nextPage: null };
|
||||
try {
|
||||
const mod = searchRegistry?.ru;
|
||||
if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null };
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 });
|
||||
const items = (results || []).filter((r) => {
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
}).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
} catch { return { items: [], nextPage: null }; }
|
||||
}
|
||||
|
||||
export async function fetchChannelContent(provider, externalId, opts = {}, ctx = {}) {
|
||||
const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '' } = opts || {};
|
||||
switch (provider) {
|
||||
case 'yt': return ytContent(externalId, { type, page, limit, sort, q });
|
||||
case 'dm': return dmContent(externalId, { type, page, limit, sort, q });
|
||||
case 'tw': return twContent(externalId, { type, page, limit, sort, q });
|
||||
case 'pt': return ptContent(externalId, { type, page, limit, sort, q });
|
||||
case 'od': return odContent(externalId, { type, page, limit, sort, q });
|
||||
case 'ru': return ruContent(externalId, { type, page, limit, q }, ctx.searchRegistry);
|
||||
default: throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
@@ -20,7 +20,7 @@ const handler = {
|
||||
page: Math.max(1, Number(page || 1)).toString(),
|
||||
// Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date)
|
||||
sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')),
|
||||
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time'
|
||||
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time'
|
||||
})
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ const handler = {
|
||||
url: `https://www.dailymotion.com/video/${item.id}`,
|
||||
thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '',
|
||||
uploaderName: item['owner.screenname'] || item['owner.username'] || '',
|
||||
channelId: item['owner.id'] || item['owner.username'] || undefined,
|
||||
uploaderAvatar: item['owner.avatar_80_url'] || '',
|
||||
duration: Number(item.duration || 0),
|
||||
views: Number(item.views_total || 0),
|
||||
|
||||
@@ -44,6 +44,7 @@ const handler = {
|
||||
url: item.url,
|
||||
thumbnail,
|
||||
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
|
||||
channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined,
|
||||
type: 'video',
|
||||
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined
|
||||
};
|
||||
|
||||
@@ -15,7 +15,17 @@
|
||||
|
||||
/** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */
|
||||
|
||||
/** @type {Record<ProviderId, { id: ProviderId, label: string, search: (q: string, opts: { limit: number, page?: number }) => Promise<Suggestion[]> }>} */
|
||||
import channelRegistry from './channel-registry.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProviderAdapter
|
||||
* @property {ProviderId} id
|
||||
* @property {string} label
|
||||
* @property {(q: string, opts: { limit: number, page?: number, sort?: string }) => Promise<Suggestion[]>} search
|
||||
* @property {(externalId: string, ctx?: any) => Promise<any=} } [fetchChannelById]
|
||||
*/
|
||||
|
||||
/** @type {Record<ProviderId, ProviderAdapter>} */
|
||||
export const providerRegistry = {
|
||||
/** @type {any} */ yt: (await import('./youtube.mjs')).default,
|
||||
/** @type {any} */ dm: (await import('./dailymotion.mjs')).default,
|
||||
@@ -25,6 +35,13 @@ export const providerRegistry = {
|
||||
/** @type {any} */ ru: (await import('./rumble.mjs')).default,
|
||||
};
|
||||
|
||||
for (const [pid, adapter] of Object.entries(providerRegistry)) {
|
||||
const channelAdapter = channelRegistry[/** @type {ProviderId} */(pid)];
|
||||
if (channelAdapter && typeof channelAdapter.fetchChannelById === 'function' && !adapter.fetchChannelById) {
|
||||
adapter.fetchChannelById = channelAdapter.fetchChannelById;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a comma separated providers list
|
||||
* @param {string} input
|
||||
|
||||
+264
-135
@@ -1,154 +1,283 @@
|
||||
import { load } from 'cheerio';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Minimal Rumble provider handler
|
||||
* Rumble provider.
|
||||
*
|
||||
* Context (2026-09): rumble.com sits behind Cloudflare. Server-side fetches from
|
||||
* Node/OpenSSL receive 403 "Just a moment..." challenges on /search/* paths —
|
||||
* the TLS (JA3) fingerprint is rejected regardless of headers. Verified working
|
||||
* bypasses:
|
||||
* - curl built against Windows Schannel (dev machine only, not portable);
|
||||
* - python curl_cffi with browser impersonation (works on Linux/Docker).
|
||||
*
|
||||
* Fetch strategy, best-effort in order:
|
||||
* 1. Node fetch with a full browser header set + shared __cf_bm cookie jar
|
||||
* (works on networks where CF does not challenge this fingerprint);
|
||||
* 2. python3 + curl_cffi helper (rumble_fetch.py) impersonating Chrome —
|
||||
* the reliable path inside the Docker image (curl_cffi installed there);
|
||||
* 3. if everything is challenged, return [] — the unified search already
|
||||
* degrades gracefully per-provider.
|
||||
*/
|
||||
|
||||
const BROWSER_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'sec-ch-ua': '"Chromium";v="131", "Not_A Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'none',
|
||||
'sec-fetch-user': '?1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
};
|
||||
|
||||
// One shared cookie jar: Cloudflare issues __cf_bm on the first 200 and expects
|
||||
// it on subsequent requests. Refreshed lazily every ~25 minutes.
|
||||
let cookieJar = null;
|
||||
let cookieJarAt = 0;
|
||||
const COOKIE_JAR_TTL_MS = 25 * 60 * 1000;
|
||||
|
||||
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PYTHON_HELPER = path.join(MODULE_DIR, 'rumble_fetch.py');
|
||||
const PYTHON_HELPER_EXISTS = fs.existsSync(PYTHON_HELPER);
|
||||
|
||||
function mergeSetCookies(existing, setCookieHeaders) {
|
||||
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return existing;
|
||||
const jar = new Map();
|
||||
for (const pair of String(existing || '').split(';').map(s => s.trim()).filter(Boolean)) {
|
||||
const idx = pair.indexOf('=');
|
||||
if (idx > 0) jar.set(pair.slice(0, idx), pair.slice(idx + 1));
|
||||
}
|
||||
for (const raw of setCookieHeaders) {
|
||||
const first = String(raw || '').split(';')[0].trim();
|
||||
const idx = first.indexOf('=');
|
||||
if (idx > 0) jar.set(first.slice(0, idx), first.slice(idx + 1));
|
||||
}
|
||||
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
function headersWithCookies() {
|
||||
const headers = { ...BROWSER_HEADERS };
|
||||
if (cookieJar && (Date.now() - cookieJarAt) < COOKIE_JAR_TTL_MS) {
|
||||
headers['Cookie'] = cookieJar;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Node fetch with cookie-jar bookkeeping. Returns {status, html}. */
|
||||
async function nodeFetch(url, { timeoutMs = 12_000 } = {}) {
|
||||
const controller = new AbortController();
|
||||
const tid = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { headers: headersWithCookies(), redirect: 'follow', signal: controller.signal });
|
||||
try {
|
||||
const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
if (setCookies.length > 0) {
|
||||
cookieJar = mergeSetCookies(cookieJar, setCookies);
|
||||
cookieJarAt = Date.now();
|
||||
}
|
||||
} catch {}
|
||||
const html = await res.text();
|
||||
return { status: res.status, html };
|
||||
} finally {
|
||||
clearTimeout(tid);
|
||||
}
|
||||
}
|
||||
|
||||
/** python3 + curl_cffi helper. Returns {status, html} or null when unavailable. */
|
||||
async function pythonFetch(url, { timeoutMs = 20_000 } = {}) {
|
||||
if (!PYTHON_HELPER_EXISTS) return null;
|
||||
const pythons = ['python3', 'python'];
|
||||
for (const py of pythons) {
|
||||
const result = await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
||||
const child = spawn(py, [PYTHON_HELPER, url, String(Math.ceil(timeoutMs / 1000))], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
let stdout = '';
|
||||
let failed = false;
|
||||
const timer = setTimeout(() => { failed = true; try { child.kill(); } catch {} done(null); }, timeoutMs + 5_000);
|
||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
child.on('error', () => { clearTimeout(timer); done(null); });
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (failed) return;
|
||||
done(code === 0 && stdout.length > 1000 ? { status: 200, html: stdout } : null);
|
||||
});
|
||||
});
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Node fetch first, python/curl_cffi fallback. */
|
||||
async function fetchHtml(url) {
|
||||
try {
|
||||
const r = await nodeFetch(url);
|
||||
if (r.status === 200 && !isChallenge(r.html)) return r;
|
||||
} catch { /* fallback */ }
|
||||
const py = await pythonFetch(url);
|
||||
if (py) return py;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isChallenge(html) {
|
||||
return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000));
|
||||
}
|
||||
|
||||
/* --------------------------------- parsing -------------------------------- */
|
||||
|
||||
function parseDurationToSeconds(raw) {
|
||||
if (raw == null) return undefined;
|
||||
const value = String(raw).trim();
|
||||
if (!value) return undefined;
|
||||
// Garde-fou : les attributs datetime (dates de publication, ex.
|
||||
// "2026-09-23T18:11:52-04:00") ne sont JAMAIS des durées — sans ce test,
|
||||
// l'extraction des chiffres+':' produit des durées absurdes (~351840 s).
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return undefined;
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
// Rumble expose parfois des durées en millisecondes sur les cartes de
|
||||
// recherche (ex. 351840 ≈ 5:52). Au-delà de ~27 h en "secondes", c'est
|
||||
// quasi certainement des ms : on convertit (sans impact sur le filtre
|
||||
// Shorts, car >100 s reste une vidéo dans tous les cas).
|
||||
if (/^\d+(\.\d+)?$/.test(value) && numeric > 100000) return Math.floor(numeric / 1000);
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
const isoMatch = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
|
||||
if (isoMatch) {
|
||||
const total = (Number(isoMatch[1] || 0) * 3600) + (Number(isoMatch[2] || 0) * 60) + Number(isoMatch[3] || 0);
|
||||
if (total > 0) return total;
|
||||
}
|
||||
const textMatch = value.match(/^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds)?)?)?$/i);
|
||||
if (textMatch && (textMatch[1] || textMatch[2] || textMatch[3])) {
|
||||
const total = (Number(textMatch[1] || 0) * 3600) + (Number(textMatch[2] || 0) * 60) + Number(textMatch[3] || 0);
|
||||
if (total > 0) return total;
|
||||
}
|
||||
const colonCandidate = value.replace(/[^0-9:]/g, '');
|
||||
if (colonCandidate.includes(':')) {
|
||||
const segments = colonCandidate.split(':').filter(Boolean).map(s => Number(s));
|
||||
if (segments.length >= 2 && segments.every(n => Number.isFinite(n))) {
|
||||
while (segments.length < 3) segments.unshift(0);
|
||||
const [h, m, s] = segments.slice(-3);
|
||||
const total = (h * 3600) + (m * 60) + s;
|
||||
if (total > 0) return total;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeThumb(raw) {
|
||||
const t = String(raw || '').replace(/\s+/g, '').trim();
|
||||
if (!t) return undefined;
|
||||
return t.startsWith('//') ? `https:${t}` : t;
|
||||
}
|
||||
|
||||
/** Classic parser: li.video-listing-entry cards. */
|
||||
function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
const $ = load(html);
|
||||
const items = [];
|
||||
$('li.video-listing-entry').each((_idx, el) => {
|
||||
if (items.length >= limit) return false;
|
||||
const $el = $(el);
|
||||
const anchor = ($el.find('a.video-item--a').attr('href') || '').trim();
|
||||
if (!anchor) return;
|
||||
const img = $el.find('img.video-item--img');
|
||||
const rawThumbnail = img.attr('data-src') || img.attr('data-original') || img.attr('src') || '';
|
||||
const title = $el.find('h3.video-item--title').text().replace(/\s+/g, ' ').trim();
|
||||
const uploaderName = $el.find('.ellipsis-1').text().replace(/\s+/g, ' ').trim();
|
||||
// Strip tracking params (e9s, sci, …) from the anchor for a canonical URL
|
||||
const cleanAnchor = anchor.startsWith('http')
|
||||
? anchor
|
||||
: `https://rumble.com${anchor.split('?')[0]}`;
|
||||
const url = cleanAnchor;
|
||||
const id = $el.attr('data-id')
|
||||
|| (url.split('/').filter(Boolean).pop() || '').replace(/\.html$/, '')
|
||||
|| String(Math.random());
|
||||
// Rumble expose la durée dans <span class="video-item--duration" data-value="3:06:42">
|
||||
// (le span n'a pas de contenu texte). L'attribut datetime des <time> est
|
||||
// une DATE de publication — jamais une durée (cf. garde-fou ci-dessus).
|
||||
const durEl = $el.find('.video-item--duration, .video-item--meta time, .video-item--meta .duration').first();
|
||||
const durationCandidates = [
|
||||
$el.attr('data-duration'),
|
||||
$el.attr('data-video-duration'),
|
||||
$el.find('[data-duration]').attr('data-duration'),
|
||||
durEl.attr('data-value'),
|
||||
durEl.attr('data-duration'),
|
||||
durEl.attr('title'),
|
||||
durEl.text(),
|
||||
];
|
||||
let durationSeconds;
|
||||
for (const candidate of durationCandidates) {
|
||||
const parsed = parseDurationToSeconds(candidate);
|
||||
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
|
||||
}
|
||||
const viewsText = $el.find('.video-item--views').first().text().trim();
|
||||
const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined;
|
||||
items.push({
|
||||
title: title || url,
|
||||
id,
|
||||
url,
|
||||
thumbnail: normalizeThumb(rawThumbnail),
|
||||
uploaderName: uploaderName || undefined,
|
||||
views,
|
||||
type: 'video',
|
||||
duration: durationSeconds,
|
||||
// Pas de flag natif côté Rumble : durée courte (1..75 s) ⇒ Short probable.
|
||||
isShort: typeof durationSeconds === 'number' && durationSeconds > 0 && durationSeconds <= 75,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/* --------------------------------- handler -------------------------------- */
|
||||
|
||||
const handler = {
|
||||
id: 'ru',
|
||||
label: 'Rumble',
|
||||
/**
|
||||
* @param {string} q
|
||||
* @param {{ limit: number, page?: number }} opts
|
||||
* @param {{ limit: number, page?: number, sort?: string }} opts
|
||||
* @returns {Promise<Array<any>>}
|
||||
*/
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1 } = opts || {};
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const pageNum = Math.max(1, Number(page || 1));
|
||||
const query = String(q || '').trim();
|
||||
if (!query) return [];
|
||||
|
||||
// --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) ---
|
||||
try {
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const pageNum = Math.max(1, Number(page || 1));
|
||||
const params = new URLSearchParams({ q: q });
|
||||
if (pageNum > 1) params.set('page', pageNum.toString());
|
||||
|
||||
const response = await fetch(`https://rumble.com/search/video?${params.toString()}` , {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Rumble API error: ${response.status}`);
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (pageNum > 1) params.set('page', String(pageNum));
|
||||
const r = await fetchHtml(`https://rumble.com/search/video?${params.toString()}`);
|
||||
if (r && !isChallenge(r.html)) {
|
||||
const items = parseSearchHtml(r.html, { limit: perPage });
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
} catch { /* next */ }
|
||||
|
||||
const html = await response.text();
|
||||
const $ = load(html);
|
||||
const items = [];
|
||||
// --- Attempt 2: search/all (all-types page, video facet included) ---
|
||||
try {
|
||||
const params = new URLSearchParams({ 'search-videos': '1', q: query });
|
||||
if (pageNum > 1) params.set('page', String(pageNum));
|
||||
const r = await fetchHtml(`https://rumble.com/search/all?${params.toString()}`);
|
||||
if (r && !isChallenge(r.html)) {
|
||||
const items = parseSearchHtml(r.html, { limit: perPage });
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
} catch { /* give up */ }
|
||||
|
||||
const parseDurationToSeconds = (raw) => {
|
||||
if (raw == null) return undefined;
|
||||
const value = String(raw).trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
// Plain numeric seconds
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric);
|
||||
|
||||
// ISO-8601 style: PT#H#M#S
|
||||
const isoMatch = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
|
||||
if (isoMatch) {
|
||||
const hours = Number(isoMatch[1] || 0);
|
||||
const minutes = Number(isoMatch[2] || 0);
|
||||
const seconds = Number(isoMatch[3] || 0);
|
||||
const totalIso = (hours * 3600) + (minutes * 60) + seconds;
|
||||
if (totalIso > 0) return totalIso;
|
||||
}
|
||||
|
||||
// Text formats like "1h 2m 3s" or "15m13s"
|
||||
const textMatch = value.match(/^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds)?)?)?$/i);
|
||||
if (textMatch && (textMatch[1] || textMatch[2] || textMatch[3])) {
|
||||
const hours = Number(textMatch[1] || 0);
|
||||
const minutes = Number(textMatch[2] || 0);
|
||||
const seconds = Number(textMatch[3] || 0);
|
||||
const totalText = (hours * 3600) + (minutes * 60) + seconds;
|
||||
if (totalText > 0) return totalText;
|
||||
}
|
||||
|
||||
// Colon separated HH:MM:SS or MM:SS
|
||||
const colonCandidate = value.replace(/[^0-9:]/g, '');
|
||||
if (colonCandidate.includes(':')) {
|
||||
const segments = colonCandidate.split(':').filter(Boolean).map(s => Number(s));
|
||||
if (segments.length >= 2 && segments.every(n => Number.isFinite(n))) {
|
||||
while (segments.length < 3) segments.unshift(0);
|
||||
const [hours, minutes, seconds] = segments.slice(-3);
|
||||
const totalColon = (hours * 3600) + (minutes * 60) + seconds;
|
||||
if (totalColon > 0) return totalColon;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: first integer found, assume seconds if >0
|
||||
const fallbackDigits = value.match(/(\d+)/);
|
||||
if (fallbackDigits) {
|
||||
const seconds = Number(fallbackDigits[1]);
|
||||
if (Number.isFinite(seconds) && seconds > 0) return seconds;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
$('li.video-listing-entry').each((_idx, el) => {
|
||||
if (items.length >= perPage) return false;
|
||||
const $el = $(el);
|
||||
const anchor = ($el.find('a.video-item--a').attr('href') || '').trim();
|
||||
if (!anchor) return;
|
||||
|
||||
const img = $el.find('img.video-item--img');
|
||||
const rawThumbnail =
|
||||
img.attr('data-src') ||
|
||||
img.attr('data-original') ||
|
||||
img.attr('src') ||
|
||||
'';
|
||||
const normalizedThumb = rawThumbnail.replace(/\s+/g, '').trim();
|
||||
const title = $el.find('h3.video-item--title').text().replace(/\s+/g, ' ').trim();
|
||||
const uploaderName = $el.find('.ellipsis-1').text().replace(/\s+/g, ' ').trim();
|
||||
|
||||
const url = anchor.startsWith('http') ? anchor : `https://rumble.com${anchor}`;
|
||||
|
||||
const id =
|
||||
$el.attr('data-id') ||
|
||||
url.split('/').filter(Boolean).pop() ||
|
||||
String(Math.random());
|
||||
|
||||
const thumbnail = normalizedThumb
|
||||
? normalizedThumb.startsWith('//')
|
||||
? `https:${normalizedThumb}`
|
||||
: normalizedThumb
|
||||
: undefined;
|
||||
|
||||
const durationCandidates = [
|
||||
$el.attr('data-duration'),
|
||||
$el.attr('data-video-duration'),
|
||||
$el.data('duration'),
|
||||
$el.find('[data-duration]').attr('data-duration'),
|
||||
$el.find('[data-video-duration]').attr('data-video-duration'),
|
||||
$el.find('time[datetime]').attr('datetime'),
|
||||
$el.find('.video-item--duration, .video-item--meta time, .video-item--meta .duration, .video-item--meta-duration, .video-item--length').first().text(),
|
||||
];
|
||||
|
||||
let durationSeconds;
|
||||
for (const candidate of durationCandidates) {
|
||||
const parsed = parseDurationToSeconds(candidate);
|
||||
if (typeof parsed === 'number' && parsed > 0) {
|
||||
durationSeconds = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: title || url,
|
||||
id,
|
||||
url,
|
||||
thumbnail,
|
||||
uploaderName: uploaderName || undefined,
|
||||
type: 'video',
|
||||
duration: durationSeconds
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
} catch (error) {
|
||||
console.error('Rumble search error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rumble Cloudflare bypass fetcher.
|
||||
|
||||
Node's TLS fingerprint (OpenSSL) is blocked by Rumble's Cloudflare config, while
|
||||
curl_cffi's browser impersonation passes. This helper fetches a URL with
|
||||
curl_cffi impersonating a Chrome client and prints the raw HTML to stdout.
|
||||
|
||||
Usage: python3 rumble_fetch.py <url> [timeout_seconds]
|
||||
Exit codes: 0 = ok (stdout = html), 2 = HTTP error / challenge, 3 = curl_cffi missing.
|
||||
"""
|
||||
import sys
|
||||
|
||||
URL = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
TIMEOUT = int(sys.argv[2]) if len(sys.argv) > 2 else 15
|
||||
|
||||
try:
|
||||
from curl_cffi import requests as curl_requests
|
||||
except ImportError:
|
||||
sys.stderr.write("curl_cffi not installed\n")
|
||||
sys.exit(3)
|
||||
|
||||
if not URL:
|
||||
sys.stderr.write("url required\n")
|
||||
sys.exit(2)
|
||||
|
||||
IMPERSONATE_CANDIDATES = ["chrome131", "chrome124", "chrome120", "safari18_0"]
|
||||
|
||||
for target in IMPERSONATE_CANDIDATES:
|
||||
try:
|
||||
resp = curl_requests.get(
|
||||
URL,
|
||||
impersonate=target,
|
||||
timeout=TIMEOUT,
|
||||
allow_redirects=True,
|
||||
headers={
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
body = resp.text or ""
|
||||
if resp.status_code == 200 and "Just a moment" not in body[:4000]:
|
||||
sys.stdout.write(body)
|
||||
sys.exit(0)
|
||||
# challenge or error: try next impersonation target
|
||||
last_status = resp.status_code
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.stderr.write(f"{target}: {exc}\n")
|
||||
last_status = 0
|
||||
|
||||
sys.stderr.write(f"all impersonation targets failed (last={last_status})\n")
|
||||
sys.exit(2)
|
||||
+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;
|
||||
|
||||
+106
-25
@@ -20,6 +20,78 @@ function parseISODurationToSeconds(iso) {
|
||||
return (hours * 3600) + (minutes * 60) + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve configured YouTube API keys.
|
||||
* Accepts YOUTUBE_API_KEYS as JSON array ('["k1","k2"]') or CSV ('k1,k2'),
|
||||
* plus the legacy single YOUTUBE_API_KEY as fallback. De-duplicated.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getYouTubeKeys() {
|
||||
const keys = [];
|
||||
try {
|
||||
const raw = process.env.YOUTUBE_API_KEYS;
|
||||
if (raw && String(raw).trim() && String(raw).trim() !== 'undefined' && String(raw).trim() !== 'null') {
|
||||
const s = String(raw).trim();
|
||||
if (s.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(s);
|
||||
if (Array.isArray(arr)) keys.push(...arr.map(v => String(v || '').trim()).filter(Boolean));
|
||||
} catch {}
|
||||
} else {
|
||||
keys.push(...s.split(',').map(v => String(v || '').trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const single = process.env.YOUTUBE_API_KEY;
|
||||
if (single && String(single).trim()) keys.push(String(single).trim());
|
||||
} catch {}
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a YouTube API failure as retryable with another key.
|
||||
* - 400 API_KEY_INVALID / "API key expired" -> key is dead, try next.
|
||||
* - 403 quotaExceeded / rateLimitExceeded / dailyLimitExceeded -> quota, try next.
|
||||
*/
|
||||
function isKeyFailure(status, data) {
|
||||
try {
|
||||
const reason = data?.error?.errors?.[0]?.reason || '';
|
||||
const message = String(data?.error?.message || '');
|
||||
if (status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(message))) return true;
|
||||
if (status === 403 && /quota|rateLimit|dailyLimit|userRateLimit/i.test(reason + ' ' + message)) return true;
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** GET JSON from YouTube, rotating through keys on key failures. */
|
||||
async function ytFetchJson(base, paramsWithoutKey) {
|
||||
const keys = getYouTubeKeys();
|
||||
if (!keys.length) {
|
||||
throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' });
|
||||
}
|
||||
let lastError = null;
|
||||
let lastStatus = 500;
|
||||
let lastData = null;
|
||||
for (const key of keys) {
|
||||
const params = new URLSearchParams(paramsWithoutKey);
|
||||
params.set('key', key);
|
||||
const resp = await fetch(`${base}?${params.toString()}`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) return data;
|
||||
lastStatus = resp.status;
|
||||
lastData = data;
|
||||
lastError = new Error(`YouTube API error: ${resp.status} ${data?.error?.message || ''}`.trim());
|
||||
// Only rotate to the next key on key-attributable failures; otherwise fail fast.
|
||||
if (!isKeyFailure(resp.status, data)) break;
|
||||
console.warn(`[YouTube] key ...${String(key).slice(-4)} failed (${resp.status}), trying next key`);
|
||||
}
|
||||
const err = lastError || new Error(`YouTube API error: ${lastStatus}`);
|
||||
err.ytStatus = lastStatus;
|
||||
err.ytData = lastData;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
|
||||
const handler = {
|
||||
id: 'yt',
|
||||
@@ -27,9 +99,9 @@ const handler = {
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
|
||||
try {
|
||||
const API_KEY = process.env.YOUTUBE_API_KEY;
|
||||
if (!API_KEY) {
|
||||
throw new Error('YOUTUBE_API_KEY not configured');
|
||||
const keys = getYouTubeKeys();
|
||||
if (!keys.length) {
|
||||
throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' });
|
||||
}
|
||||
|
||||
let order = 'relevance';
|
||||
@@ -44,21 +116,20 @@ const handler = {
|
||||
let lastItems = [];
|
||||
|
||||
while (currentPage <= targetPage) {
|
||||
const params = new URLSearchParams({
|
||||
const params = {
|
||||
part: 'snippet',
|
||||
q: q,
|
||||
type: 'video',
|
||||
maxResults: String(perPage),
|
||||
key: API_KEY,
|
||||
order
|
||||
});
|
||||
if (pageToken) params.set('pageToken', pageToken);
|
||||
order,
|
||||
// Ne retourner que des vidéos lisibles en embed (évite l'erreur 153 côté player).
|
||||
// Les vidéos non-embeddables sont filtrées via le champ status ci-dessous pour /videos.
|
||||
videoEmbeddable: 'true',
|
||||
safeSearch: 'moderate',
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
|
||||
const response = await fetch(`https://www.googleapis.com/youtube/v3/search?` + params.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`YouTube API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
||||
|
||||
if (currentPage === targetPage) {
|
||||
lastItems = Array.isArray(data.items) ? data.items : [];
|
||||
@@ -82,18 +153,16 @@ const handler = {
|
||||
|
||||
const detailsMap = new Map();
|
||||
if (videoIds.length > 0) {
|
||||
const detailsParams = new URLSearchParams({
|
||||
part: 'contentDetails,statistics',
|
||||
id: videoIds.join(','),
|
||||
key: API_KEY
|
||||
});
|
||||
|
||||
const detailsResp = await fetch(`https://www.googleapis.com/youtube/v3/videos?${detailsParams.toString()}`);
|
||||
if (detailsResp.ok) {
|
||||
const detailsData = await detailsResp.json();
|
||||
try {
|
||||
const detailsData = await ytFetchJson('https://www.googleapis.com/youtube/v3/videos', {
|
||||
part: 'contentDetails,statistics,status',
|
||||
id: videoIds.join(','),
|
||||
});
|
||||
for (const vid of detailsData?.items || []) {
|
||||
if (vid?.id) detailsMap.set(vid.id, vid);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +178,10 @@ const handler = {
|
||||
const isoDuration = details?.contentDetails?.duration || '';
|
||||
const duration = parseISODurationToSeconds(isoDuration);
|
||||
const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined;
|
||||
const channelId = snippet.channelId || undefined;
|
||||
const channelHandle = snippet.channelTitle || undefined;
|
||||
// status.embeddable === false -> le player renvoie l'erreur 153 ("Video configuration error").
|
||||
const embeddable = details?.status ? details.status.embeddable !== false : undefined;
|
||||
|
||||
return {
|
||||
title: snippet.title || '',
|
||||
@@ -119,12 +192,20 @@ const handler = {
|
||||
type: 'video',
|
||||
duration: duration > 0 ? duration : undefined,
|
||||
views,
|
||||
publishedAt: snippet.publishedAt || undefined
|
||||
publishedAt: snippet.publishedAt || undefined,
|
||||
channelId,
|
||||
channelHandle,
|
||||
channelExternalId: channelId,
|
||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined,
|
||||
embeddable,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('YouTube search error:', error);
|
||||
return [];
|
||||
// Log explicite (clé expirée / quota) puis remonte l'erreur pour que
|
||||
// /api/search la reporte dans `errors.yt` au lieu d'un groupe vide silencieux.
|
||||
const status = error?.ytStatus || 500;
|
||||
console.error(`YouTube search error (status ${status}):`, error?.message || error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user