/** * Minimal PeerTube provider handler */ /** * Mapping d'une vidéo PeerTube vers le contrat `Suggestion`. * Exporté pour les tests : la règle « `isLive` → `type:'live'` + `kind:'live'` » * est le cœur de la capacité Live du provider ; sur un direct la durée (temps * écoulé, pas une longueur) n'est jamais émise, doctrine « jamais de 0 ». * * @param {any} item réponse brute de l'API PeerTube / sepiasearch * @returns {any} suggestion normalisée */ export function mapPeertubeItem(item) { const rawThumb = item.thumbnailUrl || item.thumbnailPath || ''; const thumbnail = rawThumb ? rawThumb.startsWith('http') ? rawThumb : rawThumb.startsWith('//') ? `https:${rawThumb}` : new URL(rawThumb, item.url || 'https://sepiasearch.org').toString() : undefined; // Dimensions max des fichiers (signal d'orientation pour isShortItem). // Défensif : sepiasearch n'expose pas toujours `files`. let fw = 0; let fh = 0; try { for (const f of (Array.isArray(item.files) ? item.files : [])) { const w = Number(f?.width); const h = Number(f?.height); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 && w * h >= fw * fh) { fw = w; fh = h; } } } catch {} const live = item.isLive === true; return { title: item.name, id: item.uuid, url: item.url, thumbnail, uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined, // Phase 1.8 - les avatars de compte ET de chaine sont exposes par // l'API sepiasearch mais n'etaient jamais mappes : la page chaine // affichait un avatar generique sur toutes les chaines PeerTube. uploaderAvatar: item.account?.avatars?.[0]?.path || item.account?.avatarUrl || undefined, channelAvatarUrl: item.channel?.avatars?.[0]?.path || item.channel?.avatarUrl || undefined, channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined, // Phase 1.7 - champs deja presents dans la reponse, jamais mappes. // `views` est le nom canonique du contrat (les 5 autres adaptateurs // l'emettaient) ; `viewCount` reste en alias pour ne pas casser un // consommateur existant. Trouve en gelant la sortie reelle (phase 8.4) : // le front lisait `views`, le serveur n'envoyait que `viewCount`, et les // compteurs PeerTube n'atteignaient donc jamais l'UI. views: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined, viewCount: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined, likes: Number.isFinite(Number(item.likes)) ? Number(item.likes) : undefined, publishedAt: item.publishedAt || item.createdAt || undefined, // L'API PeerTube renvoie `language` comme OBJET `{ id, label }` (et non // `{ code }` comme le laissait croire l'ancienne garde) : sans ce // tri, l'objet entier partait dans le contrat, où `language` est // déclaré `string`. Le front recevait `{id: null, label: 'Unknown'}` au // lieu d'un code ISO. Vu en gelant la sortie réelle (phase 8.4). // `label` est volontairement exclu : c'est une étiquette lisible // ("Unknown") et non un code — la publier produirait une langue // inventée, que la règle « absent = undefined » interdit. language: typeof item.language === 'string' ? item.language : (item.language?.code || item.language?.id || undefined), hasSubtitles: Array.isArray(item.subtitleFiles) ? item.subtitleFiles.length > 0 : undefined, // Direct : `type`/`kind`/'isLive' posés ensemble (doctrine Twitch phase // 3.2 — un des trois seul ne passe ni `isLiveItem()` ni la classification). type: live ? 'live' : 'video', kind: live ? 'live' : 'vod', ...(live ? { isLive: true, // `viewers` = spectateurs en direct (V2 du contrat), jamais des vues. ...(Number(item.viewers) > 0 ? { viewers: Number(item.viewers) } : {}), } : {}), duration: !live && typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined, ...(fw > 0 ? { width: fw } : {}), ...(fh > 0 ? { height: fh } : {}), }; } const handler = { id: 'pt', label: 'PeerTube', /** * @param {string} q * @param {{ limit: number, page?: number, filters?: { type?: string } }} opts * @returns {Promise>} */ async search(q, opts) { const { limit = 10, page = 1, filters } = opts || {}; try { // Use PeerTube API search const params = new URLSearchParams({ count: Math.min(limit, 50).toString(), start: Math.max(0, (Math.max(1, Number(page || 1)) - 1) * Math.min(limit, 50)).toString(), }); // Filtre Live : `isLive` est un paramètre natif de l'API PeerTube // (openapi, /videos + /search/videos) ; sepiasearch l'applique aussi // (probe 2026-10 : `search=news&isLive=true` → 0, sans texte → 113 directs). if (filters?.type === 'live') params.set('isLive', 'true'); if (q) params.set('search', q); const response = await fetch( `https://sepiasearch.org/api/v1/search/videos?${params}` ); if (!response.ok) { throw new Error(`PeerTube API error: ${response.status}`); } const data = await response.json(); return (data.data || []).map(mapPeertubeItem); } catch (error) { console.error('PeerTube search error:', error); return []; } } }; export default handler;