From 71278eb0b1f70d96bb8814bcd615eece079fa829 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 21:34:40 -0400 Subject: [PATCH] fix(history): recherche et filtres fonctionnels sur les 3 onglets - distinctUntilChanged() sur Subject avalait toutes les frappes apres la premiere : barre de recherche morte sur les 3 onglets. - Filtre provider recherches : ids courts stockes (yt, dm) vs filtre long (youtube) -> zero resultat. Match court+long cote serveur. - LIKE echappes (%, _, \\) sur q des 3 onglets. - Watch/transcripts : provider normalise (court+long), lang insensible a la casse avec prefixe (fr -> fr, fr-ca). - Ecriture watch canonique (nom long). Test test:history. --- docs/user-management.md | 6 +- package.json | 1 + server/db.mjs | 79 +++++++++---- server/tests/history_filters.test.mjs | 105 ++++++++++++++++++ .../account/history/history.component.ts | 9 +- 5 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 server/tests/history_filters.test.mjs diff --git a/docs/user-management.md b/docs/user-management.md index 8d26da7..11800d7 100644 --- a/docs/user-management.md +++ b/docs/user-management.md @@ -199,15 +199,17 @@ Responses typically include: `{ user: {id, username, email}, accessToken, sessio - PATCH `/user/preferences` → { language?, defaultProvider?, theme?, videoQuality?, region? } ### History -- GET `/user/history/search?limit=&before=` +- GET `/user/history/search?limit=&before=&q=&provider=` (provider court `yt` ou long `youtube`, LIKE échappés) - POST `/user/history/search` → { query, filters? } - DELETE `/user/history/search/:id` | `/user/history/search?all=1` -- GET `/user/history/watch?limit=&before=` +- GET `/user/history/watch?limit=&before=&q=&provider=` (titre/provider/video_id, LIKE échappés, provider normalisé) - POST `/user/history/watch` → { provider, videoId, title?, watchedAt, progressSeconds?, durationSeconds? } - PATCH `/user/history/watch/:id` → { progressSeconds?, lastPositionSeconds? } - DELETE `/user/history/watch/:id` | `/user/history/watch?all=1` +- GET `/user/history/transcripts?limit=&before=&q=&provider=&lang=` (`lang=fr` matche `fr` + `fr-ca`, insensible à la casse ; `q` cherche aussi dans le texte conservé) + ### Subscriptions & Categories - GET `/user/subscriptions?category=&provider=` - POST `/user/subscriptions` → { provider, channelId, channelName?, notify? } diff --git a/package.json b/package.json index 2ff3dd4..e1156c2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "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:transcript": "node --test server/tests/transcript.test.mjs", + "test:history": "node server/tests/history_filters.test.mjs", "test:ytscrape": "node server/tests/youtube-scrape.test.mjs", "test:ytinnertube": "node server/tests/youtube-innertube.test.mjs", "ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\"" diff --git a/server/db.mjs b/server/db.mjs index 26f6a04..8843b4a 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -399,6 +399,29 @@ export function cryptoRandomUUID() { export default db; // -------------------- Search History -------------------- +// Ids courts (front : yt, dm, tw, pt, od, ru) <-> noms longs (API/Watch : +// youtube, dailymotion, twitch, peertube, odysee, rumble). Les filtres +// d'historique acceptent les deux formes ; le stockage garde la forme reçue. +const HISTORY_PROVIDER_ALIASES = { + youtube: ['youtube', 'yt'], + dailymotion: ['dailymotion', 'dm'], + twitch: ['twitch', 'tw'], + peertube: ['peertube', 'pt'], + odysee: ['odysee', 'od'], + rumble: ['rumble', 'ru'], +}; +const HISTORY_SHORT_TO_LONG = { yt: 'youtube', dm: 'dailymotion', tw: 'twitch', pt: 'peertube', od: 'odysee', ru: 'rumble' }; +/** Normalise un provider (court ou long, toute casse) vers le nom long, ou null. */ +export function normalizeHistoryProvider(raw) { + const v = String(raw || '').trim().toLowerCase(); + if (!v) return null; + if (HISTORY_PROVIDER_ALIASES[v]) return v; + return HISTORY_SHORT_TO_LONG[v] || null; +} +/** Échappe les jokers LIKE (`%`, `_`, `\`) d'une saisie utilisateur. */ +export function escapeLikePattern(raw) { + return String(raw ?? '').replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} export function insertSearchHistory({ userId, query, filters }) { const id = cryptoRandomId(); const created_at = nowIso(); @@ -426,14 +449,18 @@ export function listSearchHistory({ userId, limit = 50, before, q, provider }) { const params = [userId]; if (before) { conds.push('created_at < ?'); params.push(String(before)); } if (typeof q === 'string' && q.trim().length > 0) { - const like = `%${q.trim()}%`; - conds.push(`(query LIKE ? OR COALESCE(filters_json,'') LIKE ?)`); + const like = `%${escapeLikePattern(q.trim())}%`; + conds.push(`(query LIKE ? ESCAPE '\\' OR COALESCE(filters_json,'') LIKE ? ESCAPE '\\')`); params.push(like, like); } - if (provider && String(provider).trim()) { - const likeP = `%${String(provider).trim()}%`; - conds.push(`COALESCE(filters_json,'') LIKE ?`); - params.push(likeP); + const normProvider = normalizeHistoryProvider(provider); + if (normProvider) { + // filters_json stocke indifféremment ids courts (["yt","dm"]) et/ou nom + // long ("provider":"youtube") : on matche les deux formes (forme courte + // entre guillemets pour éviter les faux positifs de sous-chaînes). + const [long, short] = HISTORY_PROVIDER_ALIASES[normProvider]; + conds.push(`(COALESCE(filters_json,'') LIKE ? ESCAPE '\\' OR COALESCE(filters_json,'') LIKE ? ESCAPE '\\')`); + params.push(`%${escapeLikePattern(long)}%`, `%\"${short}\"%`); } const where = `WHERE ${conds.join(' AND ')}`; return db.prepare(`SELECT * FROM search_history ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, lim); @@ -451,6 +478,8 @@ export function deleteAllSearchHistory(userId) { export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds }) { const now = nowIso(); const watched_at = watchedAt || now; + // Stockage canonique (nom long) : les filtres matchent court + long. + const normProvider = normalizeHistoryProvider(provider) || String(provider || '').trim().toLowerCase(); // Insert or update on unique (user_id, provider, video_id) db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, thumbnail, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -461,10 +490,10 @@ export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds), last_position_seconds=COALESCE(excluded.last_position_seconds, watch_history.last_position_seconds), last_watched_at=excluded.last_watched_at`).run( - cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now + cryptoRandomId(), userId, normProvider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now ); // Return the row id - const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId); + const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, normProvider, videoId); return row; } @@ -484,13 +513,16 @@ export function listWatchHistory({ userId, limit = 50, before, q, provider }) { const conds = ['user_id = ?']; const params = [userId]; if (before) { conds.push('watched_at < ?'); params.push(String(before)); } - if (provider && String(provider).trim()) { - conds.push('provider = ?'); - params.push(String(provider).trim().toLowerCase()); + const normProvider = normalizeHistoryProvider(provider); + if (normProvider) { + // Lignes anciennes : forme courte possible -> matche les deux formes. + const [long, short] = HISTORY_PROVIDER_ALIASES[normProvider]; + conds.push(`(provider = ? OR provider = ?)`); + params.push(long, short); } if (typeof q === 'string' && q.trim().length > 0) { - const like = `%${q.trim()}%`; - conds.push(`(COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?)`); + const like = `%${escapeLikePattern(q.trim())}%`; + conds.push(`(COALESCE(title,'') LIKE ? ESCAPE '\\' OR provider LIKE ? ESCAPE '\\' OR video_id LIKE ? ESCAPE '\\')`); params.push(like, like, like); } const where = `WHERE ${conds.join(' AND ')}`; @@ -616,17 +648,21 @@ export function listTranscriptHistory({ userId, limit = 50, before, q, provider, const conds = ['user_id = ?']; const params = [userId]; if (before) { conds.push('updated_at < ?'); params.push(String(before)); } - if (provider && String(provider).trim()) { - conds.push('provider = ?'); - params.push(String(provider).trim().toLowerCase()); + const normProvider = normalizeHistoryProvider(provider); + if (normProvider) { + const [long, short] = HISTORY_PROVIDER_ALIASES[normProvider]; + conds.push(`(provider = ? OR provider = ?)`); + params.push(long, short); } if (lang && String(lang).trim()) { - conds.push('lang = ?'); - params.push(String(lang).trim().slice(0, 12).toLowerCase()); + // `fr` matche `fr` et `fr-ca` ; casse indifférente (stockage minuscule). + const primary = String(lang).trim().split('-')[0].toLowerCase(); + conds.push(`(lang = ? OR lang LIKE ? ESCAPE '\\')`); + params.push(primary, `${escapeLikePattern(primary)}-%`); } if (typeof q === 'string' && q.trim().length > 0) { - const like = `%${q.trim()}%`; - conds.push(`(COALESCE(title,'') LIKE ? OR video_id LIKE ? OR lang LIKE ? OR COALESCE(lines_json,'') LIKE ?)`); + const like = `%${escapeLikePattern(q.trim())}%`; + conds.push(`(COALESCE(title,'') LIKE ? ESCAPE '\\' OR video_id LIKE ? ESCAPE '\\' OR lang LIKE ? ESCAPE '\\' OR COALESCE(lines_json,'') LIKE ? ESCAPE '\\')`); params.push(like, like, like, like); } const where = `WHERE ${conds.join(' AND ')}`; @@ -642,7 +678,8 @@ export function deleteTranscriptHistoryById(userId, id) { export function deleteAllTranscriptHistory(userId, provider) { ensureTranscriptHistoryTable(); if (provider && String(provider).trim()) { - db.prepare(`DELETE FROM transcript_history WHERE user_id = ? AND provider = ?`).run(userId, String(provider).trim().toLowerCase()); + const norm = normalizeHistoryProvider(provider) || String(provider).trim().toLowerCase(); + db.prepare(`DELETE FROM transcript_history WHERE user_id = ? AND provider = ?`).run(userId, norm); return; } db.prepare(`DELETE FROM transcript_history WHERE user_id = ?`).run(userId); diff --git a/server/tests/history_filters.test.mjs b/server/tests/history_filters.test.mjs new file mode 100644 index 0000000..04ecc96 --- /dev/null +++ b/server/tests/history_filters.test.mjs @@ -0,0 +1,105 @@ +// Filtres de la section Historique : q/provider/lang sur les 3 onglets. +// Run with: npm run test:history +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-history-test-')); +process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'test.db'); + +const dbMod = await import('../db.mjs'); +const { + insertUser, + cryptoRandomUUID, + insertSearchHistory, + insertSearchHistoryAt, + listSearchHistory, + upsertWatchHistory, + listWatchHistory, + upsertTranscriptHistory, + listTranscriptHistory, + normalizeHistoryProvider, + escapeLikePattern, +} = dbMod; + +function expect(cond, msg) { + if (!cond) throw new Error(`Assertion failed: ${msg}`); +} +function logOk(msg) { console.log(`✓ ${msg}`); } + +const user = cryptoRandomUUID(); +insertUser({ id: user, username: 'hist-a', email: null, passwordHash: 'x' }); + +// ---------- helpers purs ---------- +expect(normalizeHistoryProvider('youtube') === 'youtube', 'long id kept'); +expect(normalizeHistoryProvider('YT') === 'youtube', 'short id mapped, case-insensitive'); +expect(normalizeHistoryProvider('dm') === 'dailymotion', 'dm mapped'); +expect(normalizeHistoryProvider('xx') === null, 'unknown -> null'); +expect(escapeLikePattern('100%_x\\y') === '100\\%\\_x\\\\y', 'LIKE wildcards escaped'); +logOk('normalizeHistoryProvider + escapeLikePattern'); + +// ---------- recherches ---------- +// Formats réels du front : { provider: 'yt', providers: ['yt','dm'] } (ids courts) +insertSearchHistory({ userId: user, query: 'lofi hip hop', filters: { provider: 'yt', providers: ['yt', 'dm'] } }); +insertSearchHistory({ userId: user, query: 'cuisine italienne', filters: { provider: 'dm', providers: ['dm'] } }); +insertSearchHistoryAt({ userId: user, query: 'takeout sans filtres', createdAt: '2024-01-01T00:00:00.000Z' }); +insertSearchHistory({ userId: user, query: '100% bio_pur', filters: { provider: 'yt', providers: ['yt'] } }); + +let rows = listSearchHistory({ userId: user, q: 'lofi' }); +expect(rows.length === 1 && rows[0].query === 'lofi hip hop', 'search q matches query'); +rows = listSearchHistory({ userId: user, provider: 'youtube' }); +expect(rows.length === 2 && rows.every((r) => r.query !== 'cuisine italienne'), 'provider long matches short-id filters_json'); +rows = listSearchHistory({ userId: user, provider: 'yt' }); +expect(rows.length === 2, 'provider short also matches'); +rows = listSearchHistory({ userId: user, provider: 'dailymotion' }); +// 'lofi' cherchait yt+dm : une recherche multi-providers remonte sous chaque provider. +expect(rows.length === 2 && rows.some((r) => r.query === 'cuisine italienne') && rows.some((r) => r.query === 'lofi hip hop'), 'provider dailymotion matches dm rows incl. multi-provider'); +rows = listSearchHistory({ userId: user, q: '100%' }); +expect(rows.length === 1 && rows[0].query === '100% bio_pur', 'LIKE % escaped (no wildcard leak)'); +rows = listSearchHistory({ userId: user, q: '100%bio' }); +expect(rows.length === 0, 'escaped % does not span text'); +rows = listSearchHistory({ userId: user, q: 'bio_pur' }); +expect(rows.length === 1, 'LIKE _ escaped, literal match works'); +logOk('search history q/provider filters'); + +// ---------- visionnage ---------- +upsertWatchHistory({ userId: user, provider: 'youtube', videoId: 'AAA', title: 'Documentaire Océans' }); +upsertWatchHistory({ userId: user, provider: 'tw', videoId: 'BBB', title: 'Live concert' }); +upsertWatchHistory({ userId: user, provider: 'dailymotion', videoId: 'CCC', title: '100% рыбалка_2024' }); + +rows = listWatchHistory({ userId: user, q: 'océans' }); +expect(rows.length === 1 && rows[0].video_id === 'AAA', 'watch q matches title'); +rows = listWatchHistory({ userId: user, provider: 'youtube' }); +expect(rows.length === 1 && rows[0].video_id === 'AAA', 'watch provider long matches'); +rows = listWatchHistory({ userId: user, provider: 'tw' }); +expect(rows.length === 1 && rows[0].video_id === 'BBB', 'watch provider short normalized to twitch'); +rows = listWatchHistory({ userId: user, q: '100%' }); +expect(rows.length === 1 && rows[0].video_id === 'CCC', 'watch LIKE % escaped'); +rows = listWatchHistory({ userId: user, q: '100x' }); +expect(rows.length === 0, 'watch escaped % has no false positive'); +logOk('watch history q/provider filters'); + +// ---------- transcripts ---------- +const L = (texts) => texts.map((text, i) => ({ t: i * 2, dur: 2, text })); +upsertTranscriptHistory({ userId: user, provider: 'youtube', videoId: 'V1', title: 'Conférence IA', lang: 'fr', languages: ['fr'], lines: L(['bonjour à tous', 'parlons intelligence artificielle']) }); +upsertTranscriptHistory({ userId: user, provider: 'youtube', videoId: 'V2', title: 'AI talk', lang: 'en-US', languages: ['en-US'], lines: L(['hello world', 'machine learning']) }); +upsertTranscriptHistory({ userId: user, provider: 'dailymotion', videoId: 'V3', title: 'Recette', lang: 'fr-CA', languages: ['fr-CA'], lines: L(['ajouter du sel', 'mélanger']) }); + +rows = listTranscriptHistory({ userId: user, q: 'intelligence' }); +expect(rows.length === 1 && rows[0].video_id === 'V1', 'transcript q searches lines_json content'); +rows = listTranscriptHistory({ userId: user, provider: 'yt' }); +expect(rows.length === 2, 'transcript provider short normalized'); +rows = listTranscriptHistory({ userId: user, lang: 'FR' }); +expect(rows.length === 2 && rows.every((r) => r.video_id !== 'V2'), 'transcript lang case-insensitive, fr matches fr + fr-ca'); +rows = listTranscriptHistory({ userId: user, lang: 'fr' }); +expect(rows.length === 2 && rows.every((r) => r.video_id !== 'V2'), 'transcript lang fr matches fr + fr-ca, not en-us'); +rows = listTranscriptHistory({ userId: user, q: 'AI', provider: 'youtube', lang: 'en' }); +expect(rows.length === 1 && rows[0].video_id === 'V2', 'transcript combined q+provider+lang'); +logOk('transcript history q/provider/lang filters'); + +// ---------- pagination + filtre combinés ---------- +rows = listSearchHistory({ userId: user, limit: 1, q: 'e' }); +expect(rows.length === 1, 'limit applies with q filter'); +logOk('limit with filters'); + +console.log('history_filters: ALL OK'); diff --git a/src/components/account/history/history.component.ts b/src/components/account/history/history.component.ts index 5296d06..a01ab80 100644 --- a/src/components/account/history/history.component.ts +++ b/src/components/account/history/history.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, computed } from '@a import { CommonModule } from '@angular/common'; import { RouterModule, ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { debounceTime } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { HistoryService, SearchHistoryItem, WatchHistoryItem, TranscriptHistoryItem, @@ -80,8 +80,11 @@ export class HistoryComponent { const tab = this.route.snapshot.queryParamMap.get('tab'); if (tab === 'watch' || tab === 'transcripts' || tab === 'search') this.activeTab.set(tab); } catch {} - // Recherche debouncée -> recharge serveur avec q/provider/lang - this.searchTerms.pipe(debounceTime(300), distinctUntilChanged()).subscribe(() => this.reloadActive(true)); + // Recherche debouncée -> recharge serveur avec q/provider/lang. + // NOTE : pas de distinctUntilChanged() ici : searchTerms est un + // Subject (émissions `undefined`), et distinctUntilChanged + // aurait avalé toutes les frappes après la première. + this.searchTerms.pipe(debounceTime(300)).subscribe(() => this.reloadActive(true)); this.reloadAll(); }