feat(search): panneau de filtres unifié, clavier/focus du typeahead
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:
2026-09-28 17:42:19 -04:00
parent 9fb0ce7549
commit f1f673040e
40 changed files with 3111 additions and 938 deletions
+21 -17
View File
@@ -49,33 +49,35 @@ Agrégez, explorez et regardez des vidéos depuis
Un seul champ, tous les fournisseurs — avec filtres, raccourcis et deep-links :
* **Chips providers** dans la barre : `All / YT / DM / TW / PT / OD / RU` (raccourcis **Alt+1..6**)
* **Autocomplete `@`** : tapez `@yt` dans le champ pour filtrer sur YouTube (flèches + Enter, Esc pour fermer)
* **Provider picker** : bouton `@` ou **Ctrl/⌘+K** — modal avec filtre texte, sélection multiple et option **« Remember as default »** (persistée dans vos préférences utilisateur)
* **Quick menu Ctrl/⌘+K** : historique des recherches + toggles providers en un écran
* **Deep-links** : `/#/search?q=…&providers=yt,ru` relance la recherche filtrée — partageable
* **Panneau de filtres** : bouton **Filtres** (ou **Ctrl/⌘+Maj+F**) — un seul écran pour *toutes* les dimensions : **sources** (multi), **type de contenu** (vidéos / shorts-clips / en direct / chaînes), **période** (dernière heure, aujourd’hui, semaine, mois, année), **durée** (courte < 4 min, moyenne 4-20 min, longue > 20 min) et **tri**. Pastilles, roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes, Entrée pour choisir, Échap pour fermer), focus trap, « Réinitialiser », « Mémoriser comme sources par défaut » et les **recherches récentes** en raccourci
* **Filtres appliqués côté serveur** : `?type=…&duration=…&period=…&sort=…` voyagent dans l’URL et dans `/api/search` — YouTube filtre nativement (InnerTube `upload_date`/`type`/`duration`/`features`, Data API `type`/`videoDuration`/`publishedAfter`, yt-dlp `--dateafter`), les autres providers sont affinés en post-traitement (`server/search-filters.mjs`)
* **Opérateurs en clair** : `linux live:`, `tuto today: long:`, `concert shorts:`… tapés dans la barre sont convertis en filtres et retirés de la requête (autocomplétion après le `:`)
* **Autocomplete `@`** : tapez `@yt` dans le champ pour cocher/décocher une source (↑/↓/Entrée, Échap pour fermer) — raccourcis **Alt+1..6**
* **Deep-links** : `/#/search?q=…&providers=yt,ru&period=week&type=live` relance la recherche filtrée — partageable
* **Fallback préférence** : URL sans `providers` → préférence `defaultProviders` de l’utilisateur → provider actif
* **Accessibilité** : focus trap dans les modals, Esc pour fermer, aria-combobox sur le champ, focus restauré à la fermeture
* **Typeahead requête** : sous l’input, suggestions de requêtes (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓/Enter/Tab/Esc, priorité au popover `@`
* **Accessibilité** : focus trap dans le panneau, Esc pour fermer, aria-combobox + `aria-activedescendant` sur le champ, focus restauré à la fermeture
* **Panneau de suggestions** : sous l’input, ligne « Rechercher `<q>` » puis suggestions (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓ (bouclants), `Home`/`End`, `PageUp`/`PageDown`, `Entrée` (valide la ligne surlignée, sinon lance la recherche), `Tab` (complète sans chercher), `Échap` (ferme puis vide), priorité au popover `@`
* **Focus** : le panneau se referme dès que le focus quitte la barre (`focusout` + `relatedTarget`, clic neutralisé sur les lignes pour garder le focus dans l’input, pas de scintillement au re-clic)
<!-- TODO: add docs/search-ux.gif (capture des chips, du picker Ctrl+K et du deep-link) -->
<!-- TODO: add docs/search-ux.gif (capture du panneau de filtres, du panneau de suggestions et du deep-link) -->
### Endpoints API concernés
* `GET /api/search?q=…&providers=yt,dm` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` ; YT sans quota via InnerTube + continuations)
* `GET /api/search?q=…&providers=yt,dm&type=live&duration=short&period=week&sort=date` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` + `filters` ; YT sans quota via InnerTube + continuations)
* `GET /api/search/suggest?q=…&providers=yt,dm&limit=10` — typeahead `{ q, groups: { yt: string[], … } }` (cache 5 min, rate-limit)
* `GET /api/details/youtube/:videoId` — métadonnées + `related[]` (watch-next InnerTube, `?related=0` pour désactiver)
* `GET /api/trending?provider=yt&limit=…` — tendances YT sans clé
* `GET /healthz` (alias `/api/healthz`) — mode YT, binaire yt-dlp `binOk`, cache, métriques quota/jour, clés
* `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` — transcript `{ lang, available, languages, lines: [{ t, dur, text }] }` (cache 24 h, rate-limit 10/min ; absent → 200 `{ available: false }`, échec → 502 ; YouTube : découverte des pistes via InnerTube, `YT_TRANSCRIPT_SOURCE`)
* `GET/PATCH /api/user/preferences` — `defaultProviders` (tableau JSON, sanitizé serveur)
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `suggest_shown`, `suggest_used`)
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `filter_panel_open`, `filter_apply`, `suggest_shown`, `suggest_used`)
### Tests
```bash
npm run test:search # unitaires SearchService + parsing @ + picker
npm run test:search # unitaires SearchService + clavier/focus du panneau de suggestions + panneau de filtres
npm run test:search-e2e # scénarios e2e (serveur réel isolé)
npm run test:filters # filtres de recherche : normalisation, bornes, mapping providers (offline)
npm run test:suggest # typeahead : parsing/dédup + contrat /api/search/suggest
npm run test:transcript # transcripts : parseurs json3/vtt + contrat /api/transcript
npm run test:preferences # persistance defaultProviders
@@ -241,8 +243,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
* ✅ Thème (système / dark / light / blue / black)
* ✅ Préférences (langue, thème, région, qualité par défaut)
* ✅ Auth légère (JWT), rate-limit API
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
* ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…)
* ⏳ **Abonnements** (routes + DB)
* ⏳ **Tags** & recherche par tags
@@ -304,6 +306,7 @@ MIT (voir `LICENSE`)
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
- `id`, `label`
- `async search(q, { limit, page })` → `Promise<Suggestion[]>`
- `async search(q, { limit, page, sort, filters })` → `Promise<Suggestion[]>` (`filters` = `{ type, duration, period, sort }` normalisé par `server/search-filters.mjs` ; ignoré par défaut, affiné en post‑traitement si besoin).
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fan‑out `/api/search`.
3) API — Recherche multi‑providers
@@ -322,10 +325,11 @@ MIT (voir `LICENSE`)
}
```
4) UI — Sélecteur & Chips
4) UI — Panneau de filtres
- Le `SearchBoxComponent` (standalone) rend les chips `All / YT / DM / TW / PT / OD / RU` + menu rapide `@` (ProviderPicker).
- Le composant émet `(submitted)` avec `{ q, providers }` et met à jour `SearchService` (RxJS state) pour l’appel API.
- Le `SearchBoxComponent` (standalone) rend le champ + le bouton **Filtres** (`SearchFilterPanelComponent` : sources, type, période, durée, tri, recherches récentes) et les pastilles des filtres actifs.
- Le composant émet `(submitted)` avec `{ q, providers, filters }` et `(filtersChange)` avec les filtres seuls ; `SearchService` (RxJS state) porte `filters$` et l’envoie à chaque adapter.
- Le modèle de filtres est partagé : `src/app/search/filters.ts` (front) et `server/search-filters.mjs` (API) implémentent les mêmes règles.
5) Deep‑link
@@ -333,7 +337,7 @@ MIT (voir `LICENSE`)
6) Tests
- Unit (`npm run test:search`) : parsing `@yt` dans l’input, toggles ProviderPicker, composition et fan-out de `SearchService`.
- Unit (`npm run test:search`) : parsing `@yt`, clavier/focus du panneau de suggestions, opérateurs `live:`, brouillon + apply du panneau de filtres, composition et fan-out de `SearchService. `npm run test:filters` : normalisation/bornes/post-filtrage/mapping providers (offline).
- e2e (`npm run test:search-e2e`) : `providers=yt,dm` → réponse ciblée sur [yt,dm]; deep-link `providers=pt`; fallback registry complet; préférence persistée; route SPA.
Astuce: l’ajout d’un provider ne nécessite pas de modifier les composants — il suffit d’ajouter une entrée dans le registry front + un handler API.