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`.
This commit is contained in:
2026-10-02 14:48:53 -04:00
parent 6f6d7ddde1
commit c45e04af42
5 changed files with 198 additions and 3 deletions
+70
View File
@@ -0,0 +1,70 @@
// Assertions offline sur le mapping d'un claim Odysee (API LBRY `resolve`).
// Fixtures calquées sur une VRAIE réponse (probe du 2026-10), pas inventées :
// value.video.duration en secondes, release_time en unix-secondes (chaîne),
// canal dans `signing_channel` (nom + vignette).
import { odyseeClaimToVideo } from '../providers/odysee.mjs';
let pass = 0;
const eq = (a, b, m) => {
const x = JSON.stringify(a), y = JSON.stringify(b);
if (x !== y) throw new Error(`${m}\n expected ${y}\n got ${x}`);
pass++; console.log(` V ${m}`);
};
const claim = (value, channel) => ({
result: {
[`${'kippers-sixth-birthday-and-shorts'}:87e34d33`]: {
value,
...(channel ? { signing_channel: channel } : {}),
},
},
});
const videoValue = {
title: 'Kipper’s Sixth Birthday And Shorts',
description: 'Une description réelle.',
thumbnail: { url: 'https://thumbs.odycdn.com/c2de27138d78b8acea50407bf54b2cd0.webp' },
video: { duration: 261, width: 1080, height: 1920 },
release_time: '1790668800',
channel_name: '@Kipper',
};
const signingChannel = { name: '@Kipper', claim_id: 'abc123', value: { thumbnail: { url: 'https://spee.ch/1/7b3c5b020f0a34a5.jpg' } } };
// --- Cas nominal ---
const ok = odyseeClaimToVideo(claim(videoValue, signingChannel));
eq(ok.title, 'Kipper’s Sixth Birthday And Shorts', 'titre mappé');
eq(ok.description, 'Une description réelle.', 'description mappée');
eq(ok.thumbnail, 'https://thumbs.odycdn.com/c2de27138d78b8acea50407bf54b2cd0.webp', 'vignette (odycdn) mappée');
eq(ok.duration, 261, 'durée = value.video.duration (secondes)');
eq(ok.releaseTime, 1790668800, 'release_time unix (chaîne) -> nombre');
eq(ok.uploaderName, '@Kipper', 'canal = signing_channel.name');
eq(ok.uploaderAvatar, 'https://spee.ch/1/7b3c5b020f0a34a5.jpg', 'avatar = vignette de la chaîne');
eq(new Date(ok.releaseTime * 1000).toISOString(), '2026-09-29T08:00:00.000Z', 'release_time -> ISO (route /api/details)');
eq('views' in ok, false, 'AUCUN compteur de vues (LBRY n\'en expose pas : ne jamais inventer 0)');
// --- Dégradations : tout doit être null ou neutre, jamais une valeur inventée ---
eq(odyseeClaimToVideo({ result: { x: { error: { name: 'NOT_FOUND' } } } }), null, 'claim introuvable -> null (l\'appelant garde les données du flux)');
eq(odyseeClaimToVideo({ result: {} }), null, 'résultat vide -> null');
eq(odyseeClaimToVideo(null), null, 'réponse nulle -> null');
eq(odyseeClaimToVideo(claim({ title: 'Post texte', description: 'x' })), null, 'claim SANS flux vidéo -> null (pas de faux titre de vidéo)');
eq(odyseeClaimToVideo(claim({ ...videoValue, video: undefined })), null, 'video absent -> null');
{
const partial = odyseeClaimToVideo(claim({ ...videoValue, video: {}, release_time: undefined, thumbnail: undefined, channel_name: undefined }));
eq(partial.duration, 0, 'durée inconnue -> 0 (la route l\'omet alors du JSON)');
eq(partial.releaseTime, 0, 'date inconnue -> 0');
eq(partial.thumbnail, '', 'vignette absente -> chaîne vide (jamais undefined)');
eq(partial.uploaderName, '', 'canal absent -> chaîne vide');
}
{
// Repli `value.channel_name` quand `signing_channel` manque (claim ancien).
const noChan = odyseeClaimToVideo(claim(videoValue));
eq(noChan.uploaderName, '@Kipper', 'repli sur value.channel_name sans signing_channel');
eq(noChan.uploaderAvatar, '', 'sans signing_channel, aucun avatar inventé');
}
{
// Repli `value.timestamp` quand `release_time` manque.
const ts = odyseeClaimToVideo(claim({ ...videoValue, release_time: undefined, timestamp: 1700000000 }));
eq(ts.releaseTime, 1700000000, 'repli sur value.timestamp');
}
console.log(`\n odysee-resolve: ${pass} assertions OK`);