Files
NewTube/server/providers/odysee.mjs
T
bruno c45e04af42 perf(details): Odysee par l'API LBRY + cache des métadonnées (45 s → 0,2 s)
Mesuré avant : `/api/details/odysee/…` = 44,7 s puis « No video formats
found! » — la page Watch restait une minute sans titre, vues, description ni
vignette. L'extracteur `lbry` de yt-dlp n'est pas utilisable ici.

- Nouvelle branche Odysee dans /api/details : `resolve` de l'API LBRY
  (`server/providers/odysee.mjs` → `resolveOdyseeVideo`) = 229 ms mesurés,
  titre/description/vignette/durée/date/canal + avatar. Claim introuvable →
  404 `odysee_claim_not_found` (le client garde les données du flux), panne →
  502 `odysee_details_failed`. Jamais de repli yt-dlp (45 s pour échouer).
- `views` volontairement OMIS pour Odysee : ni LBRY ni lighthouse n'exposent
  de compteur pour un claim isolé (l'`effective_amount` est un montant LBC).
- Cache mémoire de /api/details : 6 h, LRU 500 (`DETAILS_CACHE_TTL_MS`,
  `DETAILS_CACHE_MAX_ENTRIES`) — métadonnées immuables, chaque /watch
  relançait un dump complet.
- `views`/`duration` ne sont plus émis quand ils valent 0 : un 0 écrasait la
  valeur du flux côté client (merge sur `typeof === 'number'`), d'où les
  « 0 vue » sur les fournisseurs muets — même doctrine que
  `parseRumbleViews` (« jamais de compteur à 0 »).
- Mapping pur `odyseeClaimToVideo` testé hors ligne : 21 assertions,
  `npm run test:odysee`.
2026-10-02 14:48:53 -04:00

140 lines
6.2 KiB
JavaScript

/** 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<null | {title:string, description:string, thumbnail:string, duration:number, releaseTime:number, uploaderName:string, uploaderAvatar:string}>}
*/
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<Array<any>>}
*/
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;