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:
+66
-2
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user