fix(history): recherche et filtres fonctionnels sur les 3 onglets
CI / build-and-test (push) Successful in 14m0s

- distinctUntilChanged() sur Subject<void> 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.
This commit is contained in:
2026-09-27 21:34:40 -04:00
parent a57bc0f7ae
commit 71278eb0b1
5 changed files with 174 additions and 26 deletions
+4 -2
View File
@@ -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? }
+1
View File
@@ -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\""
+58 -21
View File
@@ -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);
+105
View File
@@ -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');
@@ -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<void> (é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();
}