feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s

Barre de recherche
- Panneau de filtres unifié (remplace le ProviderPicker) : sources, type
  (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année),
  durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex
  (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F,
  recherches récentes, « mémoriser par défaut », « Réinitialiser ».
- Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur.
- Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants,
  Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon
  recherche brute), Tab complète sans chercher, Échap ferme puis vide,
  ligne « Rechercher <q> », défilement de l'option active, aria à jour.
- Focus : le panneau se referme dès que le focus quitte la barre
  (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les
  lignes pour garder le focus dans l'input (plus de scintillement).
- Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des
  filtres et sont retirés de la requête (autocomplétion après le `:`).

Filtres côté serveur
- Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs.
- /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type,
  duration, features), Data API v3 (type, videoDuration, publishedAfter),
  yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif.
  Un champ manquant ne fait jamais disparaître un résultat.
- Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache).
- Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView
  non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues.

Tests / docs
- 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres),
  server/tests/search-filters.test.mjs (npm run test:filters) + CI,
  scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
2026-09-28 17:42:19 -04:00
parent 9fb0ce7549
commit f1f673040e
40 changed files with 3111 additions and 938 deletions
+104 -8
View File
@@ -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-<videoId>` : 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;
+14 -3
View File
@@ -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);
}
+33 -13
View File
@@ -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<Suggestion[]> }} */
/**
* @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<Suggestion[]> }} */
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') {