diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fda1401..c3db034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Unit tests (search UX) run: npm run test:search + - name: Search filters (offline) + run: npm run test:filters + - name: E2E scenarios (search UX) run: npm run test:search-e2e diff --git a/README.md b/README.md index ea6d9aa..28d0eb6 100644 --- a/README.md +++ b/README.md @@ -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 `` » 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) - + ### 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/.mjs` qui exporte `default` avec: - `id`, `label` - `async search(q, { limit, page })` → `Promise` +- `async search(q, { limit, page, sort, filters })` → `Promise` (`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. diff --git a/api/README.md b/api/README.md index 527f85c..744d40d 100644 --- a/api/README.md +++ b/api/README.md @@ -14,7 +14,10 @@ Query parameters - providers (string, optional): Comma-separated list of provider ids. Allowed values: yt, dm, tw, pt, od, ru. Defaults to all when omitted or invalid. - page (integer, optional): Page index starting at 1. Default: 1. - pageSize (integer, optional): Page size, max 50. Default: 24. -- sort (string, optional): Reserved for future use. +- sort (string, optional): relevance | date | views. Default: relevance. +- type (string, optional): video | shorts | live | channel. Default: all. +- duration (string, optional): short (< 4 min) | medium (4-20 min) | long (> 20 min). Default: all. +- period (string, optional): hour | today | week | month | year (upload date). Default: all. Response ``` @@ -32,9 +35,13 @@ Response "ru": [] }, "page": 1, - "pageSize": 24 + "pageSize": 24, + "sort": "relevance", + "filters": { "type": "live", "period": "week" } } ``` +`filters` echoes the active filters (default values are omitted). Unknown filter +values are ignored (never a 4xx), so a typo silently degrades to "no filter". Error responses - 400 { error: "invalid_query", details: "min_length_2" } @@ -51,4 +58,12 @@ Providers Notes - Each provider is queried in parallel with a per-provider limit equal to `pageSize`. - The endpoint currently does not expose a `total` field; the frontend should offer a simple Next page affordance or infinite scroll when appropriate. -- Future: support for `sort`. +- Filters are applied natively when the provider supports them (YouTube: InnerTube + `upload_date` / `type` / `duration` / `features`, Data API v3 `type` / + `videoDuration` / `publishedAfter`, yt-dlp `--dateafter`), and refined in + post-processing otherwise (see `server/search-filters.mjs`). +- A result whose metadata is missing is never dropped by a filter: only values + that are present and out of range are filtered out. +- The `channel` type is only enforced for YouTube (the only provider that + returns channels as search results). +- `period=hour` is not a native InnerTube filter: it is applied in post-processing. diff --git a/index.css b/index.css index ff712ff..feb68cd 100644 --- a/index.css +++ b/index.css @@ -243,6 +243,9 @@ body { @supports not (height: 100dvh) { .quick-menu-dialog { max-height: 85vh; } } +/* Panneau de filtres : même coque que la palette Ctrl+K, plus étroit (les + options sont des pastilles, pas des colonnes). */ +.filter-panel-dialog { max-width: 44rem; } @media (max-width: 639px) { /* On phones the dialog owns the viewport: no floating "modal" look. */ .quick-menu-backdrop { padding: 0; } diff --git a/mcp/server.mjs b/mcp/server.mjs index 4a5c9a9..a38ca0d 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -97,7 +97,7 @@ function transcriptToSrt(data) { const TOOLS = [ { name: 'search_videos', - description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble). q min 2 caractères.', + description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble) avec filtres (type de contenu, durée, période de mise en ligne). q min 2 caractères.', inputSchema: { type: 'object', properties: { @@ -106,6 +106,9 @@ const TOOLS = [ page: { type: 'integer', minimum: 1, default: 1 }, pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 }, sort: { type: 'string', enum: ['relevance', 'date', 'views'], default: 'relevance' }, + type: { type: 'string', enum: ['video', 'shorts', 'live', 'channel'], description: 'Type de contenu attendu' }, + duration: { type: 'string', enum: ['short', 'medium', 'long'], description: 'Durée : court < 4 min, moyen 4-20 min, long > 20 min' }, + period: { type: 'string', enum: ['hour', 'today', 'week', 'month', 'year'], description: 'Période de mise en ligne' }, }, required: ['q'], }, @@ -240,10 +243,15 @@ async function callTool(name, args = {}) { page: String(args.page ?? 1), pageSize: String(args.pageSize ?? 10), sort: String(args.sort ?? 'relevance'), + // Filtres : ignorés côté API s'ils sont inconnus, donc sûrs à passer. + ...(args.type ? { type: String(args.type) } : {}), + ...(args.duration ? { duration: String(args.duration) } : {}), + ...(args.period ? { period: String(args.period) } : {}), }); const data = await apiFetch(`/search?${qs}`); const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length])); - return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}`, data }; + const filterHint = data.filters && Object.keys(data.filters).length ? ` [${JSON.stringify(data.filters)}]` : ''; + return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}${filterHint}`, data }; } case 'suggest_queries': { const q = String(args.q || '').trim(); diff --git a/package.json b/package.json index 058f90a..45d380f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:subscriptions": "node --loader ts-node/esm --experimental-specifier-resolution=node src/services/subscriptions.service.spec.ts", "test:search": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search.service.spec.ts && node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search-components.spec.ts", "test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs", + "test:filters": "node ./server/tests/search-filters.test.mjs", "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", diff --git a/server/index.mjs b/server/index.mjs index 95183c7..83ce3d2 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -17,6 +17,7 @@ import axios from 'axios'; import rumbleRouter from './rumble.mjs'; import { providerRegistry, validateProviders } from './providers/registry.mjs'; import { dedupeSuggestGroups } from './suggest.mjs'; +import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs'; import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs'; import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs'; import { @@ -2522,7 +2523,7 @@ r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => { const { event, meta } = req.body || {}; if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' }); // Whitelist known event names to keep the table clean - const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']); + const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']); if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' }); const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null }); return res.status(201).json(row || { ok: true }); @@ -2542,7 +2543,7 @@ r.get('/telemetry/events', authMiddleware, (req, res) => { r.get('/telemetry/summary', authMiddleware, (req, res) => { const since = typeof req.query.since === 'string' ? req.query.since : undefined; const events = {}; - for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']) { + for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']) { events[name] = countTelemetryEvents({ event: name, since }); } return res.json({ events }); @@ -3105,10 +3106,15 @@ app.get('/api/search', async (req, res) => { } const pageNum = Math.max(1, Number(req.query.page || 1)); const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24))); + // Filtres de recherche (type / durée / période / tri). Un filtre inconnu est + // ignoré (jamais 400) : `normalizeFilters` retombe sur 'all'. + const filters = parseSearchFilters(req.query); // Optional sort parameter (normalized to known set) const allowedSort = new Set(['relevance', 'date', 'views']); let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance'); if (!allowedSort.has(sort)) sort = 'relevance'; + // `filters.sort` fait autorité (le front envoie les deux, ils concordent) + sort = filters.sort; // Validate and normalize providers list (default to all supported when none/invalid) const requested = typeof providers === 'string' ? String(providers) : ''; const validProviders = validateProviders(requested); @@ -3118,8 +3124,8 @@ app.get('/api/search', async (req, res) => { validProviders.map((providerId) => { const mod = providerRegistry[/** @type {any} */(providerId)]; if (!mod || typeof mod.search !== 'function') return Promise.resolve([]); - // Basic options include pagination and sort hints - return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort })); + // Basic options include pagination, sort hints and the search filters + return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort, filters })); }) ); @@ -3129,7 +3135,8 @@ app.get('/api/search', async (req, res) => { results.forEach((result, index) => { const providerId = validProviders[index]; if (result.status === 'fulfilled') { - groups[providerId] = Array.isArray(result.value) ? result.value : []; + // Filtres non supportés nativement par le provider -> affinage ici. + groups[providerId] = applySearchFilters(providerId, Array.isArray(result.value) ? result.value : [], filters); } else { console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason); groups[providerId] = []; @@ -3144,7 +3151,11 @@ app.get('/api/search', async (req, res) => { } }); - return res.json({ q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort }); + return res.json({ + q, providers: validProviders, groups, errors, + page: pageNum, pageSize, sort, + filters: activeSearchFilters(filters), + }); } catch (e) { return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) }); } diff --git a/server/providers/youtube-innertube.mjs b/server/providers/youtube-innertube.mjs index b3295ee..df1880a 100644 --- a/server/providers/youtube-innertube.mjs +++ b/server/providers/youtube-innertube.mjs @@ -2,6 +2,7 @@ // WEB client : search + continuations (pagination illimitée), watch-next (related), // sans clé API ni quota. Le dispatcher bascule sur scrape/API si indisponible. import { hashSearchKey, ytMetrics } from './youtube-common.mjs'; +import { innertubeSearchFilters, filtersCacheKey } from '../search-filters.mjs'; let sessionPromise = null; let LogSilenced = false; @@ -95,22 +96,53 @@ export function mapVideoNode(n) { if (type === 'LockupView') return mapLockupView(n); if (/playlist|channel|gridchannel|shelf|radio|show|album/i.test(type) && !/playlistpanelvideo|watchcard/i.test(type)) return null; - const id = n.id ? String(n.id) : null; + // Les Shorts (ShortsLockupView / ReelItem) n'ont pas de `id` : il vit dans + // l'endpoint de navigation. Sans cela, une recherche `type=shorts` ne + // remonterait aucun résultat. + const entityId = n.entity_id ? String(n.entity_id) : ''; + const id = n.id ? String(n.id) : ( + n.on_tap_endpoint?.payload?.videoId + || n.inline_player_data?.payload?.videoId + || n.on_tap?.innertubeCommand?.reelWatchEndpoint?.videoId + || n.on_tap?.innertubeCommand?.reelPlayerEndpoint?.videoId + || n.navigation_endpoint?.watchEndpoint?.videoId + || n.innertubeCommand?.reelWatchEndpoint?.videoId + // `shorts-shelf-item-` : dernier recours. + || (entityId.startsWith('shorts-shelf-item-') ? entityId.slice('shorts-shelf-item-'.length) : '') + || null + ); if (!id) return null; - const title = n.title?.text ?? (typeof n.title === 'string' ? n.title : ''); + // `??` ne suffit pas : `typeof n.title === 'string' ? … : ''` renvoie '' + // (non nullish) et court-circuiterait les replis. On chaîne donc à part. + const title = n.title?.text + || (typeof n.title === 'string' ? n.title : '') + // ShortsLockupView / ReelItem : le titre est dans les métadonnées superposées. + || n.overlay_metadata?.primary_text?.text + || n.overlay_metadata?.headline?.text + || ''; if (!title) return null; const duration = n.duration?.seconds != null ? Number(n.duration.seconds) : undefined; const author = n.author || n.uploader || null; const authorName = author?.name ?? (typeof author === 'string' ? author : undefined); const authorId = author?.id ? String(author.id) : undefined; - const views = parseViewsText(n.view_count?.text ?? n.view_count ?? n.views?.text); + const views = parseViewsText( + n.view_count?.text ?? n.view_count ?? n.views?.text + // ShortsLockupView : vues dans la 2e ligne des métadonnées superposées. + ?? n.overlay_metadata?.secondary_text?.text, + ); + // Vignette : `thumbnails[]` (classique), `thumbnail` (ShortsLockupView : + // tableau de Thumbnail) ou la vignette portée par l'endpoint du Short. + const thumb = bestThumb(n.thumbnails) + || 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)); const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : []; return { title: String(title), id, url: `https://www.youtube.com/watch?v=${id}`, - thumbnail: bestThumb(n.thumbnails), + thumbnail: thumb, uploaderName: authorName, type: 'video', ...(Number.isFinite(duration) && duration > 0 ? { duration } : {}), @@ -190,6 +222,55 @@ export function mapNodes(nodes) { return out; } +/** + * Aplatit un nœud InnerTube (YTNode) en ses feuilles. + * `Shelf.content` peut être une `Grid`/`Shelf` imbriquée : les Shorts d'une + * recherche « Shorts » vivent là, hors de `feed.videos`. + */ +function flattenNodes(node, depth = 0, out = []) { + if (!node || depth > 5) return out; + if (Array.isArray(node)) { + for (const n of node) flattenNodes(n, depth, out); + return out; + } + if (typeof node !== 'object') return out; + let descended = false; + for (const key of ['contents', 'items', 'content']) { + const child = node[key]; + if (child && typeof child === 'object') { + descended = true; + flattenNodes(child, depth + 1, out); + } + } + if (!descended) out.push(node); + return out; +} + +/** + * Vidéos contenues dans les `Shelf` d'un feed de recherche (Shorts/clips). + * `feed.videos` ne regarde que les rendus directs, donc une recherche + * `type=shorts` y renvoie 0 sans ce complément. + * Pur et testable offline. + */ +export function mapShelfNodes(feed) { + const seen = new Set(); + const out = []; + try { + const results = Array.isArray(feed?.results) ? Array.from(feed.results) : []; + for (const node of results) { + const type = String(node?.type || ''); + if (!/shelf/i.test(type)) continue; + for (const leaf of flattenNodes(node.content ?? node)) { + const mapped = mapVideoNode(leaf); + if (!mapped || seen.has(mapped.id)) continue; + seen.add(mapped.id); + out.push(mapped); + } + } + } catch { return out; } + return out; +} + // Chaîne de continuations en mémoire : q_hash -> { feed, pages }. // (SQLite persiste les pages déjà servies ; la chaîne évite de rejouer les pages 1..N-1.) const chainCache = new Map(); @@ -212,6 +293,8 @@ function sortParam(sort) { /** * Recherche InnerTube avec vraie pagination (continuations). * Page 1 ~35-40 vidéos ; pages suivantes via getContinuation() fusionnées (façon SmartTube). + * `opts.filters` : { type, duration, period, sort } — traduits en filtres + * natifs InnerTube (upload_date / type / duration / features). */ export async function searchViaInnerTube(q, opts = {}) { const query = String(q || '').trim(); @@ -219,9 +302,16 @@ export async function searchViaInnerTube(q, opts = {}) { const limit = Math.min(50, Math.max(1, Number(opts?.limit || 24))); const page = Math.min(10, Math.max(1, Number(opts?.page || 1))); const sort = String(opts?.sort || 'relevance'); + const itFilters = innertubeSearchFilters(opts?.filters); + // Les Shorts d'une recherche « Shorts » sont servis dans un `Shelf`, hors de + // `feed.videos` : on complète le lot dans ce seul cas (une recherche normale + // garde exactement son comportement et son ordre actuels). + const withShelves = itFilters.type === 'shorts'; ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1; const yt = await getSession(); - const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}`)}`; + // La chaîne de continuations est propre à une combinaison + // (requête + tri + filtres) : sinon on servirait les pages d'un filtre à l'autre. + const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}|${filtersCacheKey(opts?.filters)}`)}`; let entry = chainGet(key); let feed = entry?.feed || null; let collected = entry?.items ? [...entry.items] : []; @@ -231,17 +321,23 @@ export async function searchViaInnerTube(q, opts = {}) { // [start, start+limit[ (façon SmartTube qui remplit son écran au fil des // continuations), au lieu d'aligner 1 page API = 1 page UI. const need = page * limit; + const harvest = (f) => { + const direct = mapNodes(f.videos); + if (!withShelves) return direct; + const extra = mapShelfNodes(f).filter((m) => !direct.some((d) => d.id === m.id)); + return direct.concat(extra); + }; try { if (!feed) { - feed = await yt.search(query, { sort_by: sortParam(sort) }); - collected = mapNodes(feed.videos); + feed = await yt.search(query, { ...itFilters, sort_by: sortParam(sort) }); + collected = harvest(feed); pagesDone = 1; } let guard = 0; while (collected.length < need && feed?.has_continuation && guard < 12) { guard++; feed = await feed.getContinuation(); - const fresh = mapNodes(feed.videos).filter((m) => !collected.some((c) => c.id === m.id)); + const fresh = harvest(feed).filter((m) => !collected.some((c) => c.id === m.id)); collected = collected.concat(fresh); pagesDone++; if (!fresh.length) break; diff --git a/server/providers/youtube-scrape.mjs b/server/providers/youtube-scrape.mjs index cf8ea70..2f064e8 100644 --- a/server/providers/youtube-scrape.mjs +++ b/server/providers/youtube-scrape.mjs @@ -3,6 +3,7 @@ import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; import { getYtDlpTimeoutMs, buildYtDlpExtraArgs, resolveYtDlpBin } from './youtube-common.mjs'; +import { periodMs } from '../search-filters.mjs'; const execFileAsync = promisify(execFileCb); @@ -85,7 +86,7 @@ export function classifyScrapeError(e) { return Object.assign(new Error(`YouTube scrape failed: ${msg.slice(0, 200)}`), { ytStatus: 502, code: 'yt_scrape_failed' }); } -async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {}) { +async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1, extraArgs = [] } = {}) { const bin = await resolveYtDlpBin(); const timeout = getYtDlpTimeoutMs(); const end = playlistStart + Math.max(1, Math.min(50, Number(limit || 10))) - 1; @@ -98,6 +99,7 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {}) '--no-playlist', '--playlist-start', String(playlistStart), '--playlist-end', String(end), + ...extraArgs, ...buildYtDlpExtraArgs(), queryOrUrl, ]; @@ -112,7 +114,11 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {}) } } -/** Recherche sans clé. sort: relevance|date|views */ +/** + * Recherche sans clé. sort: relevance|date|views + * `opts.filters` : seule la période est traduite nativement (`--dateafter`) ; + * durée / type sont affinés en post-traitement (côté /api/search). + */ export async function searchViaScrape(q, opts = {}) { const query = String(q || '').trim(); if (query.length < 2) return []; @@ -125,7 +131,12 @@ export async function searchViaScrape(q, opts = {}) { if (sort === 'date') prefix = 'ytsearchdate'; // views : pas de préfixe natif stable -> ytsearch + tri local const target = `${prefix}${perPage}:${query}`; - let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start }); + // --dateafter : borne basse de mise en ligne (format YYYYMMDD attendu par yt-dlp). + const maxAge = periodMs(opts?.filters?.period); + const extraArgs = maxAge > 0 + ? ['--dateafter', new Date(Date.now() - maxAge).toISOString().slice(0, 10).replace(/-/g, '')] + : []; + let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start, extraArgs }); if (sort === 'views') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0)); return items.slice(0, perPage); } diff --git a/server/providers/youtube.mjs b/server/providers/youtube.mjs index 5775a46..bc54eba 100644 --- a/server/providers/youtube.mjs +++ b/server/providers/youtube.mjs @@ -15,6 +15,7 @@ import { } from './youtube-common.mjs'; import { searchViaScrape } from './youtube-scrape.mjs'; import { searchViaInnerTube } from './youtube-innertube.mjs'; +import { apiSearchParams, filtersCacheKey } from '../search-filters.mjs'; function parseISODurationToSeconds(iso) { if (typeof iso !== 'string' || !iso) return 0; @@ -108,7 +109,7 @@ export function ytScrapeCacheStats() { return { memEntries: memCache.size, memMax: MEM_MAX }; } -async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}) { +async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) { const keys = getYouTubeKeys(); if (!keys.length) { throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' }); @@ -116,6 +117,8 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {} let order = 'relevance'; if (sort === 'date') order = 'date'; else if (sort === 'views') order = 'viewCount'; + // Filtres natifs de l'API v3 : type, videoDuration, publishedAfter. + const extra = apiSearchParams(filters); const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); const targetPage = Math.max(1, Number(page || 1)); let pageToken = ''; @@ -123,8 +126,9 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {} let lastItems = []; while (currentPage <= targetPage) { const params = { - part: 'snippet', q, type: 'video', maxResults: String(perPage), order, + part: 'snippet', q, maxResults: String(perPage), order, videoEmbeddable: 'true', safeSearch: 'moderate', + ...extra, }; if (pageToken) params.pageToken = pageToken; const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params); @@ -148,35 +152,51 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {} } return (lastItems || []).map((item) => { const videoId = item?.id?.videoId; + // Une recherche de type `channel` renvoie des channelId, pas des videoId. + const rawId = videoId || item?.id?.channelId || null; + const isChannel = !videoId && !!item?.id?.channelId; + if (!rawId) return null; const snippet = item?.snippet || {}; const thumb = snippet.thumbnails?.high?.url || snippet.thumbnails?.medium?.url || snippet.thumbnails?.default?.url || undefined; const details = videoId ? detailsMap.get(videoId) : null; const duration = parseISODurationToSeconds(details?.contentDetails?.duration || ''); const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined; - const channelId = snippet.channelId || undefined; + const channelId = snippet.channelId || item?.id?.channelId || undefined; const embeddable = details?.status ? details.status.embeddable !== false : undefined; return { - title: snippet.title || '', id: videoId, - url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined, - thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, type: 'video', + title: snippet.title || '', id: rawId, + url: isChannel + ? `https://www.youtube.com/channel/${rawId}` + : `https://www.youtube.com/watch?v=${rawId}`, + thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, + type: isChannel ? 'channel' : 'video', duration: duration > 0 ? duration : undefined, views, publishedAt: snippet.publishedAt || undefined, channelId, channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId, channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable, }; - }); + }).filter(Boolean); } -/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise }} */ +/** + * @typedef {Object} SearchFilters + * @property {string} [type] all|video|shorts|live|channel + * @property {string} [duration] all|short|medium|long + * @property {string} [period] all|hour|today|week|month|year + * @property {string} [sort] relevance|date|views + */ +/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views', filters?: SearchFilters }) => Promise }} */ const handler = { id: 'yt', label: 'YouTube', async search(q, opts) { - const { limit = 10, page = 1, sort = 'relevance' } = opts || {}; + const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {}; const mode = getSearchMode(); const ttl = getScrapeTtlMs(); const perPage = Math.min(Math.max(1, Number(limit || 10)), 50); - const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}`)}`; + // La signature des filtres entre dans la clé de cache : deux recherches + // identiques avec des filtres différents ne doivent pas se partager de cache. + const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}|${filtersCacheKey(filters)}`)}`; // 1) mémoire const memHit = memGet(key, ttl); if (memHit) { ytMetrics.scrapeHits++; return memHit; } @@ -207,10 +227,10 @@ const handler = { const tryScrape = async () => { ytMetrics.scrapeCalls++; try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {} - return searchViaScrape(q, { limit: perPage, page, sort }); + return searchViaScrape(q, { limit: perPage, page, sort, filters }); }; - const tryApi = () => searchViaApi(q, { limit: perPage, page, sort }); - const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort }); + const tryApi = () => searchViaApi(q, { limit: perPage, page, sort, filters }); + const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort, filters }); const log = (source, extra = '') => console.log(`[YT search] source=${source} mode=${mode} latency=${Date.now() - t0}ms results=${extra}`); try { if (mode === 'api-only') { diff --git a/server/search-filters.mjs b/server/search-filters.mjs new file mode 100644 index 0000000..d65853e --- /dev/null +++ b/server/search-filters.mjs @@ -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; +} diff --git a/server/tests/search-filters.test.mjs b/server/tests/search-filters.test.mjs new file mode 100644 index 0000000..32e13ec --- /dev/null +++ b/server/tests/search-filters.test.mjs @@ -0,0 +1,189 @@ +// Filtres de recherche — tests unitaires (offline, aucun réseau). +// Run with: npm run test:filters +// Couverture : normalisation, bornes, post-filtrage par provider, mapping +// InnerTube / Data API v3. + +import { + normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey, + periodMs, durationBounds, matchesFilters, applySearchFilters, + innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs, +} from '../search-filters.mjs'; + +function expect(cond, msg) { + if (!cond) throw new Error(`Assertion failed: ${msg}`); +} +function eq(actual, expected, msg) { + const a = JSON.stringify(actual); + const b = JSON.stringify(expected); + if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`); +} +function logOk(msg) { console.log(`✓ ${msg}`); } + +// ---------- Normalisation ---------- + +eq(normalizeFilters(null), { type: 'all', duration: 'all', period: 'all', sort: 'relevance' }, 'null filters -> defaults'); +eq( + normalizeFilters({ type: 'LIVE', duration: 'moyen', period: 'nope', sort: 'views' }), + { type: 'live', duration: 'all', period: 'all', sort: 'views' }, + 'values are case-insensitive, unknown values fall back per key (no cross-contamination)', +); +eq( + parseSearchFilters({ type: 'shorts', duration: 'short', period: 'week', sort: 'date', junk: 'x' }), + { type: 'shorts', duration: 'short', period: 'week', sort: 'date' }, + 'parseSearchFilters reads the Express query', +); +eq( + parseSearchFilters({ type: 'plaid' }).type, + 'all', + 'unknown type is ignored (never a 400)', +); +eq(activeFilters({ type: 'live', period: 'all' }), { type: 'live' }, 'activeFilters keeps only non-defaults'); +eq( + filtersCacheKey({ type: 'live', sort: 'date' }), + 'live.all.all.date', + 'cache key signature is stable and ordered', +); +logOk('normalizeFilters / parseSearchFilters / activeFilters / filtersCacheKey'); + +// ---------- Bornes ---------- + +eq(periodMs('all'), 0, 'period all -> no bound'); +expect(periodMs('hour') === 3600 * 1000, 'hour bound'); +expect(periodMs('week') === 7 * 24 * 3600 * 1000, 'week bound'); +eq(durationBounds('short'), { min: 1, max: 240 }, 'short duration bound'); +eq(durationBounds('medium'), { min: 240, max: 1200 }, 'medium duration bound'); +eq(durationBounds('long'), { min: 1200, max: Infinity }, 'long duration bound'); +eq(durationBounds('all'), null, 'no duration bound'); +logOk('periodMs / durationBounds'); + +// ---------- Lectures de métadonnées ---------- + +eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration'); +eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)'); +eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed'); +eq(itemDurationSec({}), 0, 'missing duration -> 0'); +expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed'); +expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled'); +eq(itemPublishedTs({}), 0, 'missing date -> 0'); +logOk('itemDurationSec / itemPublishedTs'); + +// ---------- Post-filtrage ---------- + +const now = Date.now(); +// `type: 'video'` par défaut = payload réaliste d'un provider qui expose le type. +const mk = (over) => ({ + id: Math.random().toString(36).slice(2), + title: 'x', + type: 'video', + duration: 100, + publishedAt: new Date(now - 60 * 1000).toISOString(), + ...over, +}); + +expect(matchesFilters(mk({ duration: 100 }), { duration: 'short' }), 'short keeps 100s'); +expect(!matchesFilters(mk({ duration: 300 }), { duration: 'short' }), 'short drops 5min'); +expect(matchesFilters(mk({ duration: 300 }), { duration: 'medium' }), 'medium keeps 5min'); +expect(matchesFilters(mk({ duration: 3600 }), { duration: 'long' }), 'long keeps 1h'); +expect(matchesFilters(mk({ duration: null }), { duration: 'short' }), 'missing duration is never dropped'); +expect( + matchesFilters(mk({ publishedAt: new Date(now - 3 * 3600 * 1000).toISOString() }), { period: 'today' }), + 'today keeps 3h ago', +); +expect( + !matchesFilters(mk({ publishedAt: new Date(now - 10 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }), + 'week drops 10 days ago', +); +expect( + matchesFilters(mk({ publishedAt: new Date(now - 3 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }), + 'week keeps 3 days ago', +); +expect(matchesFilters(mk({ publishedAt: null }), { period: 'today' }), 'missing date is never dropped'); +expect(matchesFilters(mk({ isLive: true }), { type: 'live' }), 'live type keeps isLive items'); +expect(!matchesFilters(mk({}), { type: 'live' }), 'live type drops regular videos'); +expect(matchesFilters(mk({ isShort: true, duration: 40 }), { type: 'shorts' }), 'shorts keeps flagged items'); +expect(matchesFilters(mk({ kind: 'clip' }), { type: 'shorts' }), 'shorts keeps clips'); +// Item sans aucun signal de type : on ne peut pas conclure -> jamais écarté. +expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'live' }), 'item without type signal is kept'); +expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'shorts' }), 'item without type signal is kept for shorts'); +expect( + matchesFilters(mk({ duration: 500 }), { type: 'video' }), + 'video type keeps plain videos', +); +expect(!matchesFilters(mk({ isLive: true }), { type: 'video' }), 'video type drops live items'); +logOk('matchesFilters (durée / période / type)'); + +// ---------- applySearchFilters par provider ---------- + +const items = [ + mk({ duration: 100, isShort: true }), + mk({ duration: 1800 }), + mk({ isLive: true, duration: 60 }), + mk({ duration: 0, publishedAt: null }), // métadonnées absentes : jamais écarté +]; + +eq( + applySearchFilters('yt', items, { duration: 'short' }).length, + 3, + 'yt post-filters duration (100s + 60s live kept, 1800s dropped, unknown kept)', +); +eq( + applySearchFilters('tw', items, { duration: 'short' }).length, + items.length, + 'twitch does not post-filter duration (no reliable value)', +); +eq( + applySearchFilters('dm', items, { type: 'channel' }).length, + items.length, + 'channel filter is not applied outside YouTube (would empty results)', +); +eq( + applySearchFilters('yt', items, { type: 'channel' }).length, + 0, + 'yt channel filter drops videos', +); +eq( + applySearchFilters('yt', items, {}).length, + items.length, + 'no filter -> list untouched (same reference semantics)', +); +eq( + applySearchFilters('yt', [], { type: 'live' }), + [], + 'empty list stays empty', +); +logOk('applySearchFilters (dimensions par provider)'); + +// ---------- Mapping providers ---------- + +eq(innertubeSearchFilters({ period: 'week' }), { upload_date: 'week' }, 'period -> upload_date'); +eq(innertubeSearchFilters({ period: 'hour' }), {}, 'hour has no InnerTube enum (post-filter only)'); +eq(innertubeSearchFilters({ type: 'shorts' }), { type: 'shorts' }, 'type shorts'); +eq(innertubeSearchFilters({ type: 'live' }), { features: ['live'] }, 'live is a feature, not a SearchType'); +eq(innertubeSearchFilters({ type: 'channel' }), { type: 'channel' }, 'type channel'); +eq( + innertubeSearchFilters({ duration: 'short' }), + { duration: 'UNDER_THREE_MINS' }, + 'short -> UNDER_THREE_MINS proto enum', +); +eq( + innertubeSearchFilters({ duration: 'medium' }), + { duration: 'THREE_TO_TWENTY_MINS' }, + 'medium -> THREE_TO_TWENTY_MINS proto enum', +); +eq( + innertubeSearchFilters({ duration: 'long' }), + { duration: 'OVER_TWENTY_MINS' }, + 'long -> OVER_TWENTY_MINS proto enum', +); +eq(innertubeSearchFilters({}), {}, 'no filters -> empty object'); + +eq(apiSearchParams({ type: 'video' }), { type: 'video' }, 'api type video'); +eq(apiSearchParams({ duration: 'long' }), { videoDuration: 'long' }, 'api videoDuration'); +eq(apiSearchParams({ type: 'shorts' }), {}, 'api has no shorts type (post-filter)'); +expect( + typeof apiSearchParams({ period: 'today' }).publishedAfter === 'string', + 'api publishedAfter ISO date', +); +logOk('innertubeSearchFilters / apiSearchParams'); + +console.log('\nAll search-filters tests passed.'); diff --git a/server/tests/search.e2e.test.mjs b/server/tests/search.e2e.test.mjs index 9f8f246..e0ac1c2 100644 --- a/server/tests/search.e2e.test.mjs +++ b/server/tests/search.e2e.test.mjs @@ -155,6 +155,33 @@ function scenario(name, fn) { // 2b) Invalid providers fall back to all await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback()); + // 2c) Filtres de recherche : ils sont renvoyés tels quels, valeurs inconnues ignorées + await scenario('search filters are echoed back and unknown values ignored', async () => { + const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=3&type=live&duration=short&period=week&sort=date`); + assert(res.ok, `search responds 200 (got ${res.status})`); + const body = await res.json(); + assert(body.filters && body.filters.type === 'live', `type filter echoed (got ${JSON.stringify(body.filters)})`); + assert(body.filters.duration === 'short', 'duration filter echoed'); + assert(body.filters.period === 'week', 'period filter echoed'); + assert(body.sort === 'date', 'sort comes from the filters'); + + // Valeurs inconnues : ignorées, jamais 400. + const bad = await fetch(`${baseUrl}/api/search?q=test&providers=dm&type=plaid&period=yesterday&duration=epic`); + assert(bad.ok, `unknown filter values do not fail (got ${bad.status})`); + const badBody = await bad.json(); + assert(!badBody.filters || Object.keys(badBody.filters).length === 0, + `unknown filters are dropped (got ${JSON.stringify(badBody.filters)})`); + assert(badBody.sort === 'relevance', 'sort falls back to relevance'); + }); + + // 2d) Un filtre ne doit jamais renvoyer d'erreur 500 (post-filtrage sûr) + await scenario('filters never break the fan-out (200 + groups)', async () => { + const res = await fetch(`${baseUrl}/api/search?q=test&pageSize=3&period=today&duration=long`); + assert(res.ok, `search responds 200 with filters (got ${res.status})`); + const body = await res.json(); + assert(body.groups && typeof body.groups === 'object', 'groups present with filters'); + }); + // 3) Preference: defaultProviders persisted and returned after re-login await scenario('default providers preference round-trip (persisted per user)', async () => { await patchPreferences(token, { defaultProviders: ['ru', 'od'] }); diff --git a/server/tests/youtube-innertube.test.mjs b/server/tests/youtube-innertube.test.mjs index 14e84ce..ba7b235 100644 --- a/server/tests/youtube-innertube.test.mjs +++ b/server/tests/youtube-innertube.test.mjs @@ -1,6 +1,6 @@ // Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes). import assert from 'node:assert/strict'; -import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs'; +import { mapVideoNode, mapLockupView, mapNodes, mapShelfNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs'; import { pickTrack, orderedTracks } from '../transcript.mjs'; import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs'; @@ -96,7 +96,50 @@ function ok(msg) { console.log(`✓ ${msg}`); } ok('mapLockupView watch-next'); } -// 5) modes innertube connus +// 5) mapShelfNodes + ShortsLockupView : les Shorts d'une recherche « Shorts » +// n'ont ni `id` ni `title` classiques (endpoint + overlay_metadata), et +// vivent dans un GridShelfView -> sans ce complément la recherche renvoie 0. +{ + // Forme réelle observée (youtubei.js v18, ShortsLockupView) + const shortsNode = (id) => ({ + type: 'ShortsLockupView', + entity_id: `shorts-shelf-item-${id}`, + on_tap_endpoint: { name: 'reelWatchEndpoint', payload: { videoId: id } }, + overlay_metadata: { + primary_text: { text: `Short ${id}` }, + secondary_text: { text: '219 M de vues' }, + }, + thumbnail: [{ url: 'http://t/low.jpg', width: 100 }, { url: 'http://t/high.jpg', width: 1080 }], + }); + const mapped = mapVideoNode(shortsNode('s1')); + assert.equal(mapped.id, 's1', 'l\'id du Short est lu dans on_tap_endpoint'); + assert.equal(mapped.title, 'Short s1', 'le titre vient de overlay_metadata'); + assert.equal(mapped.isShort, true, 'un Short est marqué isShort'); + assert.equal(mapped.views, 219000000, 'les vues viennent de la 2e ligne d\'overlay'); + assert.equal(mapped.thumbnail, 'http://t/high.jpg', 'la plus grande vignette est retenue'); + // Forme « brute » (avant parsing youtubei.js) également gérée + assert.equal(mapVideoNode({ + type: 'ShortsLockupView', + on_tap: { innertubeCommand: { reelWatchEndpoint: { videoId: 'raw1' } } }, + overlay_metadata: { primary_text: { text: 'Raw' } }, + }).id, 'raw1', 'forme brute (on_tap) gérée'); + + const feed = { + results: [ + { type: 'Video', id: 'regular1', title: { text: 'Vidéo normale' } }, + { type: 'GridShelfView', contents: [shortsNode('s1'), shortsNode('s2'), { type: 'ChipCloud' }] }, + ], + }; + const shelf = mapShelfNodes(feed); + assert.equal(shelf.length, 2, 'les 2 Shorts du shelf sont mappés'); + assert.deepEqual(shelf.map((s) => s.id).sort(), ['s1', 's2']); + assert.equal(mapShelfNodes({ results: [{ type: 'Video', id: 'v' }] }).length, 0, 'hors shelf, rien à faire'); + assert.deepEqual(mapShelfNodes(null), []); + assert.deepEqual(mapShelfNodes({}), []); + ok('mapShelfNodes + ShortsLockupView (Shorts dans un shelf)'); +} + +// 6) modes innertube connus { assert.ok(YT_SEARCH_MODES.includes('innertube-first')); assert.ok(YT_SEARCH_MODES.includes('innertube-only')); @@ -107,7 +150,7 @@ function ok(msg) { console.log(`✓ ${msg}`); } ok('modes innertube-first/only'); } -// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack +// 7) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack { const cap = mapCaptionTracks([ { base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } }, diff --git a/src/app/search/adapters/dm.ts b/src/app/search/adapters/dm.ts index 716cd46..cbbb477 100644 --- a/src/app/search/adapters/dm.ts +++ b/src/app/search/adapters/dm.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; // TODO: Make SearchResult generic export class DmAdapter implements ProviderAdapter { @@ -12,6 +13,9 @@ export class DmAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined; const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({ @@ -25,7 +29,10 @@ export class DmAdapter implements ProviderAdapter { publishedAt: undefined, // Dailymotion API does not provide published date in search results channelExternalId: it.channelId || undefined, channelAvatarUrl: it.uploaderAvatar || undefined, - })); + // Signaux de type pour le raffinement client des filtres. + type: typeof it.type === 'string' ? it.type : undefined, + isShort: it.isShort === true ? true : undefined, + } as VideoItem)); return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError }; } } diff --git a/src/app/search/adapters/filter-params.ts b/src/app/search/adapters/filter-params.ts new file mode 100644 index 0000000..fd47cd4 --- /dev/null +++ b/src/app/search/adapters/filter-params.ts @@ -0,0 +1,17 @@ +import { HttpParams } from '@angular/common/http'; +import { activeFilters, type SearchFilters } from '../filters'; + +/** + * Ajoute les filtres de recherche à une `HttpParams` d'adapter. + * Les valeurs par défaut ne sont pas envoyées (URL/backend plus lisibles, cache + * serveur plus efficace : deux recherches sans filtre partagent la même entrée). + */ +export function withFilterParams(params: HttpParams, filters: Partial | null | undefined): HttpParams { + const active = activeFilters(filters); + let out = params; + if (active.type) out = out.set('type', active.type); + if (active.duration) out = out.set('duration', active.duration); + if (active.period) out = out.set('period', active.period); + if (active.sort) out = out.set('sort', active.sort); + return out; +} diff --git a/src/app/search/adapters/od.ts b/src/app/search/adapters/od.ts index d9b33d0..58a8d8a 100644 --- a/src/app/search/adapters/od.ts +++ b/src/app/search/adapters/od.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; export class OdAdapter implements ProviderAdapter { key = 'od' as const; @@ -11,6 +12,9 @@ export class OdAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'od'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined; const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => { @@ -34,6 +38,9 @@ export class OdAdapter implements ProviderAdapter { // Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od // sait le résoudre avec ou sans préfixe @. channelExternalId: it.uploaderName || undefined, + // Signaux de type pour le raffinement client des filtres. + type: typeof it.type === 'string' ? it.type : undefined, + isShort: it.isShort === true ? true : 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 811ebd5..64a8887 100644 --- a/src/app/search/adapters/pt.ts +++ b/src/app/search/adapters/pt.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; export class PtAdapter implements ProviderAdapter { key = 'pt' as const; @@ -11,6 +12,9 @@ export class PtAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined; const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => { @@ -31,7 +35,10 @@ export class PtAdapter implements ProviderAdapter { viewCount: undefined, publishedAt: undefined, channelExternalId, - }; + // Signaux de type pour le raffinement client des filtres. + type: typeof it.type === 'string' ? it.type : undefined, + isShort: it.isShort === true ? true : 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 2f0ddf9..5a245c5 100644 --- a/src/app/search/adapters/ru.ts +++ b/src/app/search/adapters/ru.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; export class RuAdapter implements ProviderAdapter { key = 'ru' as const; @@ -11,6 +12,9 @@ export class RuAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined; const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({ @@ -22,7 +26,10 @@ export class RuAdapter implements ProviderAdapter { thumbnailUrl: it.thumbnail, viewCount: typeof it.views === 'number' ? it.views : undefined, publishedAt: it.publishedAt, - })); + // Signaux de type (Shorts / direct) pour le raffinement client. + type: typeof it.type === 'string' ? it.type : undefined, + isShort: it.isShort === true ? true : 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 5e579e0..b10de48 100644 --- a/src/app/search/adapters/tw.ts +++ b/src/app/search/adapters/tw.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; export class TwAdapter implements ProviderAdapter { key = 'tw' as const; @@ -11,6 +12,9 @@ export class TwAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined; const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => { @@ -41,6 +45,7 @@ export class TwAdapter implements ProviderAdapter { type: typeof it.type === 'string' ? it.type : undefined, isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined, kind: typeof it.kind === 'string' ? it.kind : undefined, + isShort: it.isShort === true ? true : 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 3c578e8..a61c693 100644 --- a/src/app/search/adapters/yt.ts +++ b/src/app/search/adapters/yt.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { withFilterParams } from './filter-params'; export class YtAdapter implements ProviderAdapter { key = 'yt' as const; @@ -11,6 +12,9 @@ export class YtAdapter implements ProviderAdapter { let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt'); if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); + // Filtres (type / durée / période) : appliqués côté serveur quand le provider + // le sait, affinés en post-traitement sinon. + httpParams = withFilterParams(httpParams, params.filters); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined; const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({ @@ -25,7 +29,12 @@ export class YtAdapter implements ProviderAdapter { // ID chaîne conservé : rend le logo/nom cliquables vers /channel // (l'avatar est résolu via /api/channels, l'API search n'en fournit pas). channelExternalId: it.channelExternalId || it.channelId || undefined, - })); + // Signaux de type : indispensables au raffinement client des filtres + // (Shorts / direct / chaîne) quand le serveur les a déjà appliqués. + type: typeof it.type === 'string' ? it.type : undefined, + isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined, + isShort: it.isShort === true ? true : undefined, + } as VideoItem)); return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError }; } } diff --git a/src/app/search/api.v1.ts b/src/app/search/api.v1.ts index 794991d..709ed96 100644 --- a/src/app/search/api.v1.ts +++ b/src/app/search/api.v1.ts @@ -18,6 +18,8 @@ export interface SearchResponseV1 { page?: number; pageSize?: number; sort?: 'relevance' | 'date' | 'views'; + /** Filtres appliqués côté serveur (les valeurs par défaut sont omises). */ + filters?: Record; /** Per-provider backend failures, keyed by provider id. */ errors?: Partial>; } diff --git a/src/app/search/filters.ts b/src/app/search/filters.ts new file mode 100644 index 0000000..738f631 --- /dev/null +++ b/src/app/search/filters.ts @@ -0,0 +1,437 @@ +/** + * Modèle de filtres de recherche — partagé par la barre de recherche, le panneau + * de filtres, la page de résultats et les adapters. + * + * Les filtres sont *un* objet plat (`SearchFilters`) : ils voyagent dans l'URL + * (`?type=…&duration=…&period=…&sort=…`), dans la requête `/api/search` et dans + * l'état de `SearchService`. Toute valeur inconnue retombe sur 'all' (jamais de + * 400, jamais de liste vide côté rendu : un filtre typo doit être ignoré). + */ +import { isShortVideo } from '../shared/utils/video-kind'; + +/** Type de contenu attendu. */ +export type FilterType = 'all' | 'video' | 'shorts' | 'live' | 'channel'; +/** Borne de durée (alignée sur YouTube : court < 4 min, moyen 4-20 min, long > 20 min). */ +export type FilterDuration = 'all' | 'short' | 'medium' | 'long'; +/** Période de mise en ligne. */ +export type FilterPeriod = 'all' | 'hour' | 'today' | 'week' | 'month' | 'year'; +/** Tri. */ +export type FilterSort = 'relevance' | 'date' | 'views'; + +export interface SearchFilters { + type: FilterType; + duration: FilterDuration; + period: FilterPeriod; + sort: FilterSort; +} + +export const FILTER_TYPES: FilterType[] = ['all', 'video', 'shorts', 'live', 'channel']; +export const FILTER_DURATIONS: FilterDuration[] = ['all', 'short', 'medium', 'long']; +export const FILTER_PERIODS: FilterPeriod[] = ['all', 'hour', 'today', 'week', 'month', 'year']; +export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views']; + +export const DEFAULT_FILTERS: SearchFilters = { + type: 'all', + duration: 'all', + period: 'all', + sort: 'relevance', +}; + +function pick(raw: unknown, allowed: T[], fallback: T): T { + const s = String(raw ?? '').trim().toLowerCase(); + return (allowed as string[]).includes(s) ? (s as T) : fallback; +} + +/** Normalise n'importe quelle entrée (URL, patch, payload serveur) en filtres valides. */ +export function normalizeFilters(raw: Partial | null | undefined): SearchFilters { + const src = raw || {}; + return { + ['type']: pick(src['type'], FILTER_TYPES, 'all'), + ['duration']: pick(src['duration'], FILTER_DURATIONS, 'all'), + ['period']: pick(src['period'], FILTER_PERIODS, 'all'), + ['sort']: pick(src['sort'], FILTER_SORTS, 'relevance'), + } as SearchFilters; +} + +/** Nombre de filtres réellement actifs (le tri par défaut ne compte pas). */ +export function activeFilterCount(f: Partial | null | undefined): number { + const n = normalizeFilters(f); + let count = 0; + if (n.type !== 'all') count++; + if (n.duration !== 'all') count++; + if (n.period !== 'all') count++; + if (n.sort !== 'relevance') count++; + return count; +} + +export function filtersEqual(a: Partial | null, b: Partial | null): boolean { + const x = normalizeFilters(a); + const y = normalizeFilters(b); + return x.type === y.type && x.duration === y.duration && x.period === y.period && x.sort === y.sort; +} + +/** Les filtres « non par défaut » seuls — pour l'URL et le cache. */ +export function activeFilters(f: Partial | null | undefined): Partial { + const n = normalizeFilters(f); + const out: Partial = {}; + 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; +} + +/** Query params (les valeurs par défaut sont omises : URL lisible et partageable). */ +export function filtersToQueryParams(f: Partial | null | undefined): Record { + const a = activeFilters(f); + const out: Record = {}; + if (a.type) out['type'] = a.type; + if (a.duration) out['duration'] = a.duration; + if (a.period) out['period'] = a.period; + if (a.sort) out['sort'] = a.sort; + return out; +} + +/** Lecture inverse depuis une `ParamMap` (ou un objet de query params). */ +export function filtersFromQueryParams(params: { + get(key: string): string | null; + has?(key: string): boolean; +} | Record | null | undefined): SearchFilters { + const read = (key: string): string | null => { + if (!params) return null; + if (typeof (params as any).get === 'function') { + try { return (params as any).get(key) ?? null; } catch { return null; } + } + const rec = params as Record; + const v = rec?.[key]; + if (v == null) return null; + return Array.isArray(v) ? String(v[0] ?? '') : String(v); + }; + return normalizeFilters({ + type: read('type') as FilterType | undefined, + duration: read('duration') as FilterDuration | undefined, + period: read('period') as FilterPeriod | undefined, + sort: read('sort') as FilterSort | undefined, + }); +} + +/** Durée de la période en ms (0 = pas de borne). */ +export function periodMs(period: FilterPeriod): number { + const HOUR = 3600 * 1000; + const DAY = 24 * HOUR; + switch (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 (null = pas de borne). */ +export function durationBounds(duration: FilterDuration): { min: number; max: number } | null { + switch (duration) { + case 'short': return { min: 1, max: 240 }; + case 'medium': return { min: 240, max: 1200 }; + case 'long': return { min: 1200, max: Number.POSITIVE_INFINITY }; + default: return null; + } +} + +// --------------------------------------------------------------------------- +// Affichage +// --------------------------------------------------------------------------- + +export type FilterKey = keyof SearchFilters; + +export interface FilterOption { + value: T; + /** Clé i18n (voir i18n.service.ts) + repli FR. */ + key: string; + label: string; + hint?: string; +} + +/** Une option d'un groupe de filtres, avec son état sélectionné. */ +export interface FilterChoice extends FilterOption { + selected: boolean; +} + +function opt(value: T, key: string, label: string, hint?: string): FilterOption { + return hint ? { value, key, label, hint } : { value, key, label }; +} + +/** Groupes de filtres dans l'ordre d'affichage du panneau. */ +export function filterGroups(f: Partial | null | undefined): Array<{ + key: string; + labelKey: string; + label: string; + multi: boolean; + options: FilterChoice[]; +}> { + const n = normalizeFilters(f); + return [ + { + key: 'type', labelKey: 'filters.type', label: 'Type de contenu', multi: false, + options: [ + opt('all', 'filters.type.all', 'Tout'), + opt('video', 'filters.type.video', 'Vidéos'), + opt('shorts', 'filters.type.shorts', 'Shorts / Clips'), + opt('live', 'filters.type.live', 'En direct'), + opt('channel', 'filters.type.channel', 'Chaînes'), + ].map(o => ({ ...o, selected: n.type === o.value })), + }, + { + key: 'period', labelKey: 'filters.period', label: 'Période', multi: false, + options: [ + opt('all', 'filters.period.all', "N'importe quand"), + opt('hour', 'filters.period.hour', 'Dernière heure'), + opt('today', 'filters.period.today', "Aujourd'hui"), + opt('week', 'filters.period.week', 'Cette semaine'), + opt('month', 'filters.period.month', 'Ce mois'), + opt('year', 'filters.period.year', 'Cette année'), + ].map(o => ({ ...o, selected: n.period === o.value })), + }, + { + key: 'duration', labelKey: 'filters.duration', label: 'Durée', multi: false, + options: [ + opt('all', 'filters.duration.all', 'Toutes'), + opt('short', 'filters.duration.short', 'Courte < 4 min'), + opt('medium', 'filters.duration.medium', 'Moyenne 4-20 min'), + opt('long', 'filters.duration.long', 'Longue > 20 min'), + ].map(o => ({ ...o, selected: n.duration === o.value })), + }, + { + key: 'sort', labelKey: 'filters.sort', label: 'Trier par', multi: false, + options: [ + opt('relevance', 'filters.sort.relevance', 'Pertinence'), + opt('date', 'filters.sort.date', 'Date'), + opt('views', 'filters.sort.views', 'Vues'), + ].map(o => ({ ...o, selected: n.sort === o.value })), + }, + ]; +} + +/** Résumé court (une ligne) des filtres actifs, pour le bouton sur mobile. */ +export function filtersSummary(f: Partial | null | undefined, groups = filterGroups(f)): string { + const n = normalizeFilters(f); + const parts: string[] = []; + if (n.type !== 'all') parts.push(groups[0].options.find(o => o.value === n.type)?.label || n.type); + if (n.period !== 'all') parts.push(groups[1].options.find(o => o.value === n.period)?.label || n.period); + if (n.duration !== 'all') parts.push(groups[2].options.find(o => o.value === n.duration)?.label || n.duration); + return parts.length ? parts.join(' · ') : 'Filtres'; +} + +// --------------------------------------------------------------------------- +// Syntaxe « opérateur » dans la requête : `linux today: long: live:` +// --------------------------------------------------------------------------- + +/** + * Aliases de jetons d'opérateur, tolérés en français et en anglais. + * Un jeton n'est consommé que s'il est *connu* : `https:`, `12:30` ou un + * pseudo-inconnu restent dans la requête telle quelle. + */ +const TOKEN_ALIASES: Record = { + // période + 'heure': { key: 'period', value: 'hour' }, + 'hour': { key: 'period', value: 'hour' }, + 'aujourdhui': { key: 'period', value: 'today' }, + 'today': { key: 'period', value: 'today' }, + 'jour': { key: 'period', value: 'today' }, + 'day': { key: 'period', value: 'today' }, + 'semaine': { key: 'period', value: 'week' }, + 'week': { key: 'period', value: 'week' }, + 'mois': { key: 'period', value: 'month' }, + 'month': { key: 'period', value: 'month' }, + 'annee': { key: 'period', value: 'year' }, + 'année': { key: 'period', value: 'year' }, + 'year': { key: 'period', value: 'year' }, + // durée + 'court': { key: 'duration', value: 'short' }, + 'courte': { key: 'duration', value: 'short' }, + 'short': { key: 'duration', value: 'short' }, + 'moyen': { key: 'duration', value: 'medium' }, + 'moyenne': { key: 'duration', value: 'medium' }, + 'medium': { key: 'duration', value: 'medium' }, + 'long': { key: 'duration', value: 'long' }, + 'longue': { key: 'duration', value: 'long' }, + // type + 'live': { key: 'type', value: 'live' }, + 'direct': { key: 'type', value: 'live' }, + 'video': { key: 'type', value: 'video' }, + 'videos': { key: 'type', value: 'video' }, + 'vidéo': { key: 'type', value: 'video' }, + 'vidéos': { key: 'type', value: 'video' }, + 'shorts': { key: 'type', value: 'shorts' }, + 'clip': { key: 'type', value: 'shorts' }, + 'clips': { key: 'type', value: 'shorts' }, + 'chaine': { key: 'type', value: 'channel' }, + 'chaîne': { key: 'type', value: 'channel' }, + 'chaines': { key: 'type', value: 'channel' }, + 'chaînes': { key: 'type', value: 'channel' }, + 'channel': { key: 'type', value: 'channel' }, + 'channels': { key: 'type', value: 'channel' }, +}; + +/** Jeton = mot (lettres accentuées autorisées) suivi de « : » en fin d'espace/mot. */ +const TOKEN_RE = /(^|\s)([a-zA-Zà-ÿ]{2,12})\s*:(?=\s|$)/g; + +export interface ParsedQuery { + /** Requête nettoyée des jetons d'opérateur reconnus. */ + query: string; + /** Jeton inconnu rencontré (l'utilisateur a peut-être fait une faute). */ + unknownTokens: string[]; + /** Filtres déduits des jetons. */ + filters: Partial; + /** Jeton le plus recently aperçu (pour l'auto-complétion `:`). */ + activeToken: string | null; +} + +/** + * Extrait les opérateurs de la requête (`live:`, `today:`, `long:`, …) et les + * convertit en filtres. Le dernier jeton rencontré gagne, la requête est + * renvoyée nettoyée. + */ +export function parseQueryTokens(input: string): ParsedQuery { + const source = String(input ?? ''); + const filters: Partial = {}; + const unknownTokens: string[] = []; + let activeToken: string | null = null; + + // 1) Jeton en cours de frappe (pas encore suivi de « : ») → pas d'extraction, + // on garde la requête telle quelle. + const trailing = /(^|\s)([a-zA-Zà-ÿ]{1,12})$/.exec(source); + if (trailing) activeToken = trailing[2].toLowerCase(); + + const cleaned = source.replace(TOKEN_RE, (whole, lead: string, word: string) => { + const alias = TOKEN_ALIASES[String(word).toLowerCase()]; + if (!alias) { + unknownTokens.push(String(word).toLowerCase()); + return whole; + } + (filters as any)[alias.key] = alias.value; + return lead ? ' ' : ''; + }).replace(/\s+/g, ' ').trim(); + + return { query: cleaned, unknownTokens, filters, activeToken }; +} + +/** Suggestions de jetons d'opérateur (autocomplétion après « mot : »). */ +export function operatorTokenSuggestions(partial: string, limit = 8): string[] { + const p = String(partial ?? '').trim().toLowerCase(); + const names = Object.keys(TOKEN_ALIASES); + const filtered = p ? names.filter(n => n.startsWith(p)) : names; + // Un seul alias par couple (clé,valeur) pour éviter 3 variantes de « short ». + const seen = new Set(); + const out: string[] = []; + for (const n of filtered) { + const sig = `${TOKEN_ALIASES[n].key}:${TOKEN_ALIASES[n].value}`; + if (seen.has(sig)) continue; + seen.add(sig); + out.push(n); + if (out.length >= limit) break; + } + return out; +} + +// --------------------------------------------------------------------------- +// Prédicat client (réfraction des résultats déjà filtrés par le serveur) +// --------------------------------------------------------------------------- + +export function itemDurationSec(item: any): number { + const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0; + if (typeof raw === 'string') { + // "1:23:45" / "12:34" + if (raw.includes(':')) { + const parts = raw.split(':').map(p => Number(p)); + if (parts.every(p => Number.isFinite(p))) { + const sec = parts.reduce((acc, p) => acc * 60 + p, 0); + return sec; + } + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +export function itemPublishedTs(item: any): number { + const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null; + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0; + if (typeof raw === 'string' && raw.trim()) { + const parsed = Date.parse(raw); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +export function isLiveItem(item: any): boolean { + if (!item) return false; + if (item.isLive === true) return true; + return String(item.type || '').toLowerCase() === 'live'; +} + +export function isShortItem(item: any): boolean { + if (!item) return false; + return isShortVideo({ + duration: itemDurationSec(item), + type: typeof item.type === 'string' ? item.type : undefined, + provider: typeof item.provider === 'string' ? item.provider : undefined, + }); +} + +export function isChannelItem(item: any): boolean { + return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true; +} + +export function isClipItem(item: any): boolean { + return String(item?.kind || '').toLowerCase() === 'clip'; +} + +/** + * Le provider expose-t-il un signal de type exploitable ? Si non, on ne peut + * pas conclure : mieux vaut garder le résultat (le serveur a déjà filtré) que + * de vider la liste. + */ +function hasTypeSignal(item: any): boolean { + 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; +} + +/** + * Prédicat de réfinement client. Règle importante : un champ *absent* ne fait + * jamais disparaître un résultat (les providers ne renvoient pas tous les + * métadonnées) ; seule une valeur présente et hors borne est écartée. + */ +export function matchesFilters(item: any, filters: Partial | null | undefined): boolean { + 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) || isClipItem(item); + if (n.type === 'video') { + return !isLiveItem(item) && !isChannelItem(item) && !isClipItem(item) && !isShortItem(item); + } + return true; +} diff --git a/src/app/search/models.ts b/src/app/search/models.ts index c3ac80f..2656264 100644 --- a/src/app/search/models.ts +++ b/src/app/search/models.ts @@ -1,4 +1,5 @@ export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru'; +import type { SearchFilters } from './filters'; export interface SearchItem { id: string; @@ -26,9 +27,8 @@ export interface ProviderSearchParams { q: string; pageToken?: string; // or page sort?: 'relevance'|'date'|'views'|'duration'; - time?: 'today'|'7d'|'30d'|'all'; - length?: 'short'|'medium'|'long'|'all'; - type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all'; + /** Filtres utilisateur (type de contenu, durée, période). Vides = aucun filtre. */ + filters?: Partial; } export interface ProviderAdapter { diff --git a/src/app/search/search-components.spec.ts b/src/app/search/search-components.spec.ts index 654c5c6..d893cff 100644 --- a/src/app/search/search-components.spec.ts +++ b/src/app/search/search-components.spec.ts @@ -1,13 +1,23 @@ import 'zone.js/node'; import '@angular/compiler'; +// Les schedulers internes d'Angular sont exportés sous leur nom ɵ (le nom +// public `ChangeDetectionScheduler` n'existe pas dans le barrel ESM : l'import +// échouait au link sur les runtimes ESM stricts). import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core'; /** - * Step 11 — Unit tests for the unified search UX components. + * Step 11 - Unit tests for the unified search UX components. * - * ts-node compiles with isolated transpilation (no decorator output => no ɵfac), + * ts-node compiles with isolated transpilation (no decorator output => no ?fac), * so TestBed cannot instantiate Angular classes here. We import dynamically and * construct components inside a minimal injection context with fakes. + * + * Coverage added with the filter panel rework: + * - keyboard navigation of the suggestion panel (↑ ↓ Home End PageUp/Dn Tab + * Enter Escape, wrapping) and the '@' popover, + * - focus-out closes the panels (blur bookkeeping), + * - the filter panel (draft, roving tabindex, apply payload), + * - operator tokens (`live:`, `today:`, …) folded into filters on submit. */ function fmtError(e: unknown): string { @@ -37,15 +47,43 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Faux KeyboardEvent : le composant n'appelle que preventDefault/stopPropagation. */ +function key(k: string, opts: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}): any { + let defaultPrevented = false; + let propagationStopped = false; + return { + key: k, + altKey: !!opts.altKey, + ctrlKey: !!opts.ctrlKey, + metaKey: !!opts.metaKey, + shiftKey: !!opts.shiftKey, + preventDefault: () => { defaultPrevented = true; }, + stopPropagation: () => { propagationStopped = true; }, + stopImmediatePropagation: () => { propagationStopped = true; }, + get isDefaultPrevented() { return defaultPrevented; }, + get isPropagationStopped() { return propagationStopped; }, + }; +} + (async () => { - const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([ + const [ + { SearchBoxComponent }, + { SearchFilterPanelComponent }, + { SearchService }, + { UserService }, + { HistoryService }, + { TelemetryService }, + { PROVIDERS }, + { normalizeFilters, parseQueryTokens, operatorTokenSuggestions }, + ] = await Promise.all([ import('../../components/search/search-box.component'), - import('../../components/search/provider-picker.component'), + import('../../components/search/search-filter-panel.component'), import('./search.service'), import('../../services/user.service'), import('../../services/history.service'), import('../../services/telemetry.service'), import('../core/providers/provider-registry'), + import('./filters'), ]); // --- Fakes for SearchBox dependencies --- @@ -53,6 +91,7 @@ function sleep(ms: number): Promise { const makeSearchServiceFake = (initialProviders: any = 'all') => ({ q$: { value: '' }, providers$: { value: initialProviders }, + filters$: { value: normalizeFilters(null) }, }); const makeUsersServiceFake = (prefs: any = null) => ({ @@ -139,59 +178,332 @@ function sleep(ms: number): Promise { const box = newBox(); const yt = PROVIDERS[0].id; - const ev = { - key: '1', - altKey: true, - ctrlKey: false, - metaKey: false, - shiftKey: false, - preventDefault: () => {}, - stopImmediatePropagation: () => {}, - } as unknown as KeyboardEvent; + const ev = key('1', { altKey: true }); box.handleKeydown(ev); assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on'); - box.handleKeydown(ev); + box.handleKeydown(key('1', { altKey: true })); assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off'); } - // --- ProviderPicker toggles --- + // --- SearchBox: panneau de suggestions au clavier --- - function testPickerDraftSelection(): void { - const picker = new ProviderPickerComponent(); - picker.selected = 'all'; - assert(picker.isChecked('yt'), 'All mode checks every provider'); + /** Force l'ouverture du panneau avec `n` suggestions locales. */ + function openSuggestions(box: any, query: string, count = 3): void { + box.queryUpdate(query); + // Le panneau s'ouvre quand `suggestFlat()` n'est pas vide : on l'ouvre à la + // main (le réseau est absent dans les tests) puis on vérifie le clavier. + box.suggestionsOpen.set(true); + void count; + } - picker.toggle('yt'); + function testSuggestionKeyboardNavigation(): void { + const box = newBox(); + openSuggestions(box, 'lin', 3); + const total = box.suggestFlat().length + 1; // +1 = ligne « Rechercher … » + assert(total > 1, 'suggestion panel has rows'); + + // Rien de sélectionné au départ : ↓ entre par le haut. + assertEqual(box.suggestIndex(), -1, 'no row is highlighted on open'); + box.handleKeydown(key('ArrowDown')); + assertEqual(box.suggestIndex(), 0, 'ArrowDown highlights the first row (search as typed)'); + box.handleKeydown(key('ArrowDown')); + assertEqual(box.suggestIndex(), 1, 'ArrowDown moves to the next row'); + box.handleKeydown(key('ArrowUp')); + assertEqual(box.suggestIndex(), 0, 'ArrowUp goes back'); + // Home / End + box.handleKeydown(key('End')); + assertEqual(box.suggestIndex(), total - 1, 'End jumps to the last row'); + box.handleKeydown(key('Home')); + assertEqual(box.suggestIndex(), 0, 'Home jumps to the first row'); + // PageDown / PageUp + box.handleKeydown(key('PageDown')); + assert(box.suggestIndex() > 0, 'PageDown moves forward'); + box.handleKeydown(key('PageUp')); + assertEqual(box.suggestIndex(), 0, 'PageUp moves back'); + } + + function testSuggestionKeyboardWrapsAndEscape(): void { + const box = newBox(); + openSuggestions(box, 'lin', 3); + const total = box.suggestFlat().length + 1; + + // Wrapping : on remonte de la première ligne -> dernière ligne + box.handleKeydown(key('ArrowUp')); + assertEqual(box.suggestIndex(), total - 1, 'ArrowUp from the top wraps to the last row'); + box.handleKeydown(key('ArrowDown')); + assertEqual(box.suggestIndex(), 0, 'ArrowDown from the bottom wraps to the first row'); + + // Échap ferme le panneau sans vider la saisie + const ev = key('Escape'); + box.handleKeydown(ev); + assert(box.suggestionsOpen() === false, 'Escape closes the suggestion panel'); + assertEqual(box.query(), 'lin', 'Escape keeps the typed text'); + assert(ev.isDefaultPrevented === true, 'Escape preventDefault (no browser default)'); + } + + function testSuggestionEnterSubmitsRow(): void { + const box = newBox(); + openSuggestions(box, 'lin', 2); + const submitted: any[] = []; + box.submitted.subscribe((v: any) => submitted.push(v)); + + // Entrée sans surbrillance = recherche brute + box.handleKeydown(key('Enter')); + assertEqual(submitted.length, 1, 'Enter without highlight runs the typed search'); + assertEqual(submitted[0].q, 'lin', 'the raw query is submitted'); + assert(box.suggestionsOpen() === false, 'the panel closes on submit'); + + // Entrée sur la ligne 0 = « Rechercher … » + openSuggestions(box, 'lin', 2); + box.suggestIndex.set(0); + box.handleKeydown(key('Enter')); + assertEqual(submitted.length, 2, 'Enter on row 0 re-runs the typed search'); + assertEqual(submitted[1].q, 'lin', 'row 0 submits the query as typed'); + + // Entrée sur une suggestion = cette suggestion + openSuggestions(box, 'lin', 2); + box.suggestIndex.set(1); + box.handleKeydown(key('Enter')); + assertEqual(submitted.length, 3, 'Enter on a suggestion submits it'); + assertEqual(submitted[2].q, box.query(), 'the input holds the picked suggestion'); + } + + function testSuggestionTabCompletes(): void { + const box = newBox(); + openSuggestions(box, 'lin', 3); + const submitted: any[] = []; + box.submitted.subscribe((v: any) => submitted.push(v)); + const ev = key('Tab'); + box.handleKeydown(ev); + assert(ev.isDefaultPrevented === true, 'Tab is intercepted while the panel is open'); + assertEqual(submitted.length, 0, 'Tab completes without searching'); + assert(box.suggestionsOpen() === true, 'the panel stays open after a Tab completion'); + } + + function testEscapeClearsQueryWhenNothingOpen(): void { + const box = newBox(); + box.queryUpdate('linux'); + box.handleKeydown(key('Escape')); + assertEqual(box.query(), '', 'Escape with no panel open clears the query'); + } + + // --- SearchBox: focus (le panneau se ferme quand le focus sort) --- + + async function testFocusOutClosesPanels(): Promise { + const box = newBox(); + openSuggestions(box, 'lin', 3); + assert(box.suggestionsOpen() === true, 'panel is open before the focus leaves'); + + // relatedTarget hors du formulaire -> fermeture + const form = { contains: (el: any) => el === inside }; + const inside = { id: 'inside' }; + box.formRef = { nativeElement: form } as any; + const ev: any = { relatedTarget: { id: 'elsewhere' } }; + box.onFocusOut(ev); + await sleep(20); + assert(box.suggestionsOpen() === false, 'focusout with an outside target closes the panel'); + } + + async function testFocusOutKeepsPanelInside(): Promise { + const box = newBox(); + openSuggestions(box, 'lin', 3); + const inside = { id: 'inside' }; + box.formRef = { nativeElement: { contains: (el: any) => el === inside } } as any; + box.onFocusOut({ relatedTarget: inside } as any); + await sleep(20); + assert(box.suggestionsOpen() === true, 'focusout toward a node inside the form keeps the panel open'); + } + + async function testFocusInCancelsPendingClose(): Promise { + const box = newBox(); + openSuggestions(box, 'lin', 3); + const inside = { id: 'inside' }; + box.formRef = { nativeElement: { contains: () => false } } as any; + box.onFocusOut({ relatedTarget: null } as any); // blur « sale » -> timer armé + box.onFocusIn({} as any); // le focus revient + await sleep(20); + assert(box.suggestionsOpen() === true, 'focusin cancels the pending close (no flicker)'); + } + + function testPointerDownKeepsFocusInInput(): void { + const box = newBox(); + const ev: any = { preventDefault: () => { ev.defaultPrevented = true; } }; + box.onPanelPointerDown(ev); + assert(ev.defaultPrevented === true, 'pointerdown inside a panel is neutralized (input keeps focus)'); + } + + // --- SearchBox: opérateurs et filtres --- + + function testOperatorTokensFoldIntoFilters(): void { + const parsed = parseQueryTokens('linux today: long: live:'); + assertEqual(parsed.query, 'linux', 'operators are stripped from the query'); + assertEqual(parsed.filters, { period: 'today', duration: 'long', type: 'live' }, 'operators map to filters'); + // Un mot inconnu reste dans la requête (pas de typo destructive) + const unknown = parseQueryTokens('tuto android:'); + assertEqual(unknown.query, 'tuto android:', 'unknown token stays in the query'); + assertEqual(unknown.unknownTokens, ['android'], 'unknown token is reported'); + // Une URL / heure ne doit pas être interprétée + assertEqual(parseQueryTokens('visite https://x.com').query, 'visite https://x.com', 'URLs are untouched'); + assert(operatorTokenSuggestions('li').includes('live'), 'operator autocompletion offers live:'); + } + + function testSubmitAppliesOperatorTokens(): void { + const box = newBox(); + const submitted: any[] = []; + box.submitted.subscribe((v: any) => submitted.push(v)); + box.queryUpdate('concert live:'); + box.onSubmit({ preventDefault: () => {} } as any); + assertEqual(submitted.length, 1, 'search is submitted'); + assertEqual(submitted[0].q, 'concert', 'the operator is not part of the query'); + assertEqual(submitted[0].filters, normalizeFilters({ type: 'live' }), 'the operator became a filter'); + assertEqual(box.activeFilterCount(), 1, 'one filter is active'); + } + + function testActiveFilterChipsAndRemoval(): void { + const box = newBox(); + box.queryUpdate('chat'); + box.applyPanel({ + selection: 'all', + filters: normalizeFilters({ type: 'live', duration: 'short', period: 'week' }), + rememberDefault: false, + }); + assertEqual(box.activeFilterCount(), 3, 'three filters are active'); + assertEqual(box.activeFilterChips().map((c: any) => c.key), ['type', 'period', 'duration'], 'chips list every active filter'); + box.removeFilter('duration'); + assertEqual(box.activeFilterCount(), 2, 'removing a chip updates the counter'); + box.clearAllFilters(); + assertEqual(box.activeFilterCount(), 0, 'clear-all resets every filter'); + assertEqual(box.activeFilterChips().length, 0, 'no chip left'); + } + + function testApplyPanelPersistsProvidersAndFilters(): void { + const box = newBox(); + const submitted: any[] = []; + box.submitted.subscribe((v: any) => submitted.push(v)); + box.queryUpdate('rust'); + box.applyPanel({ + selection: ['yt', 'tw'], + filters: normalizeFilters({ period: 'month' }), + rememberDefault: false, + }); + assertEqual(box.providersForRequest(), ['yt', 'tw'], 'sources are applied'); + assertEqual(box.filters().period, 'month', 'filters are applied'); + assert(box.panelOpen() === false, 'the panel closes after apply'); + assertEqual(submitted.length, 1, 'an in-flight search is re-run with the new filters'); + assertEqual(submitted[0].filters.period, 'month', 'the submitted payload carries the filters'); + } + + function testPanelDoesNotRelaunchSearchWithoutQuery(): void { + const box = newBox(); + const submitted: any[] = []; + box.submitted.subscribe((v: any) => submitted.push(v)); + box.applyPanel({ + selection: 'all', + filters: normalizeFilters({ type: 'shorts' }), + rememberDefault: false, + }); + assertEqual(submitted.length, 0, 'no search is launched while the query is empty'); + assertEqual(box.filters().type, 'shorts', 'filters are still remembered'); + } + + // --- Panneau de filtres --- + + function newPanel(providers: any = 'all', filters: any = null, history: string[] = []): any { + const panel = new SearchFilterPanelComponent(); + panel.open = true; + panel.history = history; + if (providers !== undefined) panel.selected = providers; + if (filters !== undefined) panel.filters = filters; + return panel; + } + + function testPanelDraftSelection(): void { + const panel = newPanel('all'); + assert(panel.isChecked('yt'), 'All mode checks every provider'); + + panel.toggle('yt'); const yt = 'yt'; - assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider'); - assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one'); - assertEqual(picker.allMode(), false, 'All mode turns off after unchecking'); + assert(panel.isChecked(yt) === false, 'unchecking from All removes only that provider'); + assert(panel.isChecked('dm'), 'other providers stay checked after unchecking one'); + assertEqual(panel.allMode(), false, 'All mode turns off after unchecking'); - picker.toggle(yt); - assert(picker.isChecked(yt), 're-checking adds the provider back'); + panel.toggle(yt); + assert(panel.isChecked(yt), 're-checking adds the provider back'); let emitted: any = null; - picker.apply.subscribe((v: any) => { emitted = v; }); - picker.onApply(); + panel.apply.subscribe((v: any) => { emitted = v; }); + panel.onApply(); assert(Array.isArray(emitted?.selection), 'apply emits a list selection'); assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked'); + assertEqual(emitted.filters, normalizeFilters(null), 'apply emits normalized (default) filters'); - picker.toggleAllMode(); - picker.onApply(); + panel.toggleAllMode(); + panel.onApply(); assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode'); assert(emitted.rememberDefault === false, 'rememberDefault defaults to false'); } - function testPickerRememberDefault(): void { - const picker = new ProviderPickerComponent(); - picker.selected = ['yt']; - picker.rememberDefault.set(true); + function testPanelFiltersAndRememberDefault(): void { + const panel = newPanel(['yt'], null); + panel.rememberDefault.set(true); let emitted: any = null; - picker.apply.subscribe((v: any) => { emitted = v; }); - picker.toggle('dm'); - picker.onApply(); + panel.apply.subscribe((v: any) => { emitted = v; }); + panel.toggle('dm'); + panel.selectFilter('type', 'live'); + panel.selectFilter('period', 'week'); + panel.selectFilter('duration', 'short'); + panel.selectFilter('sort', 'date'); + panel.onApply(); assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply'); assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted'); + assertEqual(emitted.filters, { type: 'live', duration: 'short', period: 'week', sort: 'date' }, 'every filter dimension is emitted'); + } + + function testPanelRovingTabindexFollowsSelection(): void { + const panel = newPanel('all', normalizeFilters({ type: 'live', period: 'month' })); + // L'onglet actif d'un groupe démarre sur l'option sélectionnée. + assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'live'), 'type group starts on the selected option'); + assertEqual(panel.activeIndex('period'), panel.optionIndexOf(panel.groups()[1], 'month'), 'period group starts on the selected option'); + // Une sélection déplace l'onglet actif. + panel.selectFilter('type', 'shorts'); + assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'shorts'), 'selecting moves the roving tabindex'); + } + + function testPanelKeyboardRovingAndEscape(): void { + const panel = newPanel('all'); + const focused: any[] = []; + // Sans DOM, `focusTab` ne trouve rien : on vérifie l'index mis à jour. + (panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } }; + + const target: any = { closest: () => ({ getAttribute: () => 'type' }) }; + const ev: any = key('ArrowRight'); + (ev as any).target = target; + panel.onDialogKeydown(ev); + assert(ev.isDefaultPrevented === true, 'ArrowRight is handled (no page scroll)'); + assertEqual(panel.activeIndex('type'), 1, 'ArrowRight moves to the next option of the group'); + assertEqual(focused.length, 0, 'no crash without a real DOM'); + void focused; + + // Début / fin de groupe + (panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } }; + const end: any = key('End'); + (end as any).target = target; + panel.onDialogKeydown(end); + assert(panel.activeIndex('type') > 1, 'End jumps to the last option'); + + // Échap ferme + let closed = 0; + panel.close.subscribe(() => { closed++; }); + panel.onDialogKeydown(key('Escape')); + assertEqual(closed, 1, 'Escape closes the panel'); + } + + function testPanelReset(): void { + const panel = newPanel(['yt'], normalizeFilters({ type: 'live' })); + panel.toggle('dm'); + panel.reset(); + assertEqual(panel.allMode(), true, 'reset returns to All sources'); + assertEqual(panel.draftFilters(), normalizeFilters(null), 'reset clears every filter'); } const tests: Array<[string, () => void | Promise]> = [ @@ -199,18 +511,35 @@ function sleep(ms: number): Promise { ['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip], ['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied], ['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips], - ['ProviderPicker draft selection + apply', testPickerDraftSelection], - ['ProviderPicker rememberDefault flag', testPickerRememberDefault], + ['SearchBox suggestion panel: ↑ ↓ Home End PageUp/Down', testSuggestionKeyboardNavigation], + ['SearchBox suggestion panel: wrapping + Escape', testSuggestionKeyboardWrapsAndEscape], + ['SearchBox suggestion panel: Enter validates the highlighted row', testSuggestionEnterSubmitsRow], + ['SearchBox suggestion panel: Tab completes without searching', testSuggestionTabCompletes], + ['SearchBox Escape clears the query when nothing is open', testEscapeClearsQueryWhenNothingOpen], + ['SearchBox focusout closes the panel when focus leaves', testFocusOutClosesPanels], + ['SearchBox focusout keeps the panel when focus stays inside', testFocusOutKeepsPanelInside], + ['SearchBox focusin cancels the pending close', testFocusInCancelsPendingClose], + ['SearchBox pointerdown inside a panel does not steal focus', testPointerDownKeepsFocusInInput], + ['SearchBox operator tokens (live:, today:, long:)', testOperatorTokensFoldIntoFilters], + ['SearchBox submit turns operators into filters', testSubmitAppliesOperatorTokens], + ['SearchBox active filter chips + removal', testActiveFilterChipsAndRemoval], + ['SearchBox applyPanel persists sources/filters and re-runs', testApplyPanelPersistsProvidersAndFilters], + ['SearchBox applyPanel without query does not search', testPanelDoesNotRelaunchSearchWithoutQuery], + ['FilterPanel draft selection + apply', testPanelDraftSelection], + ['FilterPanel filters + rememberDefault', testPanelFiltersAndRememberDefault], + ['FilterPanel roving tabindex follows the selection', testPanelRovingTabindexFollowsSelection], + ['FilterPanel arrow keys + Escape', testPanelKeyboardRovingAndEscape], + ['FilterPanel reset', testPanelReset], ]; let failures = 0; for (const [name, fn] of tests) { try { await fn(); - console.log(`✓ ${name}`); + console.log(`V ${name}`); } catch (error) { failures += 1; - console.error(`✗ ${name}`); + console.error(`? ${name}`); console.error(error instanceof Error ? error.stack : fmtError(error)); } } diff --git a/src/app/search/search.service.ts b/src/app/search/search.service.ts index c60b7d9..9cf9957 100644 --- a/src/app/search/search.service.ts +++ b/src/app/search/search.service.ts @@ -11,6 +11,7 @@ import { OdAdapter } from './adapters/od'; import { RuAdapter } from './adapters/ru'; import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models'; import { VideoItem } from 'src/app/shared/models/video-item.model'; +import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters'; export type SuggestionItem = SuggestionItemV1; export type SearchResponse = SearchResponseV1; @@ -28,6 +29,11 @@ export class SearchService { readonly page$ = new BehaviorSubject(1); readonly pageSize$ = new BehaviorSubject(24); readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance'); + /** + * Filtres de recherche (type / durée / période / tri). Source de vérité des + * filtres UI : le tri y est aussi reflété (`sort$` reste pour compat). + */ + readonly filters$ = new BehaviorSubject({ ...DEFAULT_FILTERS }); // In-memory cache 60s per (provider, q, params) private cache = new Map>(); @@ -43,7 +49,7 @@ export class SearchService { ru: new RuAdapter(this.http) }; - readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe( + readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$, this.filters$]).pipe( debounceTime(120), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) ); @@ -51,11 +57,13 @@ export class SearchService { // Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry readonly request$ = this.params$.pipe( filter(([q]) => typeof q === 'string' && q.trim().length >= 2), - switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({ + switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({ q: String(q), pageToken: String(page || 1), sort: (sort as any) || 'relevance', - // global filter knobs could be added here: time/length/type in the future + // Filtres globaux (type / durée / période) : envoyés à chaque adapter, + // appliqués nativement par le provider quand c'est possible. + filters: normalizeFilters(filters), }, prov))) , // Map provider results to the format expected by the UI @@ -88,7 +96,7 @@ export class SearchService { const tasks = active.map(async (pid) => { const adapter = this.adapters[pid]; if (!adapter) return [pid, { items: [] } as SearchResult] as const; - const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`; + const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`; const now = Date.now(); const cached = this.cache.get(key); if (cached && (now - cached.t) < this.cacheTtlMs) { @@ -127,6 +135,26 @@ export class SearchService { setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); } setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration']; - this.sort$.next(allowed.includes(sort) ? sort : 'relevance'); + const next = allowed.includes(sort) ? sort : 'relevance'; + this.sort$.next(next); + // Le tri vit aussi dans `filters` (source de vérité des filtres) : on garde + // les deux BehaviorSubjects synchronisés sans boucle (next -> next). + const current = this.filters$.value; + const mapped = next === 'duration' ? 'relevance' : next; + if (current.sort !== mapped) this.filters$.next({ ...current, sort: mapped as SearchFilters['sort'] }); } + /** Applique un patch de filtres (normalisé) et synchronise `sort$`. */ + setFilters(patch: Partial | null | undefined) { + const next = normalizeFilters({ ...this.filters$.value, ...(patch || {}) }); + if (JSON.stringify(next) === JSON.stringify(this.filters$.value)) return; + this.filters$.next(next); + if (this.sort$.value !== next.sort) this.sort$.next(next.sort as any); + } + resetFilters() { this.setFilters({ ...DEFAULT_FILTERS }); } + private filterKey(filters: Partial | null | undefined): string { + const n = normalizeFilters(filters); + return `${n.type}.${n.duration}.${n.period}.${n.sort}`; + } + /** Filtres actifs seulement (pour l'URL / le partage). */ + activeFilters(): Partial { return activeFilters(this.filters$.value); } } diff --git a/src/app/shared/models/video-item.model.ts b/src/app/shared/models/video-item.model.ts index 98fc6df..426cfba 100644 --- a/src/app/shared/models/video-item.model.ts +++ b/src/app/shared/models/video-item.model.ts @@ -18,6 +18,7 @@ export interface VideoItem { // Live status (only providers exposing it, e.g. Twitch, set these) type?: string; // e.g. 'live' | 'channel' | 'video' isLive?: boolean; // true when actually broadcasting now + 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 } diff --git a/src/components/header/header.component.html b/src/components/header/header.component.html index 2f8488b..1662788 100644 --- a/src/components/header/header.component.html +++ b/src/components/header/header.component.html @@ -36,7 +36,8 @@ @@ -132,7 +133,8 @@ [class.hidden]="!mobileSearchOpen()">
+ (submitted)="onSearchBoxSubmit($event)" + (filtersChange)="onSearchFiltersChange($event)">
diff --git a/src/components/header/header.component.ts b/src/components/header/header.component.ts index 360b28d..bb20802 100644 --- a/src/components/header/header.component.ts +++ b/src/components/header/header.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router, RouterLink, NavigationEnd } from '@angular/router'; +import { Router, RouterLink, NavigationEnd, ActivatedRoute } from '@angular/router'; import { InstanceService, Provider } from '../../services/instance.service'; import { FormsModule } from '@angular/forms'; import { AuthService } from '../../services/auth.service'; @@ -11,6 +11,7 @@ import { I18nService } from '../../services/i18n.service'; import { ThemesService } from '../../services/themes.service'; import { HistoryService, SearchHistoryItem } from '../../services/history.service'; import { SearchBoxComponent } from '../search/search-box.component'; +import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters'; @Component({ selector: 'app-header', @@ -28,6 +29,7 @@ import { SearchBoxComponent } from '../search/search-box.component'; }) export class HeaderComponent implements AfterViewInit { private router = inject(Router); + private route = inject(ActivatedRoute); instances = inject(InstanceService); private auth = inject(AuthService); private userService = inject(UserService); @@ -139,8 +141,8 @@ export class HeaderComponent implements AfterViewInit { return items; }); - // New SearchBox submit handler (navigate with providers list) - onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all' }) { + // New SearchBox submit handler (navigate with providers list + filters) + onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all'; filters?: SearchFilters }) { const q = (evt?.q || '').trim(); if (!q) return; const provider = this.selectedProvider(); @@ -152,6 +154,9 @@ export class HeaderComponent implements AfterViewInit { // Explicitly include all providers so SearchComponent uses unified multi-provider mode qp.providers = 'yt,dm,tw,pt,od,ru'; } + // Filtres (type / durée / période / tri) : seulement les valeurs actives, + // pour garder une URL lisible et partageable. + Object.assign(qp, filtersToQueryParams(evt?.filters)); if (theme) qp.theme = theme; try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {} this.suggestionsOpen.set(false); @@ -159,6 +164,25 @@ export class HeaderComponent implements AfterViewInit { this.router.navigate(['/search'], { queryParams: qp }); } + /** + * Filtres modifiés sans relancer de recherche : on réécrit l'URL courante + * pour que la page /search reste partageable (et se recharge côté serveur). + */ + onSearchFiltersChange(filters: SearchFilters): void { + if (!this.router.url.startsWith('/search')) return; + const current: any = { ...this.route.snapshot.queryParams }; + delete current['type']; + delete current['duration']; + delete current['period']; + delete current['sort']; + Object.assign(current, filtersToQueryParams(filters)); + this.router.navigate([], { + relativeTo: this.route, + queryParams: current, + replaceUrl: true, + }); + } + // --- Mobile chrome actions ---------------------------------------------- toggleMobileSearch(): void { const next = !this.mobileSearchOpen(); @@ -604,7 +628,8 @@ export class HeaderComponent implements AfterViewInit { return; } - // Ctrl/Cmd+K focuses search + // Ctrl/Cmd+K focuses search. Le panneau de filtres (Ctrl/Cmd+Maj+F) est + // entièrement piloté par . if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); if (!this.mobileSearchOpen() && typeof window !== 'undefined' && window.innerWidth < 768) { diff --git a/src/components/search/provider-picker.component.html b/src/components/search/provider-picker.component.html deleted file mode 100644 index 6d29fa0..0000000 --- a/src/components/search/provider-picker.component.html +++ /dev/null @@ -1,41 +0,0 @@ -
-
- -
-
diff --git a/src/components/search/provider-picker.component.ts b/src/components/search/provider-picker.component.ts deleted file mode 100644 index 56e1ac6..0000000 --- a/src/components/search/provider-picker.component.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry'; - -@Component({ - selector: 'app-provider-picker', - standalone: true, - imports: [CommonModule, FormsModule], - templateUrl: './provider-picker.component.html' -}) -export class ProviderPickerComponent implements AfterViewInit, OnDestroy { - @Input() open = false; - // Incoming selection: 'all' or a list of provider ids - @Input() set selected(value: ProviderId[] | 'all') { - // Reset the draft each time the picker opens with a new incoming selection - this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []); - this._allMode.set(value === 'all'); - } - @Output() close = new EventEmitter(); - @Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>(); - - filter = signal(''); - rememberDefault = signal(false); - - // Draft state: working copy while the modal is open - private _draft = signal([]); - private _allMode = signal(true); - readonly draft = computed(() => this._draft()); - readonly allMode = computed(() => this._allMode() && this._draft().length === 0); - - @ViewChild('dialog', { static: false }) dialogRef?: ElementRef; - - readonly providers = computed(() => { - const q = this.filter().toLowerCase(); - return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q)); - }); - - toggleAllMode() { - this._allMode.set(true); - this._draft.set([]); - } - - isChecked(id: ProviderId): boolean { - if (this.allMode()) return true; - return this._draft().includes(id); - } - - toggle(id: ProviderId) { - if (this.allMode()) { - // Leaving All mode by unchecking one provider: start from every provider - // then remove the unchecked one ("all except this one") - const set = new Set(PROVIDERS.map(p => p.id)); - set.delete(id); - this._draft.set(Array.from(set)); - this._allMode.set(false); - return; - } - const set = new Set(this._draft()); - if (set.has(id)) { - set.delete(id); - } else { - set.add(id); - } - this._draft.set(Array.from(set)); - this._allMode.set(false); - } - - onApply() { - const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft(); - this.apply.emit({ selection, rememberDefault: this.rememberDefault() }); - } - - ngAfterViewInit() { - // Focus the dialog for accessibility - queueMicrotask(() => { - try { this.dialogRef?.nativeElement?.focus(); } catch {} - }); - } - - ngOnDestroy() { - try { document.body.style.overflow = ''; } catch {} - } - - // Focus trap within the dialog (Tab / Shift+Tab) - onDialogKeydown(ev: KeyboardEvent) { - if (ev.key === 'Escape') { - ev.preventDefault(); - this.close.emit(); - return; - } - if (ev.key !== 'Tab') return; - const root = this.dialogRef?.nativeElement; - if (!root) return; - const focusables = Array.from(root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) - .filter(el => !el.hasAttribute('disabled')); - if (focusables.length === 0) return; - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const active = document.activeElement as HTMLElement | null; - if (!ev.shiftKey && active === last) { - ev.preventDefault(); - first.focus(); - } else if (ev.shiftKey && (active === first || active === root)) { - ev.preventDefault(); - last.focus(); - } - } -} diff --git a/src/components/search/search-box.component.html b/src/components/search/search-box.component.html index ae4733a..c2c9e41 100644 --- a/src/components/search/search-box.component.html +++ b/src/components/search/search-box.component.html @@ -1,153 +1,205 @@ -