/** * Filtres de recherche — modèle partagé avec le front (`src/app/search/filters.ts`). * * Rôle : valider/normaliser les filtres reçus par `GET /api/search`, les * traduire dans les paramètres natifs de chaque provider (InnerTube, Data API * v3…) et, pour les providers qui ne savent pas filtrer, affiner la liste * renvoyée. * * Principe de sûreté : un filtre inconnu est IGNORÉ (jamais d'erreur 4xx), et * un champ métadonnée absent ne fait jamais disparaître un résultat. */ const TYPES = ['all', 'video', 'shorts', 'live', 'channel']; const DURATIONS = ['all', 'short', 'medium', 'long']; const PERIODS = ['all', 'hour', 'today', 'week', 'month', 'year']; const SORTS = ['relevance', 'date', 'views']; /** @returns {any} filtres normalisés (toujours les 4 clés) */ export function normalizeFilters(raw) { const src = raw && typeof raw === 'object' ? raw : {}; const pick = (v, allowed, fallback) => { const s = String(v ?? '').trim().toLowerCase(); return allowed.includes(s) ? s : fallback; }; return { type: pick(src.type, TYPES, 'all'), duration: pick(src.duration, DURATIONS, 'all'), period: pick(src.period, PERIODS, 'all'), sort: pick(src.sort, SORTS, 'relevance'), }; } /** Extrait + normalise les filtres depuis une query string Express. */ export function parseSearchFilters(query) { const q = query && typeof query === 'object' ? query : {}; return normalizeFilters({ type: q.type, duration: q.duration, period: q.period, sort: q.sort, }); } /** Filtres actifs seulement (pour le cache + la réponse JSON). */ export function activeFilters(filters) { const n = normalizeFilters(filters); const out = {}; if (n.type !== 'all') out.type = n.type; if (n.duration !== 'all') out.duration = n.duration; if (n.period !== 'all') out.period = n.period; if (n.sort !== 'relevance') out.sort = n.sort; return out; } /** Signature courte et stable (cache mémoire / SQLite). */ export function filtersCacheKey(filters) { const n = normalizeFilters(filters); return `${n.type}.${n.duration}.${n.period}.${n.sort}`; } const HOUR = 3600 * 1000; const DAY = 24 * HOUR; /** Bornes de la période de mise en ligne, en ms. 0 = pas de borne. */ export function periodMs(period) { switch (normalizeFilters({ period }).period) { case 'hour': return HOUR; case 'today': return DAY; case 'week': return 7 * DAY; case 'month': return 30 * DAY; case 'year': return 365 * DAY; default: return 0; } } /** Bornes de durée en secondes. */ export function durationBounds(duration) { switch (normalizeFilters({ duration }).duration) { case 'short': return { min: 1, max: 240 }; case 'medium': return { min: 240, max: 1200 }; case 'long': return { min: 1200, max: Infinity }; default: return null; } } // --------------------------------------------------------------------------- // Post-filtrage (providers sans filtre natif) // --------------------------------------------------------------------------- /** * Dimensions que l'on peut affiner en post-traitement, par provider. * - Twitch : les chaînes n'ont pas de durée exploitable, et `live` est le seul * type fiable (les VOD portent `type: 'video'`). * - Odysee : pas de durée fiable sur la recherche. * - Le type `channel` n'a de sens que pour YouTube (seul provider qui expose * des chaines comme résultats de recherche) : ailleurs on ne filtre pas, sinon * la recherche vide le résultat au lieu de le restreindre. */ const POST_FILTER_DIMS = { yt: ['period', 'duration', 'type'], dm: ['period', 'duration', 'type'], tw: ['period', 'type'], pt: ['period', 'duration', 'type'], od: ['period', 'type'], ru: ['period', 'duration', 'type'], }; function num(item, keys) { for (const k of keys) { const v = item?.[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v); } return 0; } export function itemDurationSec(item) { const d = num(item, ['duration', 'durationSec', 'length', 'durationSeconds']); if (d > 0) return d; // "1:23:45" / "12:34" const raw = item?.duration ?? item?.length ?? ''; const s = String(raw || ''); if (s.includes(':')) { const parts = s.split(':').map((p) => Number(p)); if (parts.length && parts.every((p) => Number.isFinite(p))) { const total = parts.reduce((acc, p) => acc * 60 + p, 0); if (total > 0) return total; } } // Phase 2.2 — « durée inconnue » vaut `undefined`, jamais `0`. Le 0 signifiait // « vidéo de 0 s » et rendait la règle « verticale sans durée => pas un short » // inopérante, tout en faisant afficher "0:00" sur les cartes. return undefined; } export function itemPublishedTs(item) { const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? item?.timestamp ?? item?.created_time; if (typeof raw === 'number' && Number.isFinite(raw)) { // Les scrapes yt-dlp donnent un timestamp en secondes. return raw > 1e12 ? raw : raw * 1000; } if (typeof raw === 'string' && raw.trim()) { const parsed = Date.parse(raw); if (!Number.isNaN(parsed)) return parsed; const asNum = Number(raw); if (Number.isFinite(asNum) && asNum > 0) return asNum > 1e12 ? asNum : asNum * 1000; } return 0; } export function isLiveItem(item) { if (!item) return false; if (item.isLive === true) return true; return String(item.type || '').toLowerCase() === 'live'; } /** * 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; const d = itemDurationSec(item); 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; // Règle (c) : `kind === 'clip'` ci-dessusprime sur `type: 'video'`. Sans cet // early return, un item `kind:'clip', type:'video'` (ce que produisait // `channel-content.mjs` pour les lives Twitch) retombait en règle 3/4. if (item.isShort === true || type === 'short' || String(item.url || '').includes('/shorts/')) { // Règle (a) : flag natif + durée INCONNUE => short. « Le provider a parlé » // prime quand rien ne le contredit. Règle (b) : un flag natif prime aussi // sur un 1:1 (repost Instagram/TikTok), mais JAMAIS sur la seule durée. if (hasKnownDuration && d > SHORT_MAX_SECONDS) return false; return true; } // 2) + 3) Orientation (width/height quand le provider les expose). // Dimensions connues ⇒ l'orientation fait AUTORITÉ : une horizontale/carrée // n'est jamais un short, même courte. 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 } return false; } // 4) Repli durée seule, uniquement quand l'orientation est inconnue. // 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) { return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true; } /** Le provider expose-t-il un signal de type exploitable ? */ function hasTypeSignal(item) { if (!item) return false; if (item.isLive === true || item.isLive === false) return true; 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, providerId) { const n = normalizeFilters(filters); if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true; const bounds = durationBounds(n.duration); if (bounds) { const d = itemDurationSec(item); if (d > 0 && (d < bounds.min || d >= bounds.max)) return false; } const maxAge = periodMs(n.period); if (maxAge > 0) { const ts = itemPublishedTs(item); if (ts > 0 && (Date.now() - ts) > maxAge) return false; } if (n.type === 'all') return true; 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, providerId); if (n.type === 'video') { return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item, providerId); } return true; } /** * Affine une liste de résultats pour un provider donné. Renvoie la liste * d'origine quand le provider ne sait pas post-filtrer cette dimension. * @param {string} providerId * @param {any[]} items * @param {any} filters */ export function applySearchFilters(providerId, items, filters) { const n = normalizeFilters(filters); if (!Array.isArray(items) || items.length === 0) return items || []; if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return items; const dims = POST_FILTER_DIMS[providerId] || []; const wanted = ['period', 'duration', 'type'].filter((d) => dims.includes(d) && n[d] && n[d] !== 'all'); 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, providerId)); } // --------------------------------------------------------------------------- // Mapping provider // --------------------------------------------------------------------------- /** * Filtres youtubei.js (`yt.search`) : les enums protos sont des noms explicites * (UNDER_THREE_MINS…), pas des alias 'short'/'medium'/'long'. * @param {any} filters */ export function innertubeSearchFilters(filters) { const n = normalizeFilters(filters); const out = {}; if (n.period !== 'all' && ['today', 'week', 'month', 'year'].includes(n.period)) { out.upload_date = n.period; } if (n.type === 'video' || n.type === 'channel') out.type = n.type; if (n.type === 'shorts') out.type = 'shorts'; // `live` n'est pas un SearchType : YouTube l'expose comme feature. if (n.type === 'live') out.features = ['live']; if (n.duration !== 'all') { out.duration = n.duration === 'short' ? 'UNDER_THREE_MINS' : n.duration === 'medium' ? 'THREE_TO_TWENTY_MINS' : 'OVER_TWENTY_MINS'; } return out; } /** * Paramètres additionnels pour la Data API v3 (`youtube/v3/search`). * `type` : 'shorts'/'live' n'existent pas côté API -> laissés au post-filtrage. */ export function apiSearchParams(filters) { const n = normalizeFilters(filters); const out = {}; if (n.type === 'video' || n.type === 'channel') out.type = n.type; if (n.duration !== 'all') out.videoDuration = n.duration; const maxAge = periodMs(n.period); if (maxAge > 0) out.publishedAfter = new Date(Date.now() - maxAge).toISOString(); return out; }