Files
NewTube/server/search-filters.mjs
T
bruno 37681c4f53
CI / build-and-test (push) Successful in 14m18s
fix(shorts): orientation autoritaire, langues YouTube et autoplay fiable
- format: des que largeur/hauteur sont connues, l'orientation fait autorite (horizontale/carree => jamais short, meme <75 s). Repli duree reserve aux providers muets
- dimensions mappees: Dailymotion (width,height), PeerTube (aspectRatio), Odysee (value.video.width/height) => validation verticale reelle
- YouTube: langue via snippet.defaultAudioLanguage (filtre FR/EN)
- autoplay: demarrage muet, suppression du deblocage automatique au scroll/selection (reload d'embed hors geste => ecran noir); son via le bouton dedie
- PeerTube embed: muted=1 pour l'autoplay
- server/search-filters.mjs: miroir de la regle d'orientation + tests mis a jour
2026-09-29 14:51:56 -04:00

330 lines
12 KiB
JavaScript

/**
* 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))) {
return parts.reduce((acc, p) => acc * 60 + p, 0);
}
}
return 0;
}
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;
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).
// 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;
}