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
-
-
-
-
-
+
+
+
+ Recherches
+ {{ filteredSearch().length }}
+
+
+ Visionnage
+ {{ filteredWatch().length }}
+
+
+ Transcripts
+ {{ filteredTranscripts().length }}
+
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+ Les transcripts sont conservés à la génération et rejoués depuis l'historique — aucune régénération inutile.
+
+
+
+
+
+
+
+ Aucune recherche {{ searchQuery() ? 'pour « ' + searchQuery() + ' »' : 'récente' }}
+
+
+ 0">
+
+ {{ searchLoading() ? 'Chargement…' : 'Charger plus' }}
+
+
+
+
+
+
+
+
+ Aucune vidéo {{ watchQuery() ? 'pour « ' + watchQuery() + ' »' : 'récemment regardée' }}
+
+
+ 0">
+
+ {{ watchLoading() ? 'Chargement…' : 'Charger plus' }}
+
+
+
+
+
+
+
+
+ Aucun transcript conservé {{ transcriptQuery() ? 'pour « ' + transcriptQuery() + ' »' : 'pour le moment' }}.
+ Ouvrez un transcript depuis une page Watch : il sera conservé ici.
+
+ 0" class="space-y-3">
+ -
+
+
+
+ @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
+
+ {{ expandedTranscriptId() === t.id ? 'Masquer le texte' : 'Voir le texte conservé' }}
+
+
+ Supprimer
+
+
+
+
+ [{{ formatDuration(l.t) }}]
+ {{ l.text }}
+
+
+
+
+
+ 0">
+
+ {{ transcriptLoading() ? 'Chargement…' : 'Charger plus' }}
+
+
+
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 });
+ }
}