/** Délai max de l'appel `resolve` (l'API LBRY répond en ~100-300 ms). */ const ODYSEE_RESOLVE_TIMEOUT_MS = Number(process.env.ODYSEE_RESOLVE_TIMEOUT_MS || 8000); /** * Résout un claim VIDÉO Odysee via l'API LBRY (`resolve`), en < 1 s. * * `slug` = le chemin Odysee tel que l'app le construit partout ailleurs * (`nom:claimId`, cf. `providerUrlFrom('odysee', …)`), il est résolu tel quel. * * Renvoie `null` si le claim est introuvable : l'appelant décide alors quoi * répondre. On ne retombe JAMAIS sur yt-dlp ici — son extracteur `lbry` met * ~45 s pour finir sur « No video formats found! » (mesuré), ce qui laissait * la page Watch sans titre ni description pendant une minute. * * @param {string} slug * @returns {Promise} */ export async function resolveOdyseeVideo(slug) { const claim = String(slug || '').trim().replace(/^\/+/, ''); if (!claim) return null; const body = { jsonrpc: '2.0', method: 'resolve', params: { urls: [claim] }, id: 1 }; const resp = await fetch('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(ODYSEE_RESOLVE_TIMEOUT_MS), }); if (!resp.ok) throw new Error(`odysee_resolve_http_${resp.status}`); return odyseeClaimToVideo(await resp.json()); } /** * Mappe une réponse `resolve` de LBRY vers les métadonnées de l'app (pure : * testable hors ligne, cf. `server/tests/odysee_resolve.test.mjs`). * Renvoie `null` pour un claim introuvable/erroné ou non-vidéo. * @param {any} json */ export function odyseeClaimToVideo(json) { const key = json?.result ? Object.keys(json.result)[0] : null; const claimObj = key ? json.result[key] : null; // `error` (NOT_FOUND, BLOCKED…) ou claim sans flux vidéo (post texte, claim de // chaîne) : pas de métadonnées de VIDÉO — on préfère `null` (l'appelant garde // les données du flux) plutôt que d'inventer un titre. if (!claimObj || claimObj.error || !claimObj.value || !claimObj.value.video) return null; const value = claimObj.value; const channel = claimObj.signing_channel || null; const release = Number(value.release_time ?? value.timestamp); const duration = Math.round(Number(value.video?.duration ?? value.duration) || 0); return { title: String(value.title || '').trim(), description: String(value.description || '').trim(), thumbnail: String(value.thumbnail?.url || '').trim(), duration: duration > 0 ? duration : 0, releaseTime: Number.isFinite(release) && release > 0 ? release : 0, uploaderName: String(channel?.name || value.channel_name || '').trim(), uploaderAvatar: String(channel?.value?.thumbnail?.url || '').trim(), }; } /** * Minimal Odysee provider handler */ const handler = { id: 'od', label: 'Odysee', /** * @param {string} q * @param {{ limit: number, page?: number }} opts * @returns {Promise>} */ async search(q, opts) { const { limit = 10, page = 1 } = opts || {}; try { const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); const pageNum = Math.max(1, Number(page || 1)); const params = new URLSearchParams({ s: q, size: perPage.toString(), from: ((pageNum - 1) * perPage).toString(), include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name,video', mediaType: 'video' }); const response = await fetch(`https://lighthouse.odysee.tv/search?${params.toString()}`); if (!response.ok) { throw new Error(`Odysee API error: ${response.status}`); } const data = await response.json(); return (Array.isArray(data) ? data : []).map(item => { const rawThumb = item.thumbnail_url || ''; const thumbnail = rawThumb ? rawThumb.startsWith('http') ? rawThumb : `https://thumbnails.odycdn.com/optimize/s:390:0/quality:85/plain/${rawThumb.replace(/^\//, '')}` : undefined; const name = item.name || ''; const claimId = item.claimId || item.claim_id || ''; const urlSegment = claimId ? `${encodeURIComponent(name)}:${claimId}` : encodeURIComponent(name); // Dimensions vidéo (signal d'orientation pour isShortItem) quand // lighthouse les expose — lecture défensive, aucun appel en plus. const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width); const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height); // Phase 1.2 - `release_time` est un timestamp unix (secondes) et etait // deja demande dans `include` mais jamais mappe : `publishedAt` etait donc // TOUJOURS undefined, ce qui rendait le filtre `period=` inoperant sur Odysee. const release = Number(item.release_time); // Phase 1.1 - `video.view_count` remonte par lighthouse quand `video` // est inclus. L'`effective_amount` est un montant LBC (bid), PAS un // nombre de vues : on ne l'utilise jamais comme compteur. const views = Number(item.video?.view_count ?? item.video?.views); return { title: item.title || name, id: claimId || name, url: claimId ? `https://odysee.com/${urlSegment}` : `https://odysee.com/${encodeURIComponent(name)}`, thumbnail, uploaderName: item.channel || undefined, type: 'video', duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined), ...(Number.isFinite(release) && release > 0 ? { publishedAt: new Date(release * 1000).toISOString() } : {}), ...(Number.isFinite(views) && views >= 0 ? { views: Math.round(views) } : {}), ...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}), ...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}), }; }); } catch (error) { console.error('Odysee search error:', error); return []; } } }; export default handler;