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