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:
@@ -254,7 +254,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
|
|||||||
* ⏳ **Abonnements** (routes + DB)
|
* ⏳ **Abonnements** (routes + DB)
|
||||||
* ⏳ **Tags** & recherche par tags
|
* ⏳ **Tags** & recherche par tags
|
||||||
* ⏳ **Page “Shorts”** unifiée (tous fournisseurs) + badges de durée
|
* ⏳ **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
|
* ✅ **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)
|
* 🔧 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)
|
* ⏳ **Import/Export** playlists (JSON / OPML-like)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"fixtures:record": "node ./server/tests/record_provider_shapes.mjs",
|
"fixtures:record": "node ./server/tests/record_provider_shapes.mjs",
|
||||||
"doc:providers": "node ./server/tests/generate-provider-doc.mjs",
|
"doc:providers": "node ./server/tests/generate-provider-doc.mjs",
|
||||||
"test:rumble": "node ./server/tests/rumble-ld.test.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:cache": "node ./server/tests/search-cache.test.mjs",
|
||||||
"test:videos": "node ./server/tests/videos_catalog.test.mjs",
|
"test:videos": "node ./server/tests/videos_catalog.test.mjs",
|
||||||
"test:channelref": "node ./server/tests/channel_ref.test.mjs",
|
"test:channelref": "node ./server/tests/channel_ref.test.mjs",
|
||||||
|
|||||||
+66
-2
@@ -1525,6 +1525,32 @@ function ytRelatedCacheSet(key, items) {
|
|||||||
ytRelatedCache.set(key, { ts: Date.now(), 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); }
|
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
|
// 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)
|
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
|
||||||
r.get('/details/:provider/:videoId', async (req, res) => {
|
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 slug = req.query.slug || undefined;
|
||||||
const sourceUrl = req.query.sourceUrl || undefined;
|
const sourceUrl = req.query.sourceUrl || undefined;
|
||||||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
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 raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||||||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||||||
const channelId = meta.channel_id || meta.uploader_id || meta.channel_url?.split('/').filter(Boolean).pop() || '';
|
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 {}
|
} 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 = {
|
const out = {
|
||||||
videoId,
|
videoId,
|
||||||
title: meta.title || '',
|
title: meta.title || '',
|
||||||
@@ -1583,8 +1643,11 @@ r.get('/details/:provider/:videoId', async (req, res) => {
|
|||||||
channelId: channelId || undefined,
|
channelId: channelId || undefined,
|
||||||
channelExternalId: channelExternalId || undefined,
|
channelExternalId: channelExternalId || undefined,
|
||||||
subscribers,
|
subscribers,
|
||||||
views: typeof meta.view_count === 'number' ? meta.view_count : (typeof meta.viewCount === 'number' ? meta.viewCount : 0),
|
// `views` / `duration` ne sont émis QUE s'ils sont connus : un 0 écraserait
|
||||||
duration: typeof meta.duration === 'number' ? meta.duration : 0,
|
// 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() : ''),
|
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 || '',
|
description: meta.description || meta.summary || '',
|
||||||
url,
|
url,
|
||||||
@@ -1606,6 +1669,7 @@ r.get('/details/:provider/:videoId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch { out.related = []; }
|
} catch { out.related = []; }
|
||||||
}
|
}
|
||||||
|
detailsCacheSet(cacheKey, out);
|
||||||
return res.json(out);
|
return res.json(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(500).json({ error: 'details_failed', details: String(e?.message || e) });
|
return res.status(500).json({ error: 'details_failed', details: String(e?.message || e) });
|
||||||
|
|||||||
@@ -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
|
* Minimal Odysee provider handler
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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`);
|
||||||
Reference in New Issue
Block a user