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
+66 -2
View File
@@ -1525,6 +1525,32 @@ function ytRelatedCacheSet(key, items) {
ytRelatedCache.set(key, { ts: Date.now(), items });
while (ytRelatedCache.size > 200) { const o = ytRelatedCache.keys().next().value; if (o === undefined) break; ytRelatedCache.delete(o); }
}
// Cache des métadonnées de /api/details : titre, description, vignette, durée et
// date de publication d'une vidéo sont immuables → TTL long (6 h par défaut).
// Sans lui, chaque ouverture de /watch relançait un dump yt-dlp complet (2-5 s,
// et ~45 s pour Odysee qui finissait en erreur).
const DETAILS_CACHE_TTL_MS = Number(process.env.DETAILS_CACHE_TTL_MS || 6 * 60 * 60 * 1000);
const DETAILS_CACHE_MAX_ENTRIES = Number(process.env.DETAILS_CACHE_MAX_ENTRIES || 500);
const detailsCache = new Map();
function detailsCacheGet(key) {
const hit = detailsCache.get(key);
if (!hit) return null;
// Récence = LRU naïf (même modèle que `ytCache`).
detailsCache.delete(key);
detailsCache.set(key, hit);
if ((Date.now() - hit.ts) >= DETAILS_CACHE_TTL_MS) { detailsCache.delete(key); return null; }
return hit.data;
}
function detailsCacheSet(key, data) {
if (detailsCache.has(key)) detailsCache.delete(key);
detailsCache.set(key, { ts: Date.now(), data });
while (detailsCache.size > DETAILS_CACHE_MAX_ENTRIES) {
const oldest = detailsCache.keys().next().value;
if (oldest === undefined) break;
detailsCache.delete(oldest);
}
}
// Returns metadata such as title, description, uploader, thumbnail, duration and views for a provider/videoId
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
r.get('/details/:provider/:videoId', async (req, res) => {
@@ -1534,6 +1560,38 @@ r.get('/details/:provider/:videoId', async (req, res) => {
const slug = req.query.slug || undefined;
const sourceUrl = req.query.sourceUrl || undefined;
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const cacheKey = `${provider}:${videoId}:${instance || ''}:${slug || ''}`;
const cached = detailsCacheGet(cacheKey);
if (cached) return res.json(cached);
// Odysee : l'API LBRY (`resolve`) répond en ~100-300 ms là où l'extracteur
// yt-dlp met ~45 s pour finir sur « No video formats found! » (mesuré). Sans
// cette branche, la page Watch restait une minute sans titre, description ni
// vignette. `views` est volontairement OMIS (aucun compteur fiable pour un
// claim isolé : l'`effective_amount` est un montant LBC, pas des vues) pour
// que le client garde la valeur du flux au lieu de l'écraser par 0.
if (String(provider) === 'odysee') {
try {
const { resolveOdyseeVideo } = await import('./providers/odysee.mjs');
const od = await resolveOdyseeVideo(slug || videoId);
if (!od) return res.status(404).json({ error: 'odysee_claim_not_found' });
const odOut = {
videoId,
url,
type: 'video',
title: od.title,
thumbnail: od.thumbnail,
description: od.description,
uploaderName: od.uploaderName,
uploaderAvatar: od.uploaderAvatar,
uploadedDate: od.releaseTime ? new Date(od.releaseTime * 1000).toISOString() : '',
...(od.duration > 0 ? { duration: od.duration } : {}),
};
detailsCacheSet(cacheKey, odOut);
return res.json(odOut);
} catch (e) {
return res.status(502).json({ error: 'odysee_details_failed', details: String(e?.message || e) });
}
}
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
const channelId = meta.channel_id || meta.uploader_id || meta.channel_url?.split('/').filter(Boolean).pop() || '';
@@ -1573,6 +1631,8 @@ r.get('/details/:provider/:videoId', async (req, res) => {
}
}
} catch {}
const views = typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0);
const duration = typeof meta.duration === 'number' ? meta.duration : 0;
const out = {
videoId,
title: meta.title || '',
@@ -1583,8 +1643,11 @@ r.get('/details/:provider/:videoId', async (req, res) => {
channelId: channelId || undefined,
channelExternalId: channelExternalId || undefined,
subscribers,
views: typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0),
duration: typeof meta.duration === 'number' ? meta.duration : 0,
// `views` / `duration` ne sont émis QUE s'ils sont connus : un 0 écraserait
// la valeur déjà affichée (le client merge sur `typeof === 'number'`), ce
// qui montrait « 0 vue » sur les fournisseurs muets sur ces champs.
...(views > 0 ? { views } : {}),
...(duration > 0 ? { duration } : {}),
uploadedDate: meta.upload_date ? new Date(meta.upload_date.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')).toISOString() : (meta.release_timestamp ? new Date(meta.release_timestamp * 1000).toISOString() : ''),
description: meta.description || meta.summary || '',
url,
@@ -1606,6 +1669,7 @@ r.get('/details/:provider/:videoId', async (req, res) => {
}
} catch { out.related = []; }
}
detailsCacheSet(cacheKey, out);
return res.json(out);
} catch (e) {
return res.status(500).json({ error: 'details_failed', details: String(e?.message || e) });