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
+2 -1
View File
@@ -254,7 +254,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
* ⏳ **Abonnements** (routes + DB)
* ⏳ **Tags** & recherche par tags
* ⏳ **Page “Shorts”** unifiée (tous fournisseurs) + badges de durée
* ✅ **UI/UX des Shorts** — le rail d'actions quitte les bords (les flèches latérales sont supprimées) et s'ancre au bord **droit** de la vidéo : **informations**, **flèches ↑/↓**, j'aime, s'abonner (pastille = avatar), commentaires, partager, **lecture auto** (interrupteur persisté, respecté par le minuteur ET la fin de vidéo YouTube) ; panneau **ⓘ** (titre, vues, engagement, chaîne, description, commentaires → Watch) en colonne à droite au bureau et en tiroir plein écran sur mobile, la vidéo rétrécissant sans jamais être masquée ; **transition glissée** d'un short à l'autre (le cadre suit le doigt, résistance aux extrémités, ressort sous 25 % de la hauteur puis éjection/emboîtement, `prefers-reduced-motion` respecté) ; FAB menu conservé (déplaçable, au-dessus de la vidéo). Le tap sur un contrôle du rail ne déclenche plus play/pause
* ✅ **UI/UX des Shorts** — le rail d'actions quitte les bords (les flèches latérales sont supprimées) et s'ancre au bord **droit** de la vidéo : **informations**, **flèches ↑/↓**, j'aime, **enregistrer** (liste « À regarder plus tard »), s'abonner (pastille = avatar), commentaires, partager, **lecture auto** (interrupteur persisté, respecté par le minuteur ET la fin de vidéo YouTube) ; panneau **ⓘ** (titre, vues, engagement, chaîne, description, renvoi vers la page complète — aucune source de commentaires dans l'app) en colonne à droite au bureau et en tiroir plein écran sur mobile, la vidéo rétrécissant sans jamais être masquée ; **transition glissée** d'un short à l'autre (le cadre suit le doigt, résistance aux extrémités, ressort sous 25 % de la hauteur puis éjection/emboîtement, `prefers-reduced-motion` respecté) ; FAB menu conservé (déplaçable, au-dessus de la vidéo). Le tap sur un contrôle du rail ne déclenche plus play/pause
* ✅ **Métadonnées Odysee sans yt-dlp** — `resolve` de l'API LBRY (**~0,2 s** contre **~45 s** mesurées avec l'extracteur `lbry` de yt-dlp, qui finissait sur « No video formats found! ») : titre, description, vignette, durée et date arrivent enfin sur `/watch` ; `views` volontairement omis (LBRY n'expose aucun compteur pour un claim isolé). Au passage : **cache `/api/details` 6 h** (LRU 500, `DETAILS_CACHE_TTL_MS`) et `views`/`duration` ne sont plus émis quand ils valent 0 (un 0 écrasait la valeur du flux : « 0 vue » sur les fournisseurs muets). Tests : `npm run test:odysee`
* ✅ **Téléchargements** intégrés — file d'attente **persistée en SQLite** (survit aux redémarrages API), jobs **par utilisateur** (répertoires isolés, ownership sur status/fichier/cancel), **quota de stockage** configurable (`DOWNLOAD_STORAGE_QUOTA_BYTES`, fenêtre `DOWNLOAD_QUOTA_WINDOW_MS`), **reprise** des jobs échoués/interrrompus (bouton Réessayer), **page Bibliothèque > Téléchargements** (filtres par état, progression live, quota), nettoyage auto des fichiers orphelins au boot
* 🔧 Variables : `DOWNLOAD_MAX_CONCURRENT` (2), `DOWNLOAD_STORAGE_QUOTA_BYTES` (5 GiB), `DOWNLOAD_QUOTA_WINDOW_MS` (30 j), `DOWNLOAD_PROVIDERS` (peertube,odysee)
* ⏳ **Import/Export** playlists (JSON / OPML-like)
+1
View File
@@ -27,6 +27,7 @@
"fixtures:record": "node ./server/tests/record_provider_shapes.mjs",
"doc:providers": "node ./server/tests/generate-provider-doc.mjs",
"test:rumble": "node ./server/tests/rumble-ld.test.mjs",
"test:odysee": "node ./server/tests/odysee-resolve.test.mjs",
"test:cache": "node ./server/tests/search-cache.test.mjs",
"test:videos": "node ./server/tests/videos_catalog.test.mjs",
"test:channelref": "node ./server/tests/channel_ref.test.mjs",
+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) });
+59
View File
@@ -1,3 +1,62 @@
/** 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
*/
+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`);