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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user