From c45e04af42adc9e468dcae8f6f9776ed0e4a1a75 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 14:48:53 -0400 Subject: [PATCH] =?UTF-8?q?perf(details):=20Odysee=20par=20l'API=20LBRY=20?= =?UTF-8?q?+=20cache=20des=20m=C3=A9tadonn=C3=A9es=20(45=20s=20=E2=86=92?= =?UTF-8?q?=200,2=20s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- README.md | 3 +- package.json | 1 + server/index.mjs | 68 ++++++++++++++++++++++++++- server/providers/odysee.mjs | 59 +++++++++++++++++++++++ server/tests/odysee-resolve.test.mjs | 70 ++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 server/tests/odysee-resolve.test.mjs diff --git a/README.md b/README.md index a9e88ad..03cb0de 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/package.json b/package.json index 3de99fb..55eed04 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.mjs b/server/index.mjs index dc8a679..9ca4cc0 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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) }); diff --git a/server/providers/odysee.mjs b/server/providers/odysee.mjs index 299fda1..7327eb8 100644 --- a/server/providers/odysee.mjs +++ b/server/providers/odysee.mjs @@ -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} + */ +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 */ diff --git a/server/tests/odysee-resolve.test.mjs b/server/tests/odysee-resolve.test.mjs new file mode 100644 index 0000000..18c301b --- /dev/null +++ b/server/tests/odysee-resolve.test.mjs @@ -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`);