diff --git a/package.json b/package.json index 45d380f..cfe25f1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:transcript": "node --test server/tests/transcript.test.mjs", "test:history": "node server/tests/history_filters.test.mjs", "test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts", + "test:kind": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/video-kind.spec.ts", "test:ytscrape": "node server/tests/youtube-scrape.test.mjs", "test:ytinnertube": "node server/tests/youtube-innertube.test.mjs", "ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\"" diff --git a/server/providers/dailymotion.mjs b/server/providers/dailymotion.mjs index 4b49a94..a8e78b7 100644 --- a/server/providers/dailymotion.mjs +++ b/server/providers/dailymotion.mjs @@ -20,7 +20,7 @@ const handler = { page: Math.max(1, Number(page || 1)).toString(), // Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date) sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')), - fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.id,owner.screenname,owner.avatar_80_url,created_time' + fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,width,height,owner.id,owner.screenname,owner.avatar_80_url,created_time' }) ); @@ -30,19 +30,27 @@ const handler = { const data = await response.json(); - return (data.list || []).map(item => ({ - title: item.title || '', - id: item.id || '', - url: `https://www.dailymotion.com/video/${item.id}`, - thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '', - uploaderName: item['owner.screenname'] || item['owner.username'] || '', - channelId: item['owner.id'] || item['owner.username'] || undefined, - uploaderAvatar: item['owner.avatar_80_url'] || '', - duration: Number(item.duration || 0), - views: Number(item.views_total || 0), - uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '', - type: 'video' - })); + return (data.list || []).map(item => { + // Dimensions d'origine (signal d'orientation pour isShortItem). + // Défensif : le champ n'est pas garanti sur toutes les réponses. + const w = Number(item.width); + const h = Number(item.height); + return { + title: item.title || '', + id: item.id || '', + url: `https://www.dailymotion.com/video/${item.id}`, + thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '', + uploaderName: item['owner.screenname'] || item['owner.username'] || '', + channelId: item['owner.id'] || item['owner.username'] || undefined, + uploaderAvatar: item['owner.avatar_80_url'] || '', + duration: Number(item.duration || 0), + views: Number(item.views_total || 0), + uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '', + type: 'video', + ...(Number.isFinite(w) && w > 0 ? { width: w } : {}), + ...(Number.isFinite(h) && h > 0 ? { height: h } : {}), + }; + }); } catch (error) { console.error('Dailymotion search error:', error); return []; diff --git a/server/providers/odysee.mjs b/server/providers/odysee.mjs index ccbd045..620c911 100644 --- a/server/providers/odysee.mjs +++ b/server/providers/odysee.mjs @@ -40,6 +40,10 @@ const handler = { const name = item.name || ''; const claimId = item.claimId || item.claim_id || ''; const urlSegment = claimId ? `${encodeURIComponent(name)}:${claimId}` : encodeURIComponent(name); + // Dimensions vidéo (signal d'orientation pour isShortItem) quand + // lighthouse les expose — lecture défensive, aucun appel en plus. + const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width); + const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height); return { title: item.title || name, @@ -50,7 +54,9 @@ const handler = { type: 'video', duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) - : (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined) + : (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined), + ...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}), + ...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}), }; }); } catch (error) { diff --git a/server/providers/peertube.mjs b/server/providers/peertube.mjs index 64ff9fa..4be4250 100644 --- a/server/providers/peertube.mjs +++ b/server/providers/peertube.mjs @@ -37,6 +37,20 @@ const handler = { ? `https:${rawThumb}` : new URL(rawThumb, item.url || 'https://sepiasearch.org').toString() : undefined; + // Dimensions max des fichiers (signal d'orientation pour isShortItem). + // Défensif : sepiasearch n'expose pas toujours `files`. + let fw = 0; + let fh = 0; + try { + for (const f of (Array.isArray(item.files) ? item.files : [])) { + const w = Number(f?.width); + const h = Number(f?.height); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 && w * h >= fw * fh) { + fw = w; + fh = h; + } + } + } catch {} return { title: item.name, @@ -46,7 +60,9 @@ const handler = { uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined, channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined, type: 'video', - duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined + duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined, + ...(fw > 0 ? { width: fw } : {}), + ...(fh > 0 ? { height: fh } : {}), }; }); } catch (error) { diff --git a/server/providers/registry.mjs b/server/providers/registry.mjs index dc478e2..efe9437 100644 --- a/server/providers/registry.mjs +++ b/server/providers/registry.mjs @@ -11,6 +11,8 @@ * @property {string=} thumbnail * @property {string=} uploaderName * @property {string=} type + * @property {number=} width (largeur vidéo en px, quand le provider l'expose) + * @property {number=} height (hauteur vidéo en px, quand le provider l'expose) */ /** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */ diff --git a/server/providers/rumble.mjs b/server/providers/rumble.mjs index e8db5c5..4597419 100644 --- a/server/providers/rumble.mjs +++ b/server/providers/rumble.mjs @@ -221,18 +221,18 @@ function parseSearchHtml(html, { limit = 50 } = {}) { } const viewsText = $el.find('.video-item--views').first().text().trim(); const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined; - items.push({ - title: title || url, - id, - url, - thumbnail: normalizeThumb(rawThumbnail), - uploaderName: uploaderName || undefined, - views, - type: 'video', - duration: durationSeconds, - // Pas de flag natif côté Rumble : durée courte (1..75 s) ⇒ Short probable. - isShort: typeof durationSeconds === 'number' && durationSeconds > 0 && durationSeconds <= 75, - }); + items.push({ + title: title || url, + id, + url, + thumbnail: normalizeThumb(rawThumbnail), + uploaderName: uploaderName || undefined, + views, + type: 'video', + duration: durationSeconds, + // Pas de flag maison ici : le filtre central (isShortItem, + // durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions. + }); }); return items; } diff --git a/server/providers/youtube-innertube.mjs b/server/providers/youtube-innertube.mjs index df1880a..c199b63 100644 --- a/server/providers/youtube-innertube.mjs +++ b/server/providers/youtube-innertube.mjs @@ -136,7 +136,11 @@ export function mapVideoNode(n) { || bestThumb(n.thumbnail) || bestThumb(n.on_tap_endpoint?.payload?.thumbnail?.thumbnails) || undefined; - const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type)); + // Un ShortsLockupView / ReelItem EST un short par construction. L'ancienne + // disjonction `|| (durée≤70 && titre sans "short" && type reel|short)` était + // morte : subsumée par le premier test (A || (B && C && A) === A), et son + // `!/short/i.test(title)` excluait à tort les shorts titrés « …shorts ». + const isShort = /short|reel/i.test(type); const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : []; return { title: String(title), diff --git a/server/search-filters.mjs b/server/search-filters.mjs index d65853e..98c66bd 100644 --- a/server/search-filters.mjs +++ b/server/search-filters.mjs @@ -151,12 +151,73 @@ export function isLiveItem(item) { return String(item.type || '').toLowerCase() === 'live'; } -export function isShortItem(item) { +/** + * Seuils partagés de détection Short (miroir de `src/app/shared/utils/video-kind.ts`). + * - SHORT_MAX_SECONDS : durée seule, repli historique (70 YouTube, 75 autres). + * - VERTICAL_SHORT_MAX_SECONDS : une verticale reste un "short" jusqu'à 90 s + * (au-delà c'est une vidéo verticale, pas un short). + * - VERTICAL_MAX_RATIO : width/height ≤ 0.8 (les quasi-carrés sont exclus). + */ +export const SHORT_MAX_SECONDS = 75; +export const YOUTUBE_SHORT_MAX_SECONDS = 70; +export const VERTICAL_SHORT_MAX_SECONDS = 90; +// ponytail: ratio 0.8 arbitré, 0.75 si de faux carrés Instagram apparaissent. +export const VERTICAL_MAX_RATIO = 0.8; + +function numOrZero(v) { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** + * Classification Vidéo / Short, source de vérité côté serveur (miroir exact + * de `isShortVideo()` front). Ordre : + * 1. flag natif (isShort / type 'short' / kind 'clip') — contredit par une + * durée connue > SHORT_MAX_SECONDS ; + * 2. orientation verticale stricte + durée connue ≤ 90 s ; + * 3. verticale SANS durée connue ⇒ PAS un short (même doctrine que + * « durée 0 ne qualifie jamais » : un faux positif pollue le swipe) ; + * 4. repli durée seule (≤70 YouTube, ≤75 autres). + */ +export function isShortItem(item, providerId) { if (!item) return false; - if (item.isShort === true) return true; - if (String(item.kind || '').toLowerCase() === 'clip') return true; const d = itemDurationSec(item); - return d > 0 && d <= 70 && !/short/i.test(String(item.title || '')); + const hasKnownDuration = d > 0; + const type = String(item.type || '').toLowerCase(); + const kind = String(item.kind || '').toLowerCase(); + + // 1) Flag natif — ignoré si la durée connue le contredit. + // Exception : `kind === 'clip'` (Twitch) est toujours un short : les clips + // font ≤ 60 s par construction, une durée > 75 s sur un clip est du bruit + // de métadonnées (le test historique « shorts keeps clips » l'impose). + if (kind === 'clip') return true; + if (item.isShort === true || type === 'short') { + if (hasKnownDuration && d > SHORT_MAX_SECONDS) return false; + return true; + } + + // 2) + 3) Orientation (width/height quand le provider les expose). + const w = numOrZero(item.width); + const h = numOrZero(item.height); + if (w > 0 && h > 0) { + const vertical = h > w && (w / h) <= VERTICAL_MAX_RATIO; + if (vertical) { + if (!hasKnownDuration) return false; // règle 3 : pas de durée ⇒ pas short + return d <= VERTICAL_SHORT_MAX_SECONDS; // règle 2 + } + } + + // 4) Repli durée seule (comportement historique inchangé). + // NOTE : l'ancien code excluait les titres contenant « short » + // (`&& !/short/i.test(title)`), ce qui est l'inverse de l'intuition : un + // vrai Short titré « … #shorts » était EXCLU du filtre shorts. Les tests + // ne couvraient que des titres neutres (« x »), donc le bug passait + // inaperçu. Condition supprimée : la durée seule tranche désormais. + if (!hasKnownDuration) return false; + const max = String(providerId || '').toLowerCase() === 'youtube' || String(item.provider || '').toLowerCase() === 'youtube' + ? YOUTUBE_SHORT_MAX_SECONDS + : SHORT_MAX_SECONDS; + return d <= max; } export function isChannelItem(item) { @@ -170,11 +231,14 @@ function hasTypeSignal(item) { if (item.isShort === true || item.isShort === false) return true; if (String(item.type || '').trim()) return true; if (String(item.kind || '').trim()) return true; + // Des dimensions connues sont un signal : l'item n'est jamais "muet", + // sinon une verticale 60 s apparaîtrait dans le filtre « vidéos ». + if (numOrZero(item.width) > 0 && numOrZero(item.height) > 0) return true; return false; } /** Un item respecte-t-il les filtres ? (champs absents => jamais écarté) */ -export function matchesFilters(item, filters) { +export function matchesFilters(item, filters, providerId) { const n = normalizeFilters(filters); if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true; @@ -194,9 +258,9 @@ export function matchesFilters(item, filters) { if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure if (n.type === 'live') return isLiveItem(item); if (n.type === 'channel') return isChannelItem(item); - if (n.type === 'shorts') return isShortItem(item); + if (n.type === 'shorts') return isShortItem(item, providerId); if (n.type === 'video') { - return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item); + return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item, providerId); } return true; } @@ -217,7 +281,7 @@ export function applySearchFilters(providerId, items, filters) { if (wanted.length === 0) return items; // `channel` : hors YouTube, on ne filtre pas (sinon résultat vide). if (wanted.includes('type') && n.type === 'channel' && providerId !== 'yt') return items; - return items.filter((it) => matchesFilters(it, n)); + return items.filter((it) => matchesFilters(it, n, providerId)); } // --------------------------------------------------------------------------- diff --git a/server/tests/search-filters.test.mjs b/server/tests/search-filters.test.mjs index 32e13ec..667ceeb 100644 --- a/server/tests/search-filters.test.mjs +++ b/server/tests/search-filters.test.mjs @@ -7,6 +7,7 @@ import { normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey, periodMs, durationBounds, matchesFilters, applySearchFilters, innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs, + isShortItem, SHORT_MAX_SECONDS, VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO, } from '../search-filters.mjs'; function expect(cond, msg) { @@ -186,4 +187,74 @@ expect( ); logOk('innertubeSearchFilters / apiSearchParams'); +// ---------- Détection Short par orientation ---------- + +eq(typeof SHORT_MAX_SECONDS, 'number', 'seuils exportés'); +eq(VERTICAL_SHORT_MAX_SECONDS, 90, 'verticale short jusqu’à 90 s'); +eq(VERTICAL_MAX_RATIO, 0.8, 'ratio vertical max 0.8'); + +// Règle 2 : verticale 60 s (ex. repost TikTok 608x1080) ⇒ short. +expect( + isShortItem({ title: 'repost', duration: 60, width: 608, height: 1080, type: 'video' }), + 'vertical 60s is a short', +); +// Règle 2 : verticale 200 s ⇒ PAS short (vidéo verticale, pas short). +expect( + !isShortItem({ title: 'vlog', duration: 200, width: 720, height: 1280, type: 'video' }), + 'vertical 200s is not a short', +); +// Règle 2 : un carré 80 s (> 75 s) n'est PAS short : l'orientation seule +// (ratio ~1) ne suffit pas ET le repli durée ne s'applique plus. +expect( + !isShortItem({ title: 'clip carré', duration: 80, width: 1080, height: 1080, type: 'video' }), + 'square 80s is not a short (orientation alone is not enough)', +); +// …mais à 30 s, la règle 4 (durée seule) le garde : non-régression historique. +expect( + matchesFilters({ id: 'a', title: 'clip carré', duration: 30, width: 1080, height: 1080, type: 'video' }, { type: 'shorts' }), + 'square 30s still matches shorts via duration fallback', +); +// Règle 4 : horizontale 50 s ⇒ short (non-régression durée seule). +expect( + isShortItem({ title: 'paysage', duration: 50, width: 1280, height: 720, type: 'video' }), + 'horizontal 50s is a short (duration fallback)', +); +// Règle 3 : verticale SANS durée connue ⇒ PAS short. +expect( + !isShortItem({ title: 'inconnu', width: 608, height: 1080, type: 'video' }), + 'vertical without duration is not a short', +); +// Règle 1 : flag contredit par une durée > 75 s ⇒ PAS short. +expect( + !isShortItem({ title: 'faux short', duration: 300, isShort: true, type: 'video' }), + 'native flag contradicted by duration is not a short', +); +// Règle 1 : un vrai Short titré « …shorts » n'est plus exclu (ancien bug +// `!/short/i.test(title)`). +expect( + isShortItem({ title: 'mon voyage #shorts', duration: 40, type: 'video' }), + 'title containing shorts is kept', +); +// Seuil YouTube (70) vs autres (75) : 72 s. +expect( + !isShortItem({ title: 'yt', duration: 72, type: 'video', provider: 'youtube' }, 'yt'), + 'youtube 72s is not a short', +); +expect( + isShortItem({ title: 'dm', duration: 72, type: 'video' }, 'dm'), + 'dailymotion 72s is a short', +); +// §4 : une verticale 60 s ne remonte jamais dans le filtre « vidéos »… +expect( + !matchesFilters({ id: 'b', title: 'v', duration: 60, width: 608, height: 1080, type: 'video' }, { type: 'video' }), + 'vertical 60s excluded from video filter', +); +// …et les dimensions comptent comme signal : l'item n'est plus "muet", +// donc le filtre `live` l'exclut (une verticale n'est pas un direct). +expect( + !matchesFilters({ id: 'c', title: 'v', duration: 0, width: 608, height: 1080 }, { type: 'live' }), + 'dimensions are a signal: vertical without live flag is dropped from live', +); +logOk('isShortItem (orientation + durée)'); + console.log('\nAll search-filters tests passed.'); diff --git a/src/app/search/adapters/dm.ts b/src/app/search/adapters/dm.ts index cbbb477..89aa854 100644 --- a/src/app/search/adapters/dm.ts +++ b/src/app/search/adapters/dm.ts @@ -32,6 +32,9 @@ export class DmAdapter implements ProviderAdapter { // Signaux de type pour le raffinement client des filtres. type: typeof it.type === 'string' ? it.type : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, } as VideoItem)); return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError }; } diff --git a/src/app/search/adapters/od.ts b/src/app/search/adapters/od.ts index 58a8d8a..abb05de 100644 --- a/src/app/search/adapters/od.ts +++ b/src/app/search/adapters/od.ts @@ -41,6 +41,9 @@ export class OdAdapter implements ProviderAdapter { // Signaux de type pour le raffinement client des filtres. type: typeof it.type === 'string' ? it.type : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, } as VideoItem; }); return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError }; diff --git a/src/app/search/adapters/pt.ts b/src/app/search/adapters/pt.ts index 64a8887..ce73ffd 100644 --- a/src/app/search/adapters/pt.ts +++ b/src/app/search/adapters/pt.ts @@ -38,6 +38,9 @@ export class PtAdapter implements ProviderAdapter { // Signaux de type pour le raffinement client des filtres. type: typeof it.type === 'string' ? it.type : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, } as VideoItem; }); return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError }; diff --git a/src/app/search/adapters/ru.ts b/src/app/search/adapters/ru.ts index 5a245c5..cb5667c 100644 --- a/src/app/search/adapters/ru.ts +++ b/src/app/search/adapters/ru.ts @@ -29,6 +29,9 @@ export class RuAdapter implements ProviderAdapter { // Signaux de type (Shorts / direct) pour le raffinement client. type: typeof it.type === 'string' ? it.type : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, } as VideoItem)); return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError }; } diff --git a/src/app/search/adapters/tw.ts b/src/app/search/adapters/tw.ts index b10de48..5917195 100644 --- a/src/app/search/adapters/tw.ts +++ b/src/app/search/adapters/tw.ts @@ -46,6 +46,9 @@ export class TwAdapter implements ProviderAdapter { isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined, kind: typeof it.kind === 'string' ? it.kind : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, game: typeof it.game === 'string' ? it.game : undefined, } as VideoItem; }).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl)); diff --git a/src/app/search/adapters/yt.ts b/src/app/search/adapters/yt.ts index a61c693..f3ed333 100644 --- a/src/app/search/adapters/yt.ts +++ b/src/app/search/adapters/yt.ts @@ -34,6 +34,9 @@ export class YtAdapter implements ProviderAdapter { type: typeof it.type === 'string' ? it.type : undefined, isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined, isShort: it.isShort === true ? true : undefined, + // Dimensions (signal d'orientation pour la détection Short). + width: typeof it.width === 'number' ? it.width : undefined, + height: typeof it.height === 'number' ? it.height : undefined, } as VideoItem)); return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError }; } diff --git a/src/app/search/filters.ts b/src/app/search/filters.ts index 738f631..95aed02 100644 --- a/src/app/search/filters.ts +++ b/src/app/search/filters.ts @@ -378,7 +378,12 @@ export function isShortItem(item: any): boolean { return isShortVideo({ duration: itemDurationSec(item), type: typeof item.type === 'string' ? item.type : undefined, + kind: typeof item.kind === 'string' ? item.kind : undefined, + url: typeof item.url === 'string' ? item.url : undefined, provider: typeof item.provider === 'string' ? item.provider : undefined, + isShort: item.isShort === true ? true : undefined, + width: typeof item.width === 'number' ? item.width : undefined, + height: typeof item.height === 'number' ? item.height : undefined, }); } @@ -401,6 +406,9 @@ function hasTypeSignal(item: any): boolean { if (item.isShort === true || item.isShort === false) return true; if (String(item.type || '').trim()) return true; if (String(item.kind || '').trim()) return true; + // Des dimensions connues sont un signal (miroir back) : une verticale + // 60 s ne doit jamais apparaître dans le filtre « vidéos ». + if (typeof item.width === 'number' && item.width > 0 && typeof item.height === 'number' && item.height > 0) return true; return false; } diff --git a/src/app/shared/models/video-item.model.ts b/src/app/shared/models/video-item.model.ts index 426cfba..b270968 100644 --- a/src/app/shared/models/video-item.model.ts +++ b/src/app/shared/models/video-item.model.ts @@ -21,4 +21,6 @@ export interface VideoItem { isShort?: boolean; // true when the provider flagged a Short/clip kind?: string; // e.g. Twitch 'vod' | 'clip' game?: string; // e.g. Twitch game/category name + width?: number; // video width in px (orientation signal, when exposed) + height?: number; // video height in px (orientation signal, when exposed) } diff --git a/src/app/shared/utils/video-kind.spec.ts b/src/app/shared/utils/video-kind.spec.ts new file mode 100644 index 0000000..e2acaab --- /dev/null +++ b/src/app/shared/utils/video-kind.spec.ts @@ -0,0 +1,78 @@ +import { + isShortVideo, isLiveVideo, classifyVideo, + SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS, + VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO, +} from './video-kind'; + +/** + * Classification Vidéo / Short (orientation + durée). + * Miroir de `server/tests/search-filters.test.mjs`. + * Run with: npm run test:kind + */ + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + const a = JSON.stringify(actual); + const b = JSON.stringify(expected); + if (a !== b) { + console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`); + process.exitCode = 1; + } else { + console.log(`ok: ${message}`); + } +} + +assertEqual(typeof SHORT_MAX_SECONDS, 'number', 'seuils exportés'); +assertEqual(VERTICAL_SHORT_MAX_SECONDS, 90, 'verticale short jusqu’à 90 s'); +assertEqual(VERTICAL_MAX_RATIO, 0.8, 'ratio vertical max 0.8'); +assertEqual(YOUTUBE_SHORT_MAX_SECONDS, 70, 'seuil YouTube 70 s'); + +// Règle 1 : flag natif / clip. +assertEqual(isShortVideo({ duration: 40, isShort: true }), true, 'flag natif 40 s ⇒ short'); +assertEqual(isShortVideo({ duration: 300, isShort: true }), false, 'flag contredit par 300 s ⇒ pas short'); +assertEqual(isShortVideo({ duration: 100, kind: 'clip' }), true, 'clip Twitch ⇒ short'); +assertEqual(isShortVideo({ duration: 45, type: 'short' }), true, "type 'short' ⇒ short"); +assertEqual(isShortVideo({ duration: 45, url: 'https://www.youtube.com/shorts/abc' }), true, 'URL /shorts/ ⇒ short'); + +// Règle 2 : verticale 60 s (ex. 608x1080) ⇒ short. +assertEqual( + isShortVideo({ duration: 60, width: 608, height: 1080 }), + true, + 'verticale 60 s ⇒ short', +); +// Règle 2 : verticale 200 s ⇒ PAS short. +assertEqual( + isShortVideo({ duration: 200, width: 720, height: 1280 }), + false, + 'verticale 200 s ⇒ pas short', +); +// Règle 2 : carré 80 s (ratio ~1) ⇒ PAS short (l'orientation seule ne suffit pas). +assertEqual( + isShortVideo({ duration: 80, width: 1080, height: 1080 }), + false, + 'carré 80 s ⇒ pas short', +); +// Règle 3 : verticale SANS durée ⇒ PAS short. +assertEqual( + isShortVideo({ width: 608, height: 1080 }), + false, + 'verticale sans durée ⇒ pas short', +); +assertEqual( + isShortVideo({ duration: 0, width: 608, height: 1080 }), + false, + 'verticale durée 0 ⇒ pas short', +); +// Règle 4 : repli durée seule (non-régression). +assertEqual(isShortVideo({ duration: 50, width: 1280, height: 720 }), true, 'horizontale 50 s ⇒ short'); +assertEqual(isShortVideo({ duration: 50 }), true, '50 s sans dimensions ⇒ short'); +assertEqual(isShortVideo({ duration: 72 }), true, '72 s non-YouTube ⇒ short (≤ 75)'); +assertEqual(isShortVideo({ duration: 72, provider: 'youtube' }), false, '72 s YouTube ⇒ pas short (> 70)'); +assertEqual(isShortVideo({ duration: 0 }), false, 'durée 0 ⇒ jamais short'); +assertEqual(isShortVideo(null), false, 'null ⇒ pas short'); + +// Live / classification globale. +assertEqual(isLiveVideo({ isLive: true }), true, 'isLive ⇒ live'); +assertEqual(isLiveVideo({ type: 'channel' }), true, 'channel ⇒ live (direct ou chaîne)'); +assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'classify verticale 60 s'); +assertEqual(classifyVideo({ duration: 500 }), 'video', 'classify 500 s'); +assertEqual(classifyVideo({ isLive: true }), 'live', 'classify live'); diff --git a/src/app/shared/utils/video-kind.ts b/src/app/shared/utils/video-kind.ts index 651160f..e79fa25 100644 --- a/src/app/shared/utils/video-kind.ts +++ b/src/app/shared/utils/video-kind.ts @@ -1,13 +1,16 @@ /** * Classification partagée Vidéo / Short / Live. * - * Règles (alignées front + back) : - * - `isShort === true` (flag natif) ⇒ Short, sauf durée connue > 75 s. - * - Twitch `kind === 'clip'` ⇒ Short (les clips font ≤ 60 s par construction). - * - YouTube : URL `/shorts/` ⇒ Short ; sinon durée 1..70 s ⇒ Short. - * - Autres providers : durée 1..75 s ⇒ Short. - * - IMPORTANT : une durée `0` / absente ne qualifie JAMAIS un Short - * (les scrapers renvoient 0 quand la durée est inconnue). + * Miroir exact de `isShortItem()` (`server/search-filters.mjs`). Ordre : + * 1. `kind === 'clip'` (Twitch) ⇒ Short (les clips font ≤ 60 s par + * construction ; une durée aberrante est du bruit, pas une preuve). + * 2. Flag natif (`isShort`, `type === 'short'`, URL `/shorts/`) ⇒ Short, + * sauf durée connue > SHORT_MAX_SECONDS. + * 3. Orientation verticale stricte (`height > width`, ratio ≤ 0.8) ET durée + * connue ≤ VERTICAL_SHORT_MAX_SECONDS (90 s) ⇒ Short. + * 4. Verticale SANS durée connue ⇒ PAS un Short (même doctrine que + * « durée 0 ne qualifie jamais »). + * 5. Repli durée seule : 1..70 s (YouTube), 1..75 s (autres). */ export type VideoKind = 'short' | 'live' | 'video'; @@ -19,11 +22,20 @@ export interface ClassifiableVideo { provider?: string | null; isShort?: boolean | null; isLive?: boolean | null; - isVertical?: boolean | null; + width?: number | null; + height?: number | null; } export const SHORT_MAX_SECONDS = 75; export const YOUTUBE_SHORT_MAX_SECONDS = 70; +export const VERTICAL_SHORT_MAX_SECONDS = 90; +// ponytail: ratio 0.8 arbitré, 0.75 si de faux carrés Instagram apparaissent. +export const VERTICAL_MAX_RATIO = 0.8; + +function numOrZero(v: unknown): number { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : 0; +} export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean { if (!v) return false; @@ -34,18 +46,28 @@ export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean { const url = String((v as { url?: unknown }).url || ''); const provider = String((v as { provider?: unknown }).provider || '').toLowerCase(); - // Clips Twitch = équivalent Shorts (durée max 60 s côté Twitch). + // 1) Clips Twitch = équivalent Shorts. if (kind === 'clip') return true; - if (type === 'short') return true; - // Flag natif (Rumble router, YouTube, …) — ignoré si la durée connue le contredit. - if ((v as { isShort?: unknown }).isShort === true) { + // 2) Flag natif — ignoré si la durée connue le contredit. + if ((v as { isShort?: unknown }).isShort === true || type === 'short' || url.includes('/shorts/')) { if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false; return true; } + // 3) + 4) Orientation (width/height quand le provider les expose). + const w = numOrZero((v as { width?: unknown }).width); + const h = numOrZero((v as { height?: unknown }).height); + if (w > 0 && h > 0) { + const vertical = h > w && w / h <= VERTICAL_MAX_RATIO; + if (vertical) { + if (!hasKnownDuration) return false; + return duration <= VERTICAL_SHORT_MAX_SECONDS; + } + } + + // 5) Repli durée seule (comportement historique inchangé). if (!hasKnownDuration) return false; - if (url.includes('/shorts/')) return true; if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS; return duration <= SHORT_MAX_SECONDS; } diff --git a/src/components/shorts/watch-short.component.ts b/src/components/shorts/watch-short.component.ts index 69a62b6..c547b5f 100644 --- a/src/components/shorts/watch-short.component.ts +++ b/src/components/shorts/watch-short.component.ts @@ -84,8 +84,13 @@ export class WatchShortComponent implements OnDestroy { // ---- Expérience façon YouTube Shorts ---- /** Cadre 9:16 (plein écran + focus). */ frameRef = viewChild>('shortsFrame'); - /** Son coupé (autoplay) : rebascule l'URL d'embed (reprend au début). */ - muted = signal(true); + /** + * Son coupé ou non. Par défaut le son est ACTIVÉ sur les Shorts : les + * navigateurs le permettent dès la première interaction (molette/clic), + * sinon le player démarre en pause et l'utilisateur appuie sur lecture. + * Rebasculer rebascule l'URL d'embed (reprend au début). + */ + muted = signal(false); /** * Embed Rumble autoritaire : id demandé -> vrai id scrapé via * /api/rumble/video/ (comme /watch). Les IDs de liste approximatifs @@ -809,7 +814,10 @@ export class WatchShortComponent implements OnDestroy { // Regroupe par provider puis entrelace (tourniquet) pour mixer les sources. const byProvider: Video[][] = defs.map((d, i) => { const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId); - const list = raw.filter(v => this.isShort({ ...v, provider: d.provider } as Video)); + const list = raw.filter(v => + this.isShort({ ...v, provider: d.provider } as Video) && + (d.provider !== 'twitch' || this.shortsLangOk({ ...v, provider: d.provider } as Video)) + ); // Mélange léger par sujet pour varier l'ordre à chaque visite. for (let k = list.length - 1; k > 0; k--) { const j = Math.floor(Math.random() * (k + 1)); @@ -918,8 +926,8 @@ export class WatchShortComponent implements OnDestroy { this.api.getTwitchClipsFeed()?.subscribe({ next: (res: { items: Video[]; nextCursor?: string | null }) => { const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId); - // Double sécurité anti "time machine" sur les clips non validés. - this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({ + // Langue utilisateur + double sécurité anti "time machine". + this.twitchShortsReady(raw.filter((v: Video) => this.isShort(v))).subscribe({ next: (filtered) => { const list: Video[] = this.dedup(filtered); this.items.set(list); @@ -959,8 +967,8 @@ export class WatchShortComponent implements OnDestroy { next: (res) => { const raw = (res?.items || []).filter(v => !!v.videoId); const shorts = raw.filter(v => this.isShort(v)); - // Les clips de recherche ne sont pas validés côté service. - this.withoutDeadClips(shorts).subscribe({ + // Langue utilisateur + validation (recherche non validée côté service). + this.twitchShortsReady(shorts).subscribe({ next: (filtered) => { const list = this.dedup(filtered); this.showTwitchFallbackList(list, res?.nextCursor || null); @@ -1105,6 +1113,29 @@ export class WatchShortComponent implements OnDestroy { return isShortVideo({ ...(v as object), provider: p } as Parameters[0]); } + /** + * La vidéo respecte-t-elle les langues de Shorts de l'utilisateur + * (langue UI + préférences + transcripts) ? Seuls les items Twitch + * portent la langue (champ Helix) ; sans métadonnée, le doute profite. + * YouTube/PeerTube sont filtrés côté serveur (relevanceLanguage, + * languageOneOf) ; Dailymotion/Odysee/Rumble n'exposent pas la langue. + */ + private shortsLangOk(v: Video): boolean { + const p = (v as any).provider || this.provider(); + if (String(p) !== 'twitch') return true; + const lang = String((v as any)?.raw?.language || '').toLowerCase().split('-')[0]; + if (!lang || lang === 'other') return true; + let accepted: string[] = ['en']; + try { accepted = this.api.getShortsLanguages(); } catch {} + return accepted.includes(lang); + } + + /** Shorts Twitch : langue utilisateur + clips morts exclus avant affichage. */ + private twitchShortsReady(list: Video[]): import('rxjs').Observable { + const inLang = list.filter(v => this.shortsLangOk(v)); + return this.withoutDeadClips(inLang); + } + /** * Exclut les clips Twitch morts ("time machine") avant affichage. * Les flux game/broadcaster sont déjà validés côté service ; ce garde-fou @@ -1238,10 +1269,11 @@ export class WatchShortComponent implements OnDestroy { if (p === 'youtube' && raw.length) { this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); }); } else if (p === 'twitch' && raw.length) { - // Les clips paginés ne sont pas tous validés côté service. - this.withoutDeadClips(raw.filter((v: Video) => this.isShort(v))).subscribe({ + // Langue utilisateur + clips paginés non tous validés côté service. + const shorts = raw.filter((v: Video) => this.isShort(v)); + this.twitchShortsReady(shorts).subscribe({ next: (clean) => apply(clean), - error: () => apply(raw.filter((v: Video) => this.isShort(v))), + error: () => apply(shorts), }); } else { apply(raw.filter((v: Video) => this.isShort(v))); diff --git a/src/models/video.model.ts b/src/models/video.model.ts index a4b727b..7180849 100644 --- a/src/models/video.model.ts +++ b/src/models/video.model.ts @@ -20,7 +20,8 @@ export interface Video { providerUrl?: string; // URL complète de la vidéo sur le site du fournisseur // Champs optionnels pour le filtrage des Shorts (non obligatoires selon les providers) provider?: 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube'; - isVertical?: boolean; // true si 9:16 + width?: number | null; // largeur vidéo en px (signal d'orientation, quand exposé) + height?: number | null; // hauteur vidéo en px (signal d'orientation, quand exposé) isShort?: boolean; // indicateur natif si disponible isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams) kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 3776d25..1842c2a 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -5,6 +5,7 @@ import { catchError, map, switchMap, shareReplay, first, tap, timeout } from 'rx import { Video, VideoDetail, Comment } from '../models/video.model'; import { InstanceService, Provider } from './instance.service'; import { I18nService } from './i18n.service'; +import { UserService } from './user.service'; export interface PagedResult { items: T[]; @@ -33,11 +34,45 @@ export class YoutubeApiService { try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; } })(); - constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService) { + constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService, private users: UserService) { this.getTwitchAuthToken().subscribe(this.twitchAuthToken$); this.ytKeys = this.loadYouTubeKeys(); } + /** + * Langues acceptées pour les Shorts : langue d'interface + langue par + * défaut des préférences + langues de transcripts (`downloadLanguages`). + * Ex. UI en anglais + transcripts fr/en ⇒ ['en', 'fr'] : les Shorts sont + * cherchés dans ces langues, jamais dans d'autres quand l'API le permet + * (YouTube relevanceLanguage, PeerTube languageOneOf, Twitch language). + */ + public getShortsLanguages(): string[] { + const out: string[] = []; + const push = (v: unknown) => { + const base = String(v || '').trim().toLowerCase().split('-')[0]; + if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base); + }; + try { push(this.i18n.lang()); } catch {} + try { + const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null; + push((prefs as any)?.language); + const dl = (prefs as any)?.downloadLanguages; + if (Array.isArray(dl)) for (const l of dl) push(l); + } catch {} + return out.length ? out : ['en']; + } + + /** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */ + private primaryShortsLang2(): string | null { + try { + const langs = this.getShortsLanguages(); + const two = langs.find(l => l.length === 2); + return two || null; + } catch { + return null; + } + } + private normalizeDuration(raw: any): number { if (typeof raw === 'number' && Number.isFinite(raw)) { return raw > 0 ? Math.round(raw) : 0; @@ -263,8 +298,9 @@ export class YoutubeApiService { if (!token) return of({ items: [], nextCursor: null }); const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` }); const after = cursor ? `&after=${encodeURIComponent(cursor)}` : ''; - const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`; - const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`; + const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : ''; + const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`; + const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`; return this.http.get(primary, { headers }).pipe( catchError(() => this.http.get(fallback, { headers })), map(res => ({ @@ -357,6 +393,8 @@ export class YoutubeApiService { } } const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 6); + // Langue de l'utilisateur (filtre natif Helix /videos). + const vodLang = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : ''; const fetchVodsForUsers = (ids: string[]) => { if (!ids.length) return of([] as any[]); // Fan-out réduit : 6 chaînes × 6 VODs suffisent, le budget @@ -364,8 +402,8 @@ export class YoutubeApiService { // remercient (une page Explore en lance déjà 4 sections). const perUserRequests = ids.slice(0, 6).map(uid => { // type=all to include archive, upload, highlight; fetch a few more to increase hit rate - const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`; - const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`; + const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`; + const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`; return this.http.get(vPrimary, { headers }).pipe( catchError(() => this.http.get(vFallback, { headers })), map(vres => Array.isArray(vres?.data) ? vres.data : []), @@ -390,8 +428,8 @@ export class YoutubeApiService { } if (!gameIds.length) return of([]); const gameCalls = gameIds.slice(0, 2).map(gid => { - const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`; - const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`; + const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`; + const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`; return this.http.get(gPrimary, { headers }).pipe( catchError(() => this.http.get(gFallback, { headers })), map(vres => Array.isArray(vres?.data) ? vres.data : []), @@ -1005,11 +1043,12 @@ export class YoutubeApiService { key: String(key), }); try { - const lang = String(this.i18n.lang()).toLowerCase(); + // Langue de l'utilisateur en priorité (relevanceLanguage YouTube). + const lang = this.getShortsLanguages()[0]; if (lang) params.set('relevanceLanguage', lang); } catch {} if (cursor) params.set('pageToken', cursor); - const cacheKey = `yt:shorts|${region}|${q}|${cursor || ''}`; + const cacheKey = `yt:shorts|${region}|${q}|${this.getShortsLanguages().join(',')}|${cursor || ''}`; const cached = this.searchCache.get(cacheKey); const now = Date.now(); if (cached && (now - cached.ts) < this.CACHE_TTL_MS) { @@ -1120,6 +1159,10 @@ export class YoutubeApiService { isLocal: 'true', nsfw: 'false', }); + // Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu). + try { + for (const l of this.getShortsLanguages()) params.append('languageOneOf', l); + } catch {} if (q) params.set('search', q); const primary = `/api/peertube/${instance}/api/v1/videos?${params.toString()}`; const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?${params.toString()}`; @@ -1321,7 +1364,8 @@ export class YoutubeApiService { } as Video; }; - // Twitch Mappers + // Twitch Mappers (la langue Helix est conservée dans `raw` pour le + // filtre langue des Shorts : l'endpoint /clips n'a pas de param langue). private mapTwitchItemToVideo = (i: any): Video => ({ url: `/watch/${i.id}`, videoId: i.id || '', @@ -1342,6 +1386,7 @@ export class YoutubeApiService { views: Number(i.view_count || i.viewer_count || 0), uploaded: i.created_at ? Date.parse(i.created_at) : 0, isLive: false, + raw: { language: i?.language || undefined }, }); // Twitch Streams mapper (for trending popular live streams) @@ -1433,6 +1478,7 @@ export class YoutubeApiService { views: Number(i.view_count || 0), uploaded: i.created_at ? Date.parse(i.created_at) : 0, isLive: false, + raw: { language: i?.language || undefined }, }); /** @@ -1547,8 +1593,10 @@ export class YoutubeApiService { if (!token) return this.getTwitchServerTopStreams(limit); const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` }); const after = cursor ? `&after=${encodeURIComponent(cursor)}` : ''; - const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`; - const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}`; + // Langue de l'utilisateur (filtre natif Helix /streams). + const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : ''; + const primary = `/api/twitch-api/helix/streams?first=${limit}${after}${langParam}`; + const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}${langParam}`; return this.http.get(primary, { headers }).pipe( catchError(() => this.http.get(fallback, { headers })), map(res => ({