/** * Minimal PeerTube provider handler */ const handler = { id: 'pt', label: 'PeerTube', /** * @param {string} q * @param {{ limit: number, page?: number }} opts * @returns {Promise>} */ async search(q, opts) { const { limit = 10, page = 1 } = opts || {}; try { // Use PeerTube API search const response = await fetch( `https://sepiasearch.org/api/v1/search/videos?` + new URLSearchParams({ search: q, count: Math.min(limit, 50).toString(), start: Math.max(0, (Math.max(1, Number(page || 1)) - 1) * Math.min(limit, 50)).toString() }) ); if (!response.ok) { throw new Error(`PeerTube API error: ${response.status}`); } const data = await response.json(); return (data.data || []).map(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 {} 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, ce 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, type: 'video', kind: 'vod', duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined, ...(fw > 0 ? { width: fw } : {}), ...(fh > 0 ? { height: fh } : {}), }; }); } catch (error) { console.error('PeerTube search error:', error); return []; } } }; export default handler;