diff --git a/server/index.mjs b/server/index.mjs index f638958..934ffd6 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -16,6 +16,7 @@ import * as cheerio from 'cheerio'; import axios from 'axios'; import rumbleRouter from './rumble.mjs'; import { providerRegistry, validateProviders } from './providers/registry.mjs'; +import { dedupeSuggestGroups } from './suggest.mjs'; import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs'; import { getUserByUsername, @@ -2614,7 +2615,8 @@ app.get('/api/search/suggest', suggestLimiter, async (req, res) => { groups[providerId] = []; } }); - const data = { q, groups }; + // La même occurrence n'est renvoyée qu'une fois (premier provider gagne). + const data = { q, groups: dedupeSuggestGroups(groups, validProviders) }; suggestCacheSet(cacheKey, data); return res.json(data); } catch (e) { diff --git a/server/suggest.mjs b/server/suggest.mjs new file mode 100644 index 0000000..ca11004 --- /dev/null +++ b/server/suggest.mjs @@ -0,0 +1,41 @@ +// Suggest helpers (pure, no network, no DB) — shared dedup policy: +// the same occurrence is only ever returned once (first provider wins). + +/** Normalize a suggestion string for dedup comparisons. */ +export function normSuggestKey(s) { + return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** + * Deduplicate suggestion groups across providers: the same occurrence + * (case/whitespace-insensitive) is kept only once, in the first provider + * group (registry order). Within a group, order is preserved (first wins). + * @param {Record} groups + * @param {string[]} order provider ids in priority order + * @returns {Record} same keys, deduped lists + */ +export function dedupeSuggestGroups(groups, order = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) { + const src = groups && typeof groups === 'object' ? groups : {}; + const seen = new Set(); + const out = {}; + const pushList = (pid) => { + const list = Array.isArray(src[pid]) ? src[pid] : []; + const clean = []; + for (const raw of list) { + const t = String(raw ?? '').trim(); + if (!t) continue; + const k = normSuggestKey(t); + if (seen.has(k)) continue; + seen.add(k); + clean.push(t); + } + out[pid] = clean; + }; + for (const pid of order) { + if (Object.prototype.hasOwnProperty.call(src, pid)) pushList(pid); + } + for (const pid of Object.keys(src)) { + if (!order.includes(pid)) pushList(pid); + } + return out; +} diff --git a/server/tests/suggest.test.mjs b/server/tests/suggest.test.mjs index 9c99f28..23ee43a 100644 --- a/server/tests/suggest.test.mjs +++ b/server/tests/suggest.test.mjs @@ -20,6 +20,7 @@ function logOk(msg) { console.log(`✓ ${msg}`); } const yt = (await import('../providers/youtube.mjs')).default; const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs'); const dm = (await import('../providers/dailymotion.mjs')).default; +const { dedupeSuggestGroups } = await import('../suggest.mjs'); // Bare JSON payload expect( @@ -56,6 +57,17 @@ expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query r expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network'); logOk('suggest min-length guard (no network)'); +// Cross-provider dedup: the same occurrence is only returned once (first provider wins) +{ + const groups = dedupeSuggestGroups( + { yt: ['Tutoriel Angular', 'tutoriel android'], dm: ['TUTORIEL angular', 'autre', ''] }, + ['yt', 'dm'], + ); + expect(JSON.stringify(groups.yt) === JSON.stringify(['Tutoriel Angular', 'tutoriel android']), 'first provider keeps its occurrences'); + expect(JSON.stringify(groups.dm) === JSON.stringify(['autre']), 'duplicates removed from later providers'); + logOk('dedupeSuggestGroups cross-provider (no duplicates)'); +} + // ---------- 2) API contract against a real isolated server ---------- const PORT = await new Promise((resolve) => { const srv = net.createServer(); diff --git a/src/app/search/suggest.spec.ts b/src/app/search/suggest.spec.ts index 2783e80..cb5583d 100644 --- a/src/app/search/suggest.spec.ts +++ b/src/app/search/suggest.spec.ts @@ -4,7 +4,7 @@ import { firstValueFrom, of } from 'rxjs'; import { Injector, runInInjectionContext } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { SuggestService } from './suggest.service'; -import { dedupeSort, mergeGroups, highlightParts } from './suggest.util'; +import { dedupeSort, mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions } from './suggest.util'; /** * Step 15 — Unit tests for query typeahead (parsing / dedup / min-length guard). @@ -57,6 +57,23 @@ function logOk(msg: string): void { ); logOk('mergeGroups order + dedup'); + // --- Pure helpers: dedupeStrings (whitespace + case variants) --- + assertEqual( + dedupeStrings([' Tutoriel Angular ', 'tutoriel angular', 'TUTORIEL ANGULAR', 'autre', '', ' ']), + ['Tutoriel Angular', 'autre'], + 'dedupeStrings shows each occurrence only once (first wins)', + ); + logOk('dedupeStrings dedup'); + + // --- Pure helpers: local fallback suggestions while typing --- + assertEqual(buildLocalQuerySuggestions('a'), [], 'no local suggestions below 2 chars'); + const local = buildLocalQuerySuggestions('angular', 8); + assert(local.length > 0 && local.length <= 8, 'local suggestions fill the panel while typing'); + assertEqual(dedupeStrings(local), local, 'local suggestions contain no duplicates'); + assert(!local.some((s) => s.trim().toLowerCase() === 'angular'), 'local suggestions never repeat the raw query'); + assert(local.some((s) => s.toLowerCase().includes('angular')), 'local suggestions derive from the typed text'); + logOk('buildLocalQuerySuggestions fallback'); + // --- Pure helpers: highlight --- assertEqual( highlightParts('Tutoriel Angular', 'toriel an'), diff --git a/src/app/search/suggest.util.ts b/src/app/search/suggest.util.ts index ff24ef0..cc062c1 100644 --- a/src/app/search/suggest.util.ts +++ b/src/app/search/suggest.util.ts @@ -34,6 +34,53 @@ export function dedupeSort(items: string[], q = ''): string[] { return clean; } +/** + * Deduplicate a flat string list (case/whitespace-insensitive, first + * occurrence wins). Order is preserved. + */ +export function dedupeStrings(items: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of items || []) { + const t = String(raw ?? '').trim(); + if (!t) continue; + const k = normSuggest(t); + if (seen.has(k)) continue; + seen.add(k); + out.push(t); + } + return out; +} + +/** + * Local query suggestions built while typing, without any network call. + * Used as a fallback tier when provider suggestions are missing or sparse + * (providers without a suggest API, offline, rate-limited): typing always + * yields suggestions. Output is deduplicated; the raw query itself is never + * included. + */ +export function buildLocalQuerySuggestions(q: string, max = 10): string[] { + const base = String(q ?? '').trim().replace(/\s+/g, ' '); + if (base.length < 2) return []; + const year = new Date().getFullYear(); + const candidates = [ + `${base} official`, + `${base} live`, + `${base} lyrics`, + `${base} remix`, + `${base} cover`, + `${base} tutorial`, + `${base} review`, + `${base} playlist`, + `${base} best of`, + `${base} full album`, + `${base} ${year}`, + `"${base}"`, + ]; + const n = Math.max(1, Number(max || 10)); + return dedupeStrings(candidates.filter((c) => normSuggest(c) !== normSuggest(base))).slice(0, n); +} + /** * Flatten provider groups into a single ordered list (registry order), * deduped case-insensitively. Unknown provider keys go last (insertion order). diff --git a/src/components/header/header.component.ts b/src/components/header/header.component.ts index 8533137..bb703a7 100644 --- a/src/components/header/header.component.ts +++ b/src/components/header/header.component.ts @@ -49,36 +49,48 @@ export class HeaderComponent { // Suggestion item shape: history vs generated readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => { type Item = { text: string; source: 'history' | 'generated' }; + const norm = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); const q = (this.searchQuery || '').trim(); const provider = this.selectedProvider(); - const fromHistory = (this.recentSearches() || []) - .map(it => (it.query || '').trim()) - .filter(Boolean); + // Historique dédupliqué (la même recherche ne s'affiche qu'une fois). + const seenHistory = new Set(); + const historyBase: string[] = []; + for (const it of this.recentSearches() || []) { + const txt = (it.query || '').trim(); + if (!txt) continue; + const k = norm(txt); + if (seenHistory.has(k)) continue; + seenHistory.add(k); + historyBase.push(txt); + } // 1) Base list from history let historyList: string[]; if (!q) { - historyList = fromHistory.slice(0, 15); + historyList = historyBase.slice(0, 15); } else { const lower = q.toLowerCase(); - historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15); + historyList = historyBase.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15); } - + const items: Item[] = historyList.map(text => ({ text, source: 'history' })); // 2) If we have fewer than 15, fill with generated suggestions if (items.length < 15) { const need = 15 - items.length; const generated = this.generateQuerySuggestions(q, provider); - // Avoid duplicates with history - const existing = new Set(items.map(i => i.text.toLowerCase())); + // Avoid duplicates with history (normalized comparison) + const existing = new Set(items.map(i => norm(i.text))); for (const g of generated) { const t = (g || '').trim(); if (!t) continue; - if (existing.has(t.toLowerCase())) continue; + const k = norm(t); + if (existing.has(k)) continue; + existing.add(k); items.push({ text: t, source: 'generated' }); if (items.length >= 15) break; } + void need; } // Reset highlight if list size changes or becomes empty @@ -152,10 +164,12 @@ export class HeaderComponent { tokens.push(`${base} highlights`, `${base} podcast`); } - // Keep unique and meaningful + // Keep unique and meaningful (normalized: case + whitespace) + const normTok = (s: string) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); const seen = new Set(); for (const t of tokens) { - const key = t.toLowerCase(); + const key = normTok(t); + if (!key || key === normTok(base)) continue; if (!seen.has(key)) { seen.add(key); suggestions.push(t); @@ -352,7 +366,15 @@ export class HeaderComponent { this.history.getSearchHistory(15).subscribe({ next: (items) => { // API likely returns newest first; ensure we keep order and dedupe by query - this.recentSearches.set(items || []); + const seen = new Set(); + const clean: SearchHistoryItem[] = []; + for (const it of items || []) { + const k = String(it?.query || '').trim().toLowerCase().replace(/\s+/g, ' '); + if (!k || seen.has(k)) continue; + seen.add(k); + clean.push(it); + } + this.recentSearches.set(clean); }, error: () => {} }); diff --git a/src/components/search/search-box.component.ts b/src/components/search/search-box.component.ts index 3c5d9f0..7579796 100644 --- a/src/components/search/search-box.component.ts +++ b/src/components/search/search-box.component.ts @@ -11,7 +11,7 @@ import { Subject, of } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { SuggestService } from '../../app/search/suggest.service'; -import { mergeGroups, highlightParts } from '../../app/search/suggest.util'; +import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util'; // No overlays; we render fixed-position modals with high z-index const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i; @@ -87,16 +87,32 @@ export class SearchBoxComponent { private suggestShownFor: string | null = null; readonly suggestFlat = computed((): SuggestFlatItem[] => { const q = this.query(); + // Tier 1 — recherches récentes (dédupliquées, une seule fois chacune). const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null })); - const seen = new Set(recents.map((r) => r.text.toLowerCase())); + const seen = new Set(recents.map((r) => normSuggest(r.text))); + // Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups) + // et contre l'historique. const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => { - const k = m.text.toLowerCase(); + const k = normSuggest(m.text); if (seen.has(k)) return false; seen.add(k); return true; }); + const out: SuggestFlatItem[] = [...recents, ...merged]; + // Tier 3 — suggestions locales générées à la frappe (sans réseau) : la + // frappe propose toujours quelque chose, même sans provider suggest + // (tw/pt/od/ru), hors-ligne ou en cas de réponse vide. + if (out.length < 10) { + for (const t of buildLocalQuerySuggestions(q, 10 - out.length)) { + const k = normSuggest(t); + if (seen.has(k)) continue; + seen.add(k); + out.push({ text: t, provider: null }); + if (out.length >= 15) break; + } + } void q; - return [...recents, ...merged].slice(0, 15); + return out.slice(0, 15); }); // Element to restore focus to when a modal closes (a11y) @@ -247,21 +263,28 @@ export class SearchBoxComponent { /** Filter cached history synchronously; refresh the cache at most every 10s. */ private refreshRecentSuggest(value: string) { const q = String(value || '').trim().toLowerCase(); - if (q.length >= 2 && this.allRecentCache.length > 0) { - this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5)); + if (this.allRecentCache.length > 0) { + // Historique dédupliqué : la même recherche ne s'affiche qu'une fois. + this.recentSuggest.set( + q.length >= 2 + ? this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5) + : this.allRecentCache.slice(0, 5), + ); } else if (q.length < 2) { this.recentSuggest.set([]); } const now = Date.now(); - if (q.length < 2 || now - this.lastRecentFetch < 10_000) return; + if (now - this.lastRecentFetch < 10_000) return; this.lastRecentFetch = now; try { this.history.getSearchHistory(15).subscribe({ next: (items) => { - this.allRecentCache = (items || []).map((i) => String(i?.query || '').trim()).filter(Boolean); + this.allRecentCache = dedupeStrings((items || []).map((i) => String(i?.query || '').trim()).filter(Boolean)); const cur = String(this.query() || '').trim().toLowerCase(); if (cur.length >= 2) { this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(cur)).slice(0, 5)); + } else { + this.recentSuggest.set(this.allRecentCache.slice(0, 5)); } }, error: () => {}, @@ -300,7 +323,9 @@ export class SearchBoxComponent { } onSuggestInputFocus() { - if (!this.atOpen() && String(this.query() || '').trim().length >= 2 && this.suggestFlat().length > 0) { + // Même avec moins de 2 caractères, on propose l'historique récent. + this.refreshRecentSuggest(this.query()); + if (!this.atOpen() && this.suggestFlat().length > 0) { this.suggestionsOpen.set(true); } } @@ -475,8 +500,23 @@ export class SearchBoxComponent { this.saveFocus(); this.quickOpen.set(true); this.telemetry.track('quick_menu_open'); - // Load last 15 searches - try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {} + // Load last 15 searches (deduped: same query shown once) + try { + this.history.getSearchHistory(15).subscribe({ + next: (items) => { + const seen = new Set(); + const clean: SearchHistoryItem[] = []; + for (const it of items || []) { + const k = normSuggest(it?.query || ''); + if (!k || seen.has(k)) continue; + seen.add(k); + clean.push(it); + } + this.recentSearches.set(clean); + }, + error: () => {}, + }); + } catch {} } closeQuickMenu() { if (!this.quickOpen()) return;