From 55c02ac52ff79d7e33c0d479048b1a0c5457a9ba Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 12:59:01 -0400 Subject: [PATCH] feat(history): refonte page historique en tabs + transcripts conserves et rejoues sans regeneration --- .../20260926_add_transcript_history.sql | 22 + db/schema.sql | 23 + server/db.mjs | 237 +++++-- server/index.mjs | 77 ++- .../account/history/history.component.html | 578 ++++++++-------- .../account/history/history.component.ts | 625 ++++++++++-------- src/components/watch/watch.component.ts | 155 +++-- src/services/history.service.ts | 131 +++- 8 files changed, 1113 insertions(+), 735 deletions(-) create mode 100644 db/migrations/20260926_add_transcript_history.sql diff --git a/db/migrations/20260926_add_transcript_history.sql b/db/migrations/20260926_add_transcript_history.sql new file mode 100644 index 0000000..0361716 --- /dev/null +++ b/db/migrations/20260926_add_transcript_history.sql @@ -0,0 +1,22 @@ +-- Historique des transcripts générés (conservés par utilisateur/vidéo/langue). +CREATE TABLE IF NOT EXISTS transcript_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + video_id TEXT NOT NULL, + title TEXT, + thumbnail TEXT, + lang TEXT NOT NULL, + languages_json TEXT, + lines_json TEXT NOT NULL DEFAULT '[]', + line_count INTEGER NOT NULL DEFAULT 0, + char_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang + ON transcript_history(user_id, provider, video_id, lang); +CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time + ON transcript_history(user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider + ON transcript_history(user_id, provider); diff --git a/db/schema.sql b/db/schema.sql index 565645b..3cca4bf 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -85,6 +85,29 @@ CREATE TABLE IF NOT EXISTS watch_history ( CREATE UNIQUE INDEX IF NOT EXISTS uq_watch_history_user_video ON watch_history(user_id, provider, video_id); CREATE INDEX IF NOT EXISTS idx_watch_history_user_time ON watch_history(user_id, watched_at DESC); +-- Transcript history (transcripts générés et conservés) -------------------- +CREATE TABLE IF NOT EXISTS transcript_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + video_id TEXT NOT NULL, + title TEXT, + thumbnail TEXT, + lang TEXT NOT NULL, + languages_json TEXT, + lines_json TEXT NOT NULL DEFAULT '[]', + line_count INTEGER NOT NULL DEFAULT 0, + char_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang + ON transcript_history(user_id, provider, video_id, lang); +CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time + ON transcript_history(user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider + ON transcript_history(user_id, provider); + -- Channels cache & subscriptions -------------------------------------------- CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/db.mjs b/server/db.mjs index fce1fb0..9397702 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -392,40 +392,23 @@ export function insertSearchHistory({ userId, query, filters }) { return { id, created_at }; } -export function listSearchHistory({ userId, limit = 50, before, q }) { - const hasBefore = Boolean(before); - const hasQ = typeof q === 'string' && q.trim().length > 0; - const like = `%${(q || '').trim()}%`; - if (hasBefore && hasQ) { - return db.prepare(` - SELECT * FROM search_history - WHERE user_id = ? AND created_at < ? AND (query LIKE ? OR COALESCE(filters_json,'') LIKE ?) - ORDER BY created_at DESC - LIMIT ? - `).all(userId, before, like, like, limit); +export function listSearchHistory({ userId, limit = 50, before, q, provider }) { + const lim = Math.max(1, Math.min(200, Number(limit) || 50)); + const conds = ['user_id = ?']; + 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 ?)`); + params.push(like, like); } - if (hasBefore) { - return db.prepare(` - SELECT * FROM search_history - WHERE user_id = ? AND created_at < ? - ORDER BY created_at DESC - LIMIT ? - `).all(userId, before, limit); + if (provider && String(provider).trim()) { + const likeP = `%${String(provider).trim()}%`; + conds.push(`COALESCE(filters_json,'') LIKE ?`); + params.push(likeP); } - if (hasQ) { - return db.prepare(` - SELECT * FROM search_history - WHERE user_id = ? AND (query LIKE ? OR COALESCE(filters_json,'') LIKE ?) - ORDER BY created_at DESC - LIMIT ? - `).all(userId, like, like, limit); - } - return db.prepare(` - SELECT * FROM search_history - WHERE user_id = ? - ORDER BY created_at DESC - LIMIT ? - `).all(userId, limit); + const where = `WHERE ${conds.join(' AND ')}`; + return db.prepare(`SELECT * FROM search_history ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, lim); } export function deleteSearchHistoryById(userId, id) { @@ -468,41 +451,22 @@ export function updateWatchHistoryById(id, { progressSeconds, lastPositionSecond return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id); } -export function listWatchHistory({ userId, limit = 50, before, q }) { - const hasBefore = Boolean(before); - const hasQ = typeof q === 'string' && q.trim().length > 0; - const like = `%${(q || '').trim()}%`; - if (hasBefore && hasQ) { - return db.prepare(` - SELECT * FROM watch_history - WHERE user_id = ? AND watched_at < ? - AND (COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?) - ORDER BY watched_at DESC - LIMIT ? - `).all(userId, before, like, like, like, limit); +export function listWatchHistory({ userId, limit = 50, before, q, provider }) { + const lim = Math.max(1, Math.min(200, Number(limit) || 50)); + 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()); } - if (hasBefore) { - return db.prepare(` - SELECT * FROM watch_history - WHERE user_id = ? AND watched_at < ? - ORDER BY watched_at DESC - LIMIT ? - `).all(userId, before, limit); + if (typeof q === 'string' && q.trim().length > 0) { + const like = `%${q.trim()}%`; + conds.push(`(COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?)`); + params.push(like, like, like); } - if (hasQ) { - return db.prepare(` - SELECT * FROM watch_history - WHERE user_id = ? AND (COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?) - ORDER BY watched_at DESC - LIMIT ? - `).all(userId, like, like, like, limit); - } - return db.prepare(` - SELECT * FROM watch_history - WHERE user_id = ? - ORDER BY watched_at DESC - LIMIT ? - `).all(userId, limit); + const where = `WHERE ${conds.join(' AND ')}`; + return db.prepare(`SELECT * FROM watch_history ${where} ORDER BY watched_at DESC LIMIT ?`).all(...params, lim); } export function deleteWatchHistoryById(userId, id) { @@ -513,6 +477,149 @@ export function deleteAllWatchHistory(userId) { db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId); } +// -------------------- Transcript History -------------------- +// Conserve les transcripts générés pour les rejouer sans régénération. +function ensureTranscriptHistoryTable() { + try { + db.exec(`CREATE TABLE IF NOT EXISTS transcript_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + video_id TEXT NOT NULL, + title TEXT, + thumbnail TEXT, + lang TEXT NOT NULL, + languages_json TEXT, + lines_json TEXT NOT NULL DEFAULT '[]', + line_count INTEGER NOT NULL DEFAULT 0, + char_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang + ON transcript_history(user_id, provider, video_id, lang);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time + ON transcript_history(user_id, updated_at DESC);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider + ON transcript_history(user_id, provider);`); + } catch {} +} +ensureTranscriptHistoryTable(); + +function transcriptRowToApi(row) { + if (!row) return null; + let lines = []; + let languages = []; + try { lines = JSON.parse(row.lines_json || '[]'); if (!Array.isArray(lines)) lines = []; } catch { lines = []; } + try { languages = JSON.parse(row.languages_json || '[]'); if (!Array.isArray(languages)) languages = []; } catch { languages = []; } + const preview = lines.slice(0, 3).map((l) => String(l?.text || '')).join(' ').slice(0, 280); + return { + id: row.id, + provider: row.provider, + video_id: row.video_id, + title: row.title, + thumbnail: row.thumbnail, + lang: row.lang, + languages, + line_count: row.line_count, + char_count: row.char_count, + preview, + created_at: row.created_at, + updated_at: row.updated_at, + lines, + }; +} + +export function upsertTranscriptHistory({ userId, provider, videoId, title, thumbnail, lang, languages, lines }) { + ensureTranscriptHistoryTable(); + if (!userId || !provider || !videoId || !lang) return null; + const cleanLines = Array.isArray(lines) ? lines.slice(0, 2000).map((l) => ({ + t: Number(l?.t) || 0, + dur: Number(l?.dur) || 0, + text: String(l?.text || '').slice(0, 2000), + })) : []; + if (!cleanLines.length) return null; + const now = nowIso(); + const normLang = String(lang).trim().slice(0, 12).toLowerCase(); + const normProvider = String(provider).trim().slice(0, 32).toLowerCase(); + const normVideoId = String(videoId).trim().slice(0, 256); + const languages_json = JSON.stringify(Array.isArray(languages) ? languages.slice(0, 32) : []); + const lines_json = JSON.stringify(cleanLines); + const line_count = cleanLines.length; + const char_count = cleanLines.reduce((n, l) => n + String(l.text || '').length, 0); + const existing = db.prepare( + `SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? AND lang = ?` + ).get(userId, normProvider, normVideoId, normLang); + if (existing) { + db.prepare(`UPDATE transcript_history SET title = COALESCE(?, title), thumbnail = COALESCE(?, thumbnail), + languages_json = ?, lines_json = ?, line_count = ?, char_count = ?, updated_at = ? WHERE id = ?`) + .run(title || null, thumbnail || null, languages_json, lines_json, line_count, char_count, now, existing.id); + return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ?`).get(existing.id)); + } + const id = cryptoRandomId(); + db.prepare(`INSERT INTO transcript_history (id, user_id, provider, video_id, title, thumbnail, lang, languages_json, lines_json, line_count, char_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, userId, normProvider, normVideoId, title || null, thumbnail || null, normLang, languages_json, lines_json, line_count, char_count, now, now); + return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ?`).get(id)); +} + +export function getTranscriptHistoryItem({ userId, provider, videoId, lang, id }) { + ensureTranscriptHistoryTable(); + if (id) { + return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ? AND user_id = ?`).get(id, userId)); + } + if (!provider || !videoId) return null; + const normProvider = String(provider).trim().toLowerCase(); + const normVideoId = String(videoId).trim(); + if (lang) { + const normLang = String(lang).trim().slice(0, 12).toLowerCase(); + return transcriptRowToApi(db.prepare( + `SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? AND lang = ?` + ).get(userId, normProvider, normVideoId, normLang)); + } + return transcriptRowToApi(db.prepare( + `SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? ORDER BY updated_at DESC LIMIT 1` + ).get(userId, normProvider, normVideoId)); +} + +export function listTranscriptHistory({ userId, limit = 50, before, q, provider, lang }) { + ensureTranscriptHistoryTable(); + const lim = Math.max(1, Math.min(200, Number(limit) || 50)); + 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()); + } + if (lang && String(lang).trim()) { + conds.push('lang = ?'); + params.push(String(lang).trim().slice(0, 12).toLowerCase()); + } + 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 ?)`); + params.push(like, like, like, like); + } + const where = `WHERE ${conds.join(' AND ')}`; + const rows = db.prepare(`SELECT * FROM transcript_history ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params, lim); + return rows.map(transcriptRowToApi); +} + +export function deleteTranscriptHistoryById(userId, id) { + ensureTranscriptHistoryTable(); + db.prepare(`DELETE FROM transcript_history WHERE id = ? AND user_id = ?`).run(id, userId); +} + +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()); + return; + } + db.prepare(`DELETE FROM transcript_history WHERE user_id = ?`).run(userId); +} + // -------------------- Likes (via tags/video_tags) -------------------- function ensureTag(userId, name) { const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, name); diff --git a/server/index.mjs b/server/index.mjs index 8c106f4..f638958 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -83,6 +83,11 @@ import { sumCompletedDownloadBytes, SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS, DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS, + upsertTranscriptHistory, + getTranscriptHistoryItem, + listTranscriptHistory, + deleteTranscriptHistoryById, + deleteAllTranscriptHistory, } from './db.mjs'; import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs'; import { fetchChannelContent } from './providers/channel-content.mjs'; @@ -1976,7 +1981,8 @@ r.get('/user/history/search', authMiddleware, (req, res) => { const limit = Math.min(200, Number(req.query.limit || 50)); const before = req.query.before ? String(req.query.before) : undefined; const q = typeof req.query.q === 'string' ? req.query.q : undefined; - const rows = listSearchHistory({ userId: req.user.id, limit, before, q }); + const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined; + const rows = listSearchHistory({ userId: req.user.id, limit, before, q, provider }); return res.json(rows); }); @@ -2003,7 +2009,8 @@ r.get('/user/history/watch', authMiddleware, (req, res) => { const limit = Math.min(200, Number(req.query.limit || 50)); const before = req.query.before ? String(req.query.before) : undefined; const q = typeof req.query.q === 'string' ? req.query.q : undefined; - const rows = listWatchHistory({ userId: req.user.id, limit, before, q }); + const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined; + const rows = listWatchHistory({ userId: req.user.id, limit, before, q, provider }); return res.json(rows); }); @@ -2032,6 +2039,72 @@ r.delete('/user/history/watch', authMiddleware, (req, res) => { return res.status(204).end(); }); +// --- History: Transcripts (conservés, rejoués sans régénération) --- +r.post('/user/history/transcripts', authMiddleware, (req, res) => { + const { provider, videoId, title, thumbnail, lang, languages, lines } = req.body || {}; + if (!provider || !videoId || !lang) { + return res.status(400).json({ error: 'provider, videoId and lang are required' }); + } + if (!Array.isArray(lines) || !lines.length) { + return res.status(400).json({ error: 'lines required' }); + } + try { + const row = upsertTranscriptHistory({ + userId: req.user.id, provider, videoId, title, thumbnail, lang, languages, lines, + }); + if (!row) return res.status(400).json({ error: 'invalid_transcript' }); + return res.status(201).json(row); + } catch (e) { + return res.status(500).json({ error: 'save_failed', details: String(e?.message || e) }); + } +}); + +r.get('/user/history/transcripts', authMiddleware, (req, res) => { + const limit = Math.min(200, Number(req.query.limit || 50)); + const before = req.query.before ? String(req.query.before) : undefined; + const q = typeof req.query.q === 'string' ? req.query.q : undefined; + const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined; + const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined; + try { + const rows = listTranscriptHistory({ userId: req.user.id, limit, before, q, provider, lang }); + return res.json(rows); + } catch (e) { + return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) }); + } +}); + +r.get('/user/history/transcripts/:provider/:videoId', authMiddleware, (req, res) => { + const { provider, videoId } = req.params; + const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined; + try { + const row = getTranscriptHistoryItem({ userId: req.user.id, provider, videoId, lang }); + if (!row) return res.status(404).json({ available: false, error: 'not_found' }); + return res.json({ available: true, fromHistory: true, ...row }); + } catch (e) { + return res.status(500).json({ available: false, error: 'lookup_failed' }); + } +}); + +r.delete('/user/history/transcripts/:id', authMiddleware, (req, res) => { + try { + deleteTranscriptHistoryById(req.user.id, req.params.id); + return res.status(204).end(); + } catch (e) { + return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) }); + } +}); + +r.delete('/user/history/transcripts', authMiddleware, (req, res) => { + if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' }); + const provider = typeof req.query.provider === 'string' && req.query.provider ? String(req.query.provider) : undefined; + try { + deleteAllTranscriptHistory(req.user.id, provider); + return res.status(204).end(); + } catch (e) { + return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) }); + } +}); + // --- Likes --- // NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via // cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer diff --git a/src/components/account/history/history.component.html b/src/components/account/history/history.component.html index 2264f15..db87fab 100644 --- a/src/components/account/history/history.component.html +++ b/src/components/account/history/history.component.html @@ -1,316 +1,288 @@
-
+

Historique

-
-
- - - -
- - +
-
- -
-
-

- - - - Historique des recherches -

-
- - {{ searchHistory().length }} recherche(s) - - • {{ filteredSearchHistory().length }} résultat(s) - - -
-
- - - -
-
-

Recherche en cours...

-
- -
- Aucun résultat trouvé dans l'historique des recherches pour "{{ searchQuery() }}" -
- - -
- - - -
- Aucune recherche récente -
- - -
-
- - -
-
-

- - - - Historique de visionnage -

-
- - {{ watchHistory().length }} vidéo(s) - - • {{ filteredWatchHistory().length }} résultat(s) - - -
-
- - - -
-
-

Recherche en cours...

-
- -
- Aucun résultat trouvé dans l'historique de visionnage pour "{{ searchQuery() }}" -
- - -
- - - -
- Aucune vidéo récemment regardée -
- - -
-
+ +
+ + +
+ + +
+
+
+ + +
+ + + +
+
+ + +
+
+
+ + +
+ + + +
+
+ + +
+
+
+ + +
+ + + + + +
+

+ Les transcripts sont conservés à la génération et rejoués depuis l'historique — aucune régénération inutile. +

+
+ + +
+
+
+

Chargement…

+
+
+ Aucune recherche {{ searchQuery() ? 'pour « ' + searchQuery() + ' »' : 'récente' }} +
+ +
+ +
+
+ + +
+
+
+

Chargement…

+
+
+ Aucune vidéo {{ watchQuery() ? 'pour « ' + watchQuery() + ' »' : 'récemment regardée' }} +
+ +
+ +
+
+ + +
+
+
+

Chargement…

+
+
+ Aucun transcript conservé {{ transcriptQuery() ? 'pour « ' + transcriptQuery() + ' »' : 'pour le moment' }}. + Ouvrez un transcript depuis une page Watch : il sera conservé ici. +
+
    +
  • +
    +
    +
    + @if (t.thumbnail) { } + @else { TXT } +
    +
    +
    {{ t.title || t.video_id }}
    +
    + + {{ getProviderDisplayName(t.provider) }} + + {{ t.lang }} + {{ t.line_count }} passages + {{ formatDate(t.updated_at) }} +
    +

    {{ t.preview || '—' }}

    +
    +
    +
    + Revoir + + +
    +
    +
    + [{{ formatDuration(l.t) }}] + {{ l.text }} +
    +
    +
    +
  • +
+
+ +
+
diff --git a/src/components/account/history/history.component.ts b/src/components/account/history/history.component.ts index 94ea36c..e88bc5d 100644 --- a/src/components/account/history/history.component.ts +++ b/src/components/account/history/history.component.ts @@ -1,10 +1,26 @@ -import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core'; -import { CommonModule, DatePipe } from '@angular/common'; -import { RouterModule } from '@angular/router'; +import { ChangeDetectionStrategy, Component, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; -import { Subject, of } from 'rxjs'; -import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { + HistoryService, SearchHistoryItem, WatchHistoryItem, TranscriptHistoryItem, +} from '../../../services/history.service'; + +export type HistoryTab = 'search' | 'watch' | 'transcripts'; +export type DateFilter = 'all' | 'today' | 'week' | 'month'; +export type SortOrder = 'recent' | 'oldest' | 'alpha'; + +const PROVIDER_OPTIONS = [ + { id: '', label: 'Tous providers' }, + { id: 'youtube', label: 'YouTube' }, + { id: 'dailymotion', label: 'Dailymotion' }, + { id: 'twitch', label: 'Twitch' }, + { id: 'peertube', label: 'PeerTube' }, + { id: 'odysee', label: 'Odysee' }, + { id: 'rumble', label: 'Rumble' }, +]; @Component({ selector: 'app-history', @@ -15,330 +31,379 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se }) export class HistoryComponent { private history = inject(HistoryService); + private route = inject(ActivatedRoute); + + activeTab = signal('search'); + providerOptions = PROVIDER_OPTIONS; + + // --- Filtres par onglet --- + searchQuery = signal(''); + searchProvider = signal(''); + searchDate = signal('all'); + searchSort = signal('recent'); + + watchQuery = signal(''); + watchProvider = signal(''); + watchDate = signal('all'); + watchSort = signal('recent'); + + transcriptQuery = signal(''); + transcriptProvider = signal(''); + transcriptLang = signal(''); + transcriptDate = signal('all'); + transcriptSort = signal('recent'); + transcriptInnerSearch = signal(''); + expandedTranscriptId = signal(null); + + // --- Données brutes (serveur déjà filtré par q/provider/lang) --- + searchItems = signal([]); + watchItems = signal([]); + transcriptItems = signal([]); + + searchLoading = signal(false); + watchLoading = signal(false); + transcriptLoading = signal(false); + + searchHasMore = signal(true); + watchHasMore = signal(true); + transcriptHasMore = signal(true); - loading = signal(false); - searchHistory = signal([]); - watchHistory = signal([]); - searchQuery = signal(''); - isLoading = signal(false); deletingSearchId = signal(null); deletingWatchId = signal(null); - - // Historiques complets - filteredSearchHistory = signal([]); - filteredWatchHistory = signal([]); - - // Sujet pour la recherche avec debounce - private searchTerms = new Subject(); - + deletingTranscriptId = signal(null); + clearingTab = signal(null); - // Obtenir le nom d'affichage d'un provider - getProviderName(providerId: string): string { - const names: Record = { - 'youtube': 'YouTube', - 'dailymotion': 'Dailymotion', - 'twitch': 'Twitch', - 'peertube': 'PeerTube', - 'odysee': 'Odysee', - 'rumble': 'Rumble' - }; - return names[providerId.toLowerCase()] || providerId; - } + private searchTerms = new Subject(); constructor() { - // Chargement initial des données - this.reload(); - - // Configuration de la recherche avec debounce - this.searchTerms.pipe( - debounceTime(300), - distinctUntilChanged(), - switchMap((term: string) => { - if (!term.trim()) { - // Si la recherche est vide, on recharge les 50 premiers éléments - return of({ search: [], watch: [] }); - } - this.isLoading.set(true); - return this.history.searchInSearchHistory(term).pipe( - switchMap(searchResults => - this.history.searchInWatchHistory(term).pipe( - switchMap(watchResults => - of({ search: searchResults, watch: watchResults }) - ) - ) - ) - ); - }) - ).subscribe({ - next: ({ search, watch }) => { - this.filteredSearchHistory.set(search); - this.filteredWatchHistory.set(watch); - this.isLoading.set(false); + try { + 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)); + this.reloadAll(); + } + + // ============ Tabs ============ + setTab(tab: HistoryTab): void { + this.activeTab.set(tab); + this.reloadActive(true); + } + + onFilterChange(): void { + this.searchTerms.next(); + } + + // ============ Chargement ============ + reloadAll(): void { + this.loadSearch(true); + this.loadWatch(true); + this.loadTranscripts(true); + } + + reloadActive(reset = false): void { + const tab = this.activeTab(); + if (tab === 'search') this.loadSearch(reset); + else if (tab === 'watch') this.loadWatch(reset); + else this.loadTranscripts(reset); + } + + loadMore(): void { + const tab = this.activeTab(); + if (tab === 'search') this.loadSearch(false); + else if (tab === 'watch') this.loadWatch(false); + else this.loadTranscripts(false); + } + + private loadSearch(reset: boolean): void { + if (this.searchLoading()) return; + this.searchLoading.set(true); + const before = reset ? undefined : this.oldestSearchDate(); + this.history.getSearchHistory(50, before, this.searchQuery().trim() || undefined, this.searchProvider() || undefined).subscribe({ + next: (items) => { + const list = items || []; + this.searchItems.update(prev => reset ? list : [...(prev || []), ...list]); + this.searchHasMore.set(list.length >= 50); + this.searchLoading.set(false); }, - error: () => { - this.isLoading.set(false); - } - }); - - // Réagir aux changements de la requête de recherche - effect(() => { - const query = this.searchQuery(); - if (query === '') { - // Si la recherche est vide, on recharge les 50 premiers éléments - this.reload(); - } else { - // Sinon on lance la recherche - this.searchTerms.next(query); - } + error: () => this.searchLoading.set(false), }); } - reload() { - this.isLoading.set(true); - this.history.getSearchHistory(50).subscribe({ + private loadWatch(reset: boolean): void { + if (this.watchLoading()) return; + this.watchLoading.set(true); + const before = reset ? undefined : this.oldestWatchDate(); + this.history.getWatchHistory(50, before, this.watchQuery().trim() || undefined, this.watchProvider() || undefined).subscribe({ next: (items) => { - this.searchHistory.set(items || []); - if (!this.searchQuery()) { - this.filteredSearchHistory.set(items || []); - } + const list = items || []; + this.watchItems.update(prev => reset ? list : [...(prev || []), ...list]); + this.watchHasMore.set(list.length >= 50); + this.watchLoading.set(false); }, - error: () => { - this.isLoading.set(false); - }, - }); - this.history.getWatchHistory(50).subscribe({ - next: (items) => { - this.watchHistory.set(items || []); - if (!this.searchQuery()) { - this.filteredWatchHistory.set(items || []); - } - }, - error: () => { - this.isLoading.set(false); - }, - complete: () => { - this.isLoading.set(false); - } + error: () => this.watchLoading.set(false), }); } - // --- Delete a single search history item + private loadTranscripts(reset: boolean): void { + if (this.transcriptLoading()) return; + this.transcriptLoading.set(true); + const before = reset ? undefined : this.oldestTranscriptDate(); + this.history.listTranscripts({ + limit: 50, before, + q: this.transcriptQuery().trim() || undefined, + provider: this.transcriptProvider() || undefined, + lang: this.transcriptLang().trim() || undefined, + }).subscribe({ + next: (items) => { + const list = items || []; + this.transcriptItems.update(prev => reset ? list : [...(prev || []), ...list]); + this.transcriptHasMore.set(list.length >= 50); + this.transcriptLoading.set(false); + }, + error: () => this.transcriptLoading.set(false), + }); + } + + private oldestSearchDate(): string | undefined { + const l = this.searchItems(); + return l.length ? l[l.length - 1].created_at : undefined; + } + private oldestWatchDate(): string | undefined { + const l = this.watchItems(); + return l.length ? l[l.length - 1].watched_at : undefined; + } + private oldestTranscriptDate(): string | undefined { + const l = this.transcriptItems(); + return l.length ? l[l.length - 1].updated_at : undefined; + } + + // ============ Filtrage client (date + tri) ============ + private applyDateFilter(items: T[], getDate: (x: T) => string, f: DateFilter): T[] { + if (f === 'all') return items; + const now = Date.now(); + const day = 24 * 3600 * 1000; + const maxAge = f === 'today' ? day : f === 'week' ? 7 * day : 30 * day; + return items.filter((x) => { + const t = new Date(getDate(x)).getTime(); + return Number.isFinite(t) && (now - t) <= maxAge; + }); + } + + private applySort(items: T[], getDate: (x: T) => string, getLabel: (x: T) => string, sort: SortOrder): T[] { + const arr = [...items]; + if (sort === 'alpha') arr.sort((a, b) => getLabel(a).localeCompare(getLabel(b))); + else if (sort === 'oldest') arr.sort((a, b) => new Date(getDate(a)).getTime() - new Date(getDate(b)).getTime()); + else arr.sort((a, b) => new Date(getDate(b)).getTime() - new Date(getDate(a)).getTime()); + return arr; + } + + filteredSearch = computed(() => { + const dated = this.applyDateFilter(this.searchItems(), (x) => x.created_at, this.searchDate()); + return this.applySort(dated, (x) => x.created_at, (x) => x.query, this.searchSort()); + }); + + filteredWatch = computed(() => { + const dated = this.applyDateFilter(this.watchItems(), (x) => x.watched_at, this.watchDate()); + return this.applySort(dated, (x) => x.watched_at, (x) => x.title || '', this.watchSort()); + }); + + filteredTranscripts = computed(() => { + const dated = this.applyDateFilter(this.transcriptItems(), (x) => x.updated_at, this.transcriptDate()); + const sorted = this.applySort(dated, (x) => x.updated_at, (x) => x.title || '', this.transcriptSort()); + const q = this.transcriptInnerSearch().trim().toLowerCase(); + if (!q) return sorted; + return sorted.filter((t) => + (t.title || '').toLowerCase().includes(q) || + (t.preview || '').toLowerCase().includes(q) || + (t.lines || []).some((l) => l.text.toLowerCase().includes(q)) + ); + }); + + expandedTranscript = computed(() => { + const id = this.expandedTranscriptId(); + if (!id) return null; + return this.transcriptItems().find((t) => t.id === id) || null; + }); + + expandedTranscriptLines = computed(() => { + const t = this.expandedTranscript(); + if (!t) return []; + const q = this.transcriptInnerSearch().trim().toLowerCase(); + const lines = t.lines || []; + if (!q) return lines.slice(0, 200); + return lines.filter((l) => l.text.toLowerCase().includes(q)).slice(0, 200); + }); + + toggleTranscriptDetail(item: TranscriptHistoryItem): void { + this.expandedTranscriptId.set(this.expandedTranscriptId() === item.id ? null : item.id); + } + + // ============ Suppressions ============ onDeleteSearch(item: SearchHistoryItem): void { if (!item?.id || this.deletingSearchId()) return; this.deletingSearchId.set(item.id); this.history.deleteSearchItem(item.id).subscribe({ - next: () => { - // Remove from both full and filtered lists - this.searchHistory.update(list => (list || []).filter(x => x.id !== item.id)); - this.filteredSearchHistory.update(list => (list || []).filter(x => x.id !== item.id)); - }, + next: () => this.searchItems.update(list => (list || []).filter(x => x.id !== item.id)), error: () => {}, complete: () => this.deletingSearchId.set(null) }); } - // --- Delete a single watch history item onDeleteWatch(item: WatchHistoryItem): void { if (!item?.id || this.deletingWatchId()) return; this.deletingWatchId.set(item.id); this.history.deleteWatchItem(item.id).subscribe({ - next: () => { - this.watchHistory.update(list => (list || []).filter(x => x.id !== item.id)); - this.filteredWatchHistory.update(list => (list || []).filter(x => x.id !== item.id)); - }, + next: () => this.watchItems.update(list => (list || []).filter(x => x.id !== item.id)), error: () => {}, complete: () => this.deletingWatchId.set(null) }); } - // Format a date to a readable string + onDeleteTranscript(item: TranscriptHistoryItem): void { + if (!item?.id || this.deletingTranscriptId()) return; + this.deletingTranscriptId.set(item.id); + this.history.deleteTranscriptItem(item.id).subscribe({ + next: () => { + this.transcriptItems.update(list => (list || []).filter(x => x.id !== item.id)); + if (this.expandedTranscriptId() === item.id) this.expandedTranscriptId.set(null); + }, + error: () => {}, + complete: () => this.deletingTranscriptId.set(null) + }); + } + + clearActiveTab(): void { + const tab = this.activeTab(); + if (this.clearingTab()) return; + if (!confirm('Tout effacer pour cet onglet ?')) return; + this.clearingTab.set(tab); + const done = () => this.clearingTab.set(null); + if (tab === 'search') { + this.history.clearSearchHistory().subscribe({ next: () => this.searchItems.set([]), error: () => {}, complete: done }); + } else if (tab === 'watch') { + this.history.clearWatchHistory().subscribe({ next: () => this.watchItems.set([]), error: () => {}, complete: done }); + } else { + const provider = this.transcriptProvider() || undefined; + this.history.clearTranscriptHistory(provider).subscribe({ next: () => this.reloadActive(true), error: () => {}, complete: done }); + } + } + + resetFilters(): void { + const tab = this.activeTab(); + if (tab === 'search') { + this.searchQuery.set(''); this.searchProvider.set(''); this.searchDate.set('all'); this.searchSort.set('recent'); + } else if (tab === 'watch') { + this.watchQuery.set(''); this.watchProvider.set(''); this.watchDate.set('all'); this.watchSort.set('recent'); + } else { + this.transcriptQuery.set(''); this.transcriptProvider.set(''); this.transcriptLang.set(''); + this.transcriptDate.set('all'); this.transcriptSort.set('recent'); this.transcriptInnerSearch.set(''); + } + this.reloadActive(true); + } + + hasActiveFilters = computed(() => { + const tab = this.activeTab(); + if (tab === 'search') return !!(this.searchQuery() || this.searchProvider() || this.searchDate() !== 'all' || this.searchSort() !== 'recent'); + if (tab === 'watch') return !!(this.watchQuery() || this.watchProvider() || this.watchDate() !== 'all' || this.watchSort() !== 'recent'); + return !!(this.transcriptQuery() || this.transcriptProvider() || this.transcriptLang() || this.transcriptDate() !== 'all' || this.transcriptSort() !== 'recent' || this.transcriptInnerSearch()); + }); + + // ============ Helpers affichage (conservés) ============ formatDate(dateString: string | Date): string { if (!dateString) return ''; const date = new Date(dateString); return date.toLocaleDateString('fr-FR', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } -getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } { - // Try to get providers from filters first - if (item.filters_json) { - try { - const filters = JSON.parse(item.filters_json); - if (filters.providers) { - // Multiple providers available - const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers]; - if (providers.length === 1) { - const providerName = this.formatProviderName(providers[0]); - return { name: providerName, id: providers[0].toLowerCase() }; - } else { - const firstProviderName = this.formatProviderName(providers[0]); - const othersCount = providers.length - 1; - const name = `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`; - return { name: name, id: providers[0], providers: providers }; - } - } else if (filters.provider) { - // Single provider - const providerName = this.formatProviderName(filters.provider); - return { name: providerName, id: String(filters.provider).toLowerCase() }; - } - } catch (e) { - console.error('Error parsing filters_json:', e); - } - } - - // If no provider in filters, try to extract from URL query params - try { - const url = new URL(item.query); - const provider = url.searchParams.get('provider'); - if (provider) { - const providerName = this.formatProviderName(provider); - return { name: providerName, id: provider.toLowerCase() }; - } - } catch (e) { - // Not a valid URL or no provider in query params - } - - // Fallback to 'all' if no provider can be determined - return { name: 'Tous', id: 'all' }; - } - - // Get the default provider from the current URL or settings - private getDefaultProvider(): string | null { - try { - // Try to get from current URL - const url = new URL(window.location.href); - const provider = url.searchParams.get('provider'); - if (provider) return provider.toLowerCase(); - - // Or get from localStorage if available - const savedProvider = localStorage.getItem('selectedProvider'); - if (savedProvider) return savedProvider.toLowerCase(); - - // Default to youtube if nothing else - return 'youtube'; - } catch (e) { - return 'youtube'; // Fallback - } - } - - getProvidersWithInfo(item: SearchHistoryItem) { - return []; - } - - // Get query params for search navigation - getSearchQueryParams(item: SearchHistoryItem): any { - const providerInfo = this.getSearchProvider(item); - if (providerInfo.providers && providerInfo.providers.length > 1) { - // Convert array to comma-separated string for URL - return { q: item.query, providers: providerInfo.providers.join(',') }; - } else { - return { q: item.query, provider: providerInfo.id }; - } - } - - // Format provider ID to a display name - private formatProviderName(providerId: string): string { - const providerMap: { [key: string]: string } = { - 'youtube': 'YouTube', - 'vimeo': 'Vimeo', - 'dailymotion': 'Dailymotion', - 'peertube': 'PeerTube', - 'rumble': 'Rumble' - }; - return providerMap[providerId.toLowerCase()] || providerId; - } - - // Expose a public-friendly name for provider IDs for templates - getProviderDisplayName(providerId: string | null | undefined): string { - if (!providerId) return ''; - return this.formatProviderName(providerId); - } - - - // Obtenir les styles pour un provider - getProviderColors(providerId: string | null | undefined): { [key: string]: string } { - if (!providerId) { - return { - 'background-color': 'rgb(51 65 85 / 0.15)', - 'color': 'rgb(148 163 184)', - 'border-color': 'rgb(71 85 105 / 0.3)' - }; - } - - const id = providerId.toLowerCase(); - let bgColor = 'rgb(51 65 85 / 0.15)'; - let textColor = 'rgb(148 163 184)'; - let borderColor = 'rgb(71 85 105 / 0.3)'; - - switch (id) { - case 'youtube': - case 'yt': - bgColor = 'rgb(220 38 38 / 0.15)'; - textColor = 'rgb(248 113 113)'; - borderColor = 'rgb(239 68 68 / 0.3)'; - break; - case 'dailymotion': - case 'dm': - bgColor = 'rgb(37 99 235 / 0.15)'; - textColor = 'rgb(96 165 250)'; - borderColor = 'rgb(59 130 246 / 0.3)'; - break; - case 'twitch': - case 'tw': - bgColor = 'rgb(147 51 234 / 0.15)'; - textColor = 'rgb(192 132 252)'; - borderColor = 'rgb(168 85 247 / 0.3)'; - break; - case 'peertube': - case 'pt': - bgColor = 'rgb(217 119 6 / 0.15)'; - textColor = 'rgb(251 191 36)'; - borderColor = 'rgb(245 158 11 / 0.3)'; - break; - case 'odysee': - case 'od': - bgColor = 'rgb(22 163 74 / 0.15)'; - textColor = 'rgb(74 222 128)'; - borderColor = 'rgb(34 197 94 / 0.3)'; - break; - case 'rumble': - case 'ru': - bgColor = 'rgb(234 88 12 / 0.15)'; - textColor = 'rgb(251 146 60)'; - borderColor = 'rgb(249 115 22 / 0.3)'; - break; - } - - return { - 'background-color': bgColor, - 'color': textColor, - 'border-color': borderColor - }; - } - - // Format seconds to HH:MM:SS or MM:SS formatDuration(seconds: number): string { if (!seconds && seconds !== 0) return '00:00'; - const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } else { - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } + if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + return `${minutes}:${secs.toString().padStart(2, '0')}`; } - // Calculate watch progress percentage getProgressPercentage(item: WatchHistoryItem): number { if (!item.duration_seconds || item.duration_seconds === 0) return 0; const progress = (item.progress_seconds || 0) / item.duration_seconds; return Math.min(100, Math.max(0, progress * 100)); } + + getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } { + if (item.filters_json) { + try { + const filters = JSON.parse(item.filters_json); + if (filters.providers) { + const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers]; + if (providers.length === 1) { + return { name: this.formatProviderName(providers[0]), id: providers[0].toLowerCase() }; + } + const firstProviderName = this.formatProviderName(providers[0]); + const othersCount = providers.length - 1; + return { name: `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`, id: providers[0], providers }; + } else if (filters.provider) { + return { name: this.formatProviderName(filters.provider), id: String(filters.provider).toLowerCase() }; + } + } catch {} + } + try { + const url = new URL(item.query); + const provider = url.searchParams.get('provider'); + if (provider) return { name: this.formatProviderName(provider), id: provider.toLowerCase() }; + } catch {} + return { name: 'Tous', id: 'all' }; + } + + getSearchQueryParams(item: SearchHistoryItem): any { + const providerInfo = this.getSearchProvider(item); + if (providerInfo.providers && providerInfo.providers.length > 1) { + return { q: item.query, providers: providerInfo.providers.join(',') }; + } + return { q: item.query, provider: providerInfo.id }; + } + + private formatProviderName(providerId: string): string { + const providerMap: { [key: string]: string } = { + youtube: 'YouTube', yt: 'YouTube', vimeo: 'Vimeo', + dailymotion: 'Dailymotion', dm: 'Dailymotion', peertube: 'PeerTube', pt: 'PeerTube', + rumble: 'Rumble', ru: 'Rumble', twitch: 'Twitch', tw: 'Twitch', + odysee: 'Odysee', od: 'Odysee', + }; + return providerMap[providerId.toLowerCase()] || providerId; + } + + getProviderDisplayName(providerId: string | null | undefined): string { + if (!providerId) return ''; + return this.formatProviderName(providerId); + } + + getProviderColors(providerId: string | null | undefined): { [key: string]: string } { + if (!providerId) { + return { 'background-color': 'rgb(51 65 85 / 0.15)', 'color': 'rgb(148 163 184)', 'border-color': 'rgb(71 85 105 / 0.3)' }; + } + const id = providerId.toLowerCase(); + let bgColor = 'rgb(51 65 85 / 0.15)'; + let textColor = 'rgb(148 163 184)'; + let borderColor = 'rgb(71 85 105 / 0.3)'; + switch (id) { + case 'youtube': case 'yt': + bgColor = 'rgb(220 38 38 / 0.15)'; textColor = 'rgb(248 113 113)'; borderColor = 'rgb(239 68 68 / 0.3)'; break; + case 'dailymotion': case 'dm': + bgColor = 'rgb(37 99 235 / 0.15)'; textColor = 'rgb(96 165 250)'; borderColor = 'rgb(59 130 246 / 0.3)'; break; + case 'twitch': case 'tw': + bgColor = 'rgb(147 51 234 / 0.15)'; textColor = 'rgb(192 132 252)'; borderColor = 'rgb(168 85 247 / 0.3)'; break; + case 'peertube': case 'pt': + bgColor = 'rgb(217 119 6 / 0.15)'; textColor = 'rgb(251 191 36)'; borderColor = 'rgb(245 158 11 / 0.3)'; break; + case 'odysee': case 'od': + bgColor = 'rgb(22 163 74 / 0.15)'; textColor = 'rgb(74 222 128)'; borderColor = 'rgb(34 197 94 / 0.3)'; break; + case 'rumble': case 'ru': + bgColor = 'rgb(234 88 12 / 0.15)'; textColor = 'rgb(251 146 60)'; borderColor = 'rgb(249 115 22 / 0.3)'; break; + } + return { 'background-color': bgColor, 'color': textColor, 'border-color': borderColor }; + } } diff --git a/src/components/watch/watch.component.ts b/src/components/watch/watch.component.ts index 805e528..0fe0ab3 100644 --- a/src/components/watch/watch.component.ts +++ b/src/components/watch/watch.component.ts @@ -1401,6 +1401,51 @@ export class WatchComponent implements OnDestroy, AfterViewInit { return true; } + /** Conserve le transcript généré dans l'historique (rejoué sans régénération). */ + private persistTranscriptToHistory(lang: string, languages: string[], lines: Array<{ t: number; dur: number; text: string }>): void { + try { + if (!this.auth.currentUser()) return; + if (!Array.isArray(lines) || !lines.length) return; + const v = this.video(); + this.history.saveTranscript({ + provider: this.provider(), + videoId: this.videoId(), + title: v?.title || undefined, + thumbnail: v?.thumbnail || undefined, + lang, languages, lines, + }).subscribe(); + } catch {} + } + + private applyTranscriptSuccess(data: any): void { + this.transcriptLoading.set(false); + this.transcriptAvailable.set(!!data?.available); + this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages)); + this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []); + if (data?.available && typeof data?.lang === 'string' && data.lang) { + this.selectedTranscriptLang.set(data.lang); + } + if (!data?.available) { + if (this.autoFallbackTranscriptLang(data?.languages)) return; + const st = this.transcriptStatusFor(data); + this.transcriptReason.set(st.reason); + this.transcriptRetryable.set(st.retryable); + this.transcriptError.set(st.message); + } else { + this.transcriptReason.set(null); + this.transcriptRetryable.set(false); + this.transcriptError.set(null); + this.transcriptActiveIndex.set(-1); + this.startTranscriptClock(); + // Conserve pour l'onglet Historique > Transcripts. + this.persistTranscriptToHistory( + String((data?.lang || this.selectedTranscriptLang()) || 'fr'), + Array.isArray(data?.languages) ? data.languages : [], + Array.isArray(data?.lines) ? data.lines : [], + ); + } + } + loadTranscript() { const p = this.provider(); const id = this.videoId(); @@ -1431,67 +1476,61 @@ export class WatchComponent implements OnDestroy, AfterViewInit { this.transcriptReason.set(null); this.transcriptRetryable.set(false); this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase()); - const opts = this.buildProviderOpts() as Record; - const params = new URLSearchParams({ lang: requestedLang, langs: allowed.join(',') }); - if (opts['instance']) params.set('instance', opts['instance']); - if (opts['slug']) params.set('slug', opts['slug']); - const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`; - this.http.get(url).subscribe({ - next: (data) => { - this.transcriptLoading.set(false); - this.transcriptAvailable.set(!!data?.available); - this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages)); - this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []); - if (data?.available && typeof data?.lang === 'string' && data.lang) { - this.selectedTranscriptLang.set(data.lang); - } - if (!data?.available) { - // La langue demandée n'a rien donné mais d'autres pistes existent : - // on bascule automatiquement dessus au lieu d'afficher une erreur. - if (this.autoFallbackTranscriptLang(data?.languages)) return; - const st = this.transcriptStatusFor(data); + // 1) Historique d'abord : rejoue le transcript conservé sans régénération. + const fetchFromNetwork = () => { + const opts = this.buildProviderOpts() as Record; + const params = new URLSearchParams({ lang: requestedLang, langs: allowed.join(',') }); + if (opts['instance']) params.set('instance', opts['instance']); + if (opts['slug']) params.set('slug', opts['slug']); + const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`; + this.http.get(url).subscribe({ + next: (data) => this.applyTranscriptSuccess(data), + error: (err) => { + this.transcriptLoading.set(false); + this.transcriptAvailable.set(false); + this.transcriptLines.set([]); + const body = err?.error; + if (body && typeof body === 'object' && Array.isArray(body.languages)) { + this.transcriptLanguages.set(this.filterTranscriptLanguages(body.languages)); + // Erreur réseau/502 mais d'autres pistes connues : on essaie la + // suivante automatiquement (une fois chacune, garde anti-boucle). + if (this.autoFallbackTranscriptLang(body.languages)) return; + } + const raw = + (body && typeof body === 'object' && (body.error || body.details || body.reason)) || + (typeof body === 'string' ? body : '') || + err?.message || + ''; + const status = err?.status; + if (status === 429 || String(raw || '').trim() === 'rate_limited') { + this.transcriptReason.set('temporarily_unavailable'); + this.transcriptRetryable.set(true); + this.transcriptError.set('Trop de requêtes. Réessayez dans une minute.'); + return; + } + const st = this.transcriptStatusFor( + body && typeof body === 'object' ? body : { error: raw }, + ); this.transcriptReason.set(st.reason); this.transcriptRetryable.set(st.retryable); this.transcriptError.set(st.message); - } else { - this.transcriptReason.set(null); - this.transcriptRetryable.set(false); - this.transcriptError.set(null); - this.transcriptActiveIndex.set(-1); - this.startTranscriptClock(); - } - }, - error: (err) => { - this.transcriptLoading.set(false); - this.transcriptAvailable.set(false); - this.transcriptLines.set([]); - const body = err?.error; - if (body && typeof body === 'object' && Array.isArray(body.languages)) { - this.transcriptLanguages.set(this.filterTranscriptLanguages(body.languages)); - // Erreur réseau/502 mais d'autres pistes connues : on essaie la - // suivante automatiquement (une fois chacune, garde anti-boucle). - if (this.autoFallbackTranscriptLang(body.languages)) return; - } - const raw = - (body && typeof body === 'object' && (body.error || body.details || body.reason)) || - (typeof body === 'string' ? body : '') || - err?.message || - ''; - const status = err?.status; - if (status === 429 || String(raw || '').trim() === 'rate_limited') { - this.transcriptReason.set('temporarily_unavailable'); - this.transcriptRetryable.set(true); - this.transcriptError.set('Trop de requêtes. Réessayez dans une minute.'); - return; - } - const st = this.transcriptStatusFor( - body && typeof body === 'object' ? body : { error: raw }, - ); - this.transcriptReason.set(st.reason); - this.transcriptRetryable.set(st.retryable); - this.transcriptError.set(st.message); - }, - }); + }, + }); + }; + try { + if (!this.auth.currentUser()) { fetchFromNetwork(); return; } + this.history.getCachedTranscript(p, id, requestedLang).subscribe({ + next: (cached) => { + const lines = (cached as any)?.lines; + if (cached && Array.isArray(lines) && lines.length) { + this.applyTranscriptSuccess({ available: true, lang: (cached as any).lang, languages: (cached as any).languages, lines }); + return; + } + fetchFromNetwork(); + }, + error: () => fetchFromNetwork(), + }); + } catch { fetchFromNetwork(); } } catch { this.transcriptLoading.set(false); this.transcriptAvailable.set(false); diff --git a/src/services/history.service.ts b/src/services/history.service.ts index 3e6780d..c07f77e 100644 --- a/src/services/history.service.ts +++ b/src/services/history.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of, tap } from 'rxjs'; +import { Observable, of, tap, catchError } from 'rxjs'; import { AuthService } from './auth.service'; export interface SearchHistoryItem { @@ -24,11 +24,42 @@ export interface WatchHistoryItem { thumbnail?: string | null; } +export interface TranscriptLine { + t: number; + dur: number; + text: string; +} + +export interface TranscriptHistoryItem { + id: string; + provider: string; + video_id: string; + title?: string | null; + thumbnail?: string | null; + lang: string; + languages: string[]; + lines: TranscriptLine[]; + line_count: number; + char_count: number; + preview?: string; + created_at: string; + updated_at: string; + fromHistory?: boolean; +} + +export interface HistoryFilters { + q?: string; + provider?: string; + lang?: string; + limit?: number; + before?: string; +} + @Injectable({ providedIn: 'root' }) export class HistoryService { private http = inject(HttpClient); private auth = inject(AuthService); - + private apiBase(): string { try { const port = window?.location?.port || ''; @@ -38,6 +69,16 @@ export class HistoryService { } } + private buildParams(f: HistoryFilters): string { + const params = new URLSearchParams(); + if (f.limit) params.set('limit', String(f.limit)); + if (f.before) params.set('before', f.before); + if (f.q) params.set('q', f.q); + if (f.provider) params.set('provider', f.provider); + if (f.lang) params.set('lang', f.lang); + return params.toString(); + } + // --- Search --- recordSearch(query: string, filters?: Record): Observable<{ id: string; created_at: string }> { const url = `${this.apiBase()}/user/history/search`; @@ -53,11 +94,9 @@ export class HistoryService { ); } - getSearchHistory(limit = 50, before?: string): Observable { - const params = new URLSearchParams(); - params.set('limit', String(limit)); - if (before) params.set('before', before); - return this.http.get(`${this.apiBase()}/user/history/search?${params.toString()}`, { withCredentials: true }); + getSearchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable { + const qs = this.buildParams({ limit, before, q, provider }); + return this.http.get(`${this.apiBase()}/user/history/search?${qs}`, { withCredentials: true }); } // --- Watch --- @@ -92,11 +131,9 @@ export class HistoryService { ); } - getWatchHistory(limit = 50, before?: string): Observable { - const params = new URLSearchParams(); - params.set('limit', String(limit)); - if (before) params.set('before', before); - return this.http.get(`${this.apiBase()}/user/history/watch?${params.toString()}`, { withCredentials: true }); + getWatchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable { + const qs = this.buildParams({ limit, before, q, provider }); + return this.http.get(`${this.apiBase()}/user/history/watch?${qs}`, { withCredentials: true }); } // --- Delete single items --- @@ -108,25 +145,65 @@ export class HistoryService { return this.http.delete(`${this.apiBase()}/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true }); } + clearSearchHistory(): Observable { + return this.http.delete(`${this.apiBase()}/user/history/search?all=1`, { withCredentials: true }); + } + + clearWatchHistory(): Observable { + return this.http.delete(`${this.apiBase()}/user/history/watch?all=1`, { withCredentials: true }); + } + // Recherche dans l'historique des recherches - searchInSearchHistory(query: string, limit = 100): Observable { - const params = new URLSearchParams(); - params.set('q', query); - params.set('limit', String(limit)); - return this.http.get( - `${this.apiBase()}/user/history/search?${params.toString()}`, - { withCredentials: true } - ); + searchInSearchHistory(query: string, limit = 100, provider?: string): Observable { + return this.getSearchHistory(limit, undefined, query, provider); } // Recherche dans l'historique de visionnage - searchInWatchHistory(query: string, limit = 100): Observable { - const params = new URLSearchParams(); - params.set('q', query); - params.set('limit', String(limit)); - return this.http.get( - `${this.apiBase()}/user/history/watch?${params.toString()}`, - { withCredentials: true } + searchInWatchHistory(query: string, limit = 100, provider?: string): Observable { + return this.getWatchHistory(limit, undefined, query, provider); + } + + // --- Transcripts (conservés, rejoués sans régénération) --- + /** Lecture prioritaire depuis l'historique : évite de régénérer à chaque demande. */ + getCachedTranscript(provider: string, videoId: string, lang?: string): Observable { + if (!this.auth.currentUser()) return of(null); + let url = `${this.apiBase()}/user/history/transcripts/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}`; + if (lang) url += `?lang=${encodeURIComponent(lang)}`; + return this.http.get(url, { withCredentials: true }).pipe( + catchError(() => of(null)) ); } + + listTranscripts(filters: HistoryFilters = {}): Observable { + const qs = this.buildParams({ limit: filters.limit ?? 50, ...filters }); + return this.http.get(`${this.apiBase()}/user/history/transcripts?${qs}`, { withCredentials: true }); + } + + searchInTranscriptHistory(query: string, limit = 100, provider?: string, lang?: string): Observable { + return this.listTranscripts({ q: query, limit, provider, lang }); + } + + saveTranscript(entry: { + provider: string; videoId: string; title?: string | null; thumbnail?: string | null; + lang: string; languages?: string[]; lines: TranscriptLine[]; + }): Observable { + if (!this.auth.currentUser()) return of(null); + if (!entry?.provider || !entry?.videoId || !entry?.lang || !Array.isArray(entry.lines) || !entry.lines.length) { + return of(null); + } + return this.http.post( + `${this.apiBase()}/user/history/transcripts`, + { ...entry }, + { withCredentials: true } + ).pipe(catchError(() => of(null))); + } + + deleteTranscriptItem(id: string): Observable { + return this.http.delete(`${this.apiBase()}/user/history/transcripts/${encodeURIComponent(id)}`, { withCredentials: true }); + } + + clearTranscriptHistory(provider?: string): Observable { + const qs = provider ? `?all=1&provider=${encodeURIComponent(provider)}` : '?all=1'; + return this.http.delete(`${this.apiBase()}/user/history/transcripts${qs}`, { withCredentials: true }); + } }