feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s
CI / build-and-test (push) Successful in 14m10s
Barre de recherche - Panneau de filtres unifié (remplace le ProviderPicker) : sources, type (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année), durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F, recherches récentes, « mémoriser par défaut », « Réinitialiser ». - Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur. - Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants, Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon recherche brute), Tab complète sans chercher, Échap ferme puis vide, ligne « Rechercher <q> », défilement de l'option active, aria à jour. - Focus : le panneau se referme dès que le focus quitte la barre (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les lignes pour garder le focus dans l'input (plus de scintillement). - Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des filtres et sont retirés de la requête (autocomplétion après le `:`). Filtres côté serveur - Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs. - /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type, duration, features), Data API v3 (type, videoDuration, publishedAfter), yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif. Un champ manquant ne fait jamais disparaître un résultat. - Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache). - Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues. Tests / docs - 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres), server/tests/search-filters.test.mjs (npm run test:filters) + CI, scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
export function isShortItem(item) {
|
||||
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 || ''));
|
||||
}
|
||||
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Un item respecte-t-il les filtres ? (champs absents => jamais écarté) */
|
||||
export function matchesFilters(item, filters) {
|
||||
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);
|
||||
if (n.type === 'video') {
|
||||
return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user