feat(history): refonte page historique en tabs + transcripts conserves et rejoues sans regeneration

This commit is contained in:
2026-09-26 12:59:01 -04:00
parent 9b4fffd3c8
commit 55c02ac52f
8 changed files with 1113 additions and 735 deletions
@@ -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);
+23
View File
@@ -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,
+172 -65
View File
@@ -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);
+75 -2
View File
@@ -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
@@ -1,316 +1,288 @@
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
<h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Historique
</h2>
<div class="relative w-full md:w-96">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<input
type="text"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
class="block w-full pl-10 pr-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-150"
placeholder="Rechercher dans l'historique..."
>
<button
*ngIf="searchQuery()"
(click)="searchQuery.set('')"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-300 transition-colors"
aria-label="Effacer la recherche"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Search History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Historique des recherches
</h3>
<div class="flex items-center gap-2">
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ searchHistory().length }} recherche(s)
<span *ngIf="searchQuery()">
• {{ filteredSearchHistory().length }} résultat(s)
</span>
</span>
<button (click)="resetFilters()" [disabled]="!hasActiveFilters()"
class="px-3 py-2 text-xs rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-colors">
Réinitialiser les filtres
</button>
<button (click)="clearActiveTab()" [disabled]="!!clearingTab()"
class="px-3 py-2 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60 transition-colors">
<span *ngIf="!clearingTab()">Tout effacer (onglet)</span>
<span *ngIf="clearingTab()">Suppression…</span>
</button>
</div>
</div>
<ng-container *ngIf="searchQuery(); else normalSearchHistory">
<!-- Mode recherche -->
<div *ngIf="isLoading()" class="text-center py-6">
<!-- Tabs -->
<div class="flex gap-2 mb-6 border-b border-slate-700" role="tablist">
<button role="tab" (click)="setTab('search')"
[class]="activeTab() === 'search'
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-blue-500 -mb-px'
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
Recherches
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredSearch().length }}</span>
</button>
<button role="tab" (click)="setTab('watch')"
[class]="activeTab() === 'watch'
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-red-500 -mb-px'
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
Visionnage
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredWatch().length }}</span>
</button>
<button role="tab" (click)="setTab('transcripts')"
[class]="activeTab() === 'transcripts'
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-emerald-500 -mb-px'
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
Transcripts
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredTranscripts().length }}</span>
</button>
</div>
<!-- ===== Barre de recherche + filtres : RECHERCHES ===== -->
<div *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
<div class="md:col-span-5 relative">
<input type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event); onFilterChange()"
placeholder="Rechercher dans les recherches…"
class="block w-full pl-3 pr-8 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button *ngIf="searchQuery()" (click)="searchQuery.set(''); onFilterChange()"
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
</div>
<select [ngModel]="searchProvider()" (ngModelChange)="searchProvider.set($event); onFilterChange()"
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
</select>
<select [ngModel]="searchDate()" (ngModelChange)="searchDate.set($event)"
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="all">Toutes dates</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 derniers jours</option>
<option value="month">30 derniers jours</option>
</select>
<select [ngModel]="searchSort()" (ngModelChange)="searchSort.set($event)"
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="recent">Plus récents</option>
<option value="oldest">Plus anciens</option>
<option value="alpha">Alphabétique</option>
</select>
</div>
</div>
<!-- ===== Barre de recherche + filtres : VISIONNAGE ===== -->
<div *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
<div class="md:col-span-5 relative">
<input type="text" [ngModel]="watchQuery()" (ngModelChange)="watchQuery.set($event); onFilterChange()"
placeholder="Rechercher une vidéo (titre, provider, id)…"
class="block w-full pl-3 pr-8 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-red-500" />
<button *ngIf="watchQuery()" (click)="watchQuery.set(''); onFilterChange()"
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
</div>
<select [ngModel]="watchProvider()" (ngModelChange)="watchProvider.set($event); onFilterChange()"
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
</select>
<select [ngModel]="watchDate()" (ngModelChange)="watchDate.set($event)"
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="all">Toutes dates</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 derniers jours</option>
<option value="month">30 derniers jours</option>
</select>
<select [ngModel]="watchSort()" (ngModelChange)="watchSort.set($event)"
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="recent">Plus récents</option>
<option value="oldest">Plus anciens</option>
<option value="alpha">Alphabétique</option>
</select>
</div>
</div>
<!-- ===== Barre de recherche + filtres : TRANSCRIPTS ===== -->
<div *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
<div class="md:col-span-4 relative">
<input type="text" [ngModel]="transcriptQuery()" (ngModelChange)="transcriptQuery.set($event); onFilterChange()"
placeholder="Rechercher (titre, vidéo, langue, contenu)…"
class="block w-full pl-3 pr-8 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500" />
<button *ngIf="transcriptQuery()" (click)="transcriptQuery.set(''); onFilterChange()"
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
</div>
<input type="text" [ngModel]="transcriptInnerSearch()" (ngModelChange)="transcriptInnerSearch.set($event)"
placeholder="Filtrer dans le texte conservé…"
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400" />
<select [ngModel]="transcriptProvider()" (ngModelChange)="transcriptProvider.set($event); onFilterChange()"
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
</select>
<input type="text" [ngModel]="transcriptLang()" (ngModelChange)="transcriptLang.set($event); onFilterChange()"
placeholder="Langue (fr, en…)"
class="md:col-span-1 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400" />
<select [ngModel]="transcriptDate()" (ngModelChange)="transcriptDate.set($event)"
class="md:col-span-1 px-2 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="all">Toutes</option>
<option value="today">Jour</option>
<option value="week">7j</option>
<option value="month">30j</option>
</select>
<select [ngModel]="transcriptSort()" (ngModelChange)="transcriptSort.set($event)"
class="md:col-span-1 px-2 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
<option value="recent">Récents</option>
<option value="oldest">Anciens</option>
<option value="alpha">A→Z</option>
</select>
</div>
<p class="mt-2 text-xs text-slate-400">
Les transcripts sont conservés à la génération et rejoués depuis l'historique — aucune régénération inutile.
</p>
</div>
<!-- ===== Contenu : RECHERCHES ===== -->
<section *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div *ngIf="searchLoading() && filteredSearch().length === 0" class="text-center py-6">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
<p class="mt-2 text-slate-400">Recherche en cours...</p>
<p class="mt-2 text-slate-400">Chargement…</p>
</div>
<div *ngIf="!isLoading() && filteredSearchHistory().length === 0" class="text-slate-400 text-center py-6">
Aucun résultat trouvé dans l'historique des recherches pour "{{ searchQuery() }}"
<div *ngIf="!searchLoading() && filteredSearch().length === 0" class="text-slate-400 text-center py-4">
Aucune recherche {{ searchQuery() ? 'pour « ' + searchQuery() + ' »' : 'récente' }}
</div>
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of filteredSearchHistory()"
<ul *ngIf="filteredSearch().length > 0" class="space-y-3">
<li *ngFor="let s of filteredSearch()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
<div class="flex-1">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
{{ s.query }}
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)" class="block p-4 pr-24 hover:no-underline">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">{{ s.query }}</div>
<div class="flex items-center mt-1 text-xs text-slate-400 gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
{{ getSearchProvider(s).name }}
</span>
<span class="text-slate-500">
{{ formatDate(s.created_at) }}
</span>
</div>
</div>
<span>{{ formatDate(s.created_at) }}</span>
</div>
</a>
<button (click)="onDeleteSearch(s); $event.preventDefault(); $event.stopPropagation();"
[disabled]="deletingSearchId() === s.id"
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
</svg>
<span class="hidden sm:inline">Supprimer</span>
<button (click)="onDeleteSearch(s)" [disabled]="deletingSearchId() === s.id"
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
Supprimer
</button>
</li>
</ul>
</ng-container>
<ng-template #normalSearchHistory>
<!-- Mode affichage normal -->
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune recherche récente
</div>
<ul *ngIf="searchHistory().length > 0" class="space-y-3">
<li *ngFor="let s of searchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
class="block p-4 pr-16 hover:no-underline">
<div class="flex items-start gap-2">
<span class="text-slate-400 mt-0.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
<div class="flex-1">
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
{{ s.query }}
</div>
<div class="flex items-center mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(getSearchProvider(s).id)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ getSearchProvider(s).name }}
</span>
<span class="text-slate-500">
{{ formatDate(s.created_at) }}
</span>
</div>
</div>
</div>
</a>
<button (click)="onDeleteSearch(s); $event.preventDefault(); $event.stopPropagation();"
[disabled]="deletingSearchId() === s.id"
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
</svg>
<span class="hidden sm:inline">Supprimer</span>
<div class="mt-4 text-center" *ngIf="searchHasMore() && filteredSearch().length > 0">
<button (click)="loadMore()" [disabled]="searchLoading()"
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
{{ searchLoading() ? 'Chargement…' : 'Charger plus' }}
</button>
</li>
</ul>
</ng-template>
</div>
</section>
<!-- Watch History Section -->
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Historique de visionnage
</h3>
<div class="flex items-center gap-2">
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
{{ watchHistory().length }} vidéo(s)
<span *ngIf="searchQuery()">
• {{ filteredWatchHistory().length }} résultat(s)
</span>
</span>
<!-- ===== Contenu : VISIONNAGE ===== -->
<section *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div *ngIf="watchLoading() && filteredWatch().length === 0" class="text-center py-6">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-red-500 border-t-transparent"></div>
<p class="mt-2 text-slate-400">Chargement…</p>
</div>
<div *ngIf="!watchLoading() && filteredWatch().length === 0" class="text-slate-400 text-center py-4">
Aucune vidéo {{ watchQuery() ? 'pour « ' + watchQuery() + ' »' : 'récemment regardée' }}
</div>
<ng-container *ngIf="searchQuery(); else normalWatchHistory">
<!-- Mode recherche -->
<div *ngIf="isLoading()" class="text-center py-6">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
<p class="mt-2 text-slate-400">Recherche en cours...</p>
</div>
<div *ngIf="!isLoading() && filteredWatchHistory().length === 0" class="text-slate-400 text-center py-6">
Aucun résultat trouvé dans l'historique de visionnage pour "{{ searchQuery() }}"
</div>
<ul *ngIf="!isLoading() && filteredWatchHistory().length > 0" class="space-y-3">
<li *ngFor="let w of filteredWatchHistory()"
<ul *ngIf="filteredWatch().length > 0" class="space-y-3">
<li *ngFor="let w of filteredWatch()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
class="block p-4 pr-16 hover:no-underline">
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }" class="block p-4 pr-24 hover:no-underline">
<div class="flex">
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
@if (w.thumbnail) {
<img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" />
} @else {
<div class="text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
}
@if (w.thumbnail) { <img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" /> }
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
</div>
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
{{ formatDuration(w.duration_seconds) }}
</div>
</div>
<div class="ml-4 flex-1 min-w-0">
<h4 class="flex items-center gap-2 text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{{ w.title || 'Sans titre' }}</span>
</h4>
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 truncate">{{ w.title || 'Sans titre' }}</h4>
<div class="mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(w.provider)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border" [ngStyle]="getProviderColors(w.provider)">
{{ getProviderDisplayName(w.provider) }}
</span>
<span>Visionné le {{ formatDate(w.watched_at) }}</span>
</div>
<p class="text-xs text-slate-400 mt-1">
Visionné le {{ formatDate(w.watched_at) }}
</p>
<div class="mt-1 text-xs text-slate-500">
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
Progression : {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}
</div>
</div>
</div>
</a>
<button (click)="onDeleteWatch(w); $event.preventDefault(); $event.stopPropagation();"
[disabled]="deletingWatchId() === w.id"
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
</svg>
<span class="hidden sm:inline">Supprimer</span>
<button (click)="onDeleteWatch(w)" [disabled]="deletingWatchId() === w.id"
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
Supprimer
</button>
</li>
</ul>
</ng-container>
<ng-template #normalWatchHistory>
<!-- Mode affichage normal -->
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
Aucune vidéo récemment regardée
</div>
<ul *ngIf="watchHistory().length > 0" class="space-y-3">
<li *ngFor="let w of watchHistory()"
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
class="block p-4 pr-16 hover:no-underline">
<div class="flex">
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
@if (w.thumbnail) {
<img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" />
} @else {
<div class="text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
</div>
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
{{ formatDuration(w.duration_seconds) }}
</div>
</div>
<div class="ml-4 flex-1 min-w-0">
<h4 class="flex items-center gap-2 text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{{ w.title || 'Sans titre' }}</span>
</h4>
<div class="mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
[ngStyle]="getProviderColors(w.provider)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
{{ getProviderDisplayName(w.provider) }}
</span>
</div>
<p class="text-xs text-slate-400 mt-1">
Visionné le {{ formatDate(w.watched_at) }}
</p>
<div class="mt-1 text-xs text-slate-500">
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
</div>
</div>
</div>
</a>
<button (click)="onDeleteWatch(w); $event.preventDefault(); $event.stopPropagation();"
[disabled]="deletingWatchId() === w.id"
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
</svg>
<span class="hidden sm:inline">Supprimer</span>
<div class="mt-4 text-center" *ngIf="watchHasMore() && filteredWatch().length > 0">
<button (click)="loadMore()" [disabled]="watchLoading()"
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
{{ watchLoading() ? 'Chargement…' : 'Charger plus' }}
</button>
</div>
</section>
<!-- ===== Contenu : TRANSCRIPTS ===== -->
<section *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
<div *ngIf="transcriptLoading() && filteredTranscripts().length === 0" class="text-center py-6">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-emerald-500 border-t-transparent"></div>
<p class="mt-2 text-slate-400">Chargement…</p>
</div>
<div *ngIf="!transcriptLoading() && filteredTranscripts().length === 0" class="text-slate-400 text-center py-4">
Aucun transcript conservé {{ transcriptQuery() ? 'pour « ' + transcriptQuery() + ' »' : 'pour le moment' }}.
Ouvrez un transcript depuis une page Watch : il sera conservé ici.
</div>
<ul *ngIf="filteredTranscripts().length > 0" class="space-y-3">
<li *ngFor="let t of filteredTranscripts()" class="bg-slate-700/50 rounded-lg overflow-hidden">
<div class="p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-20 h-12 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
@if (t.thumbnail) { <img [src]="t.thumbnail" alt="" class="w-full h-full object-cover" /> }
@else { <span class="text-slate-500 text-xs">TXT</span> }
</div>
<div class="flex-1 min-w-0">
<div class="text-slate-100 font-medium truncate">{{ t.title || t.video_id }}</div>
<div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-slate-400">
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(t.provider)">
{{ getProviderDisplayName(t.provider) }}
</span>
<span class="px-2 py-0.5 rounded bg-emerald-600/20 text-emerald-300 border border-emerald-500/30">{{ t.lang }}</span>
<span>{{ t.line_count }} passages</span>
<span>{{ formatDate(t.updated_at) }}</span>
</div>
<p class="mt-1 text-xs text-slate-400 line-clamp-2">{{ t.preview || '—' }}</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a [routerLink]="['/watch', t.video_id]" [queryParams]="{ p: t.provider }"
class="px-3 py-1.5 text-xs rounded-lg bg-blue-600/90 hover:bg-blue-500 text-white">Revoir</a>
<button (click)="toggleTranscriptDetail(t)"
class="px-3 py-1.5 text-xs rounded-lg border border-slate-500 text-slate-200 hover:bg-slate-600">
{{ expandedTranscriptId() === t.id ? 'Masquer le texte' : 'Voir le texte conservé' }}
</button>
<button (click)="onDeleteTranscript(t)" [disabled]="deletingTranscriptId() === t.id"
class="px-3 py-1.5 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60">
Supprimer
</button>
</div>
<div *ngIf="expandedTranscriptId() === t.id" class="mt-3 max-h-64 overflow-y-auto rounded bg-slate-900/60 p-3 space-y-1">
<div *ngFor="let l of expandedTranscriptLines()" class="text-xs text-slate-300 flex gap-2">
<span class="text-slate-500 font-mono shrink-0">[{{ formatDuration(l.t) }}]</span>
<span>{{ l.text }}</span>
</div>
</div>
</div>
</li>
</ul>
</ng-template>
<div class="mt-4 text-center" *ngIf="transcriptHasMore() && filteredTranscripts().length > 0">
<button (click)="loadMore()" [disabled]="transcriptLoading()"
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
{{ transcriptLoading() ? 'Chargement…' : 'Charger plus' }}
</button>
</div>
</section>
</div>
</div>
@@ -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<HistoryTab>('search');
providerOptions = PROVIDER_OPTIONS;
// --- Filtres par onglet ---
searchQuery = signal('');
searchProvider = signal('');
searchDate = signal<DateFilter>('all');
searchSort = signal<SortOrder>('recent');
watchQuery = signal('');
watchProvider = signal('');
watchDate = signal<DateFilter>('all');
watchSort = signal<SortOrder>('recent');
transcriptQuery = signal('');
transcriptProvider = signal('');
transcriptLang = signal('');
transcriptDate = signal<DateFilter>('all');
transcriptSort = signal<SortOrder>('recent');
transcriptInnerSearch = signal('');
expandedTranscriptId = signal<string | null>(null);
// --- Données brutes (serveur déjà filtré par q/provider/lang) ---
searchItems = signal<SearchHistoryItem[]>([]);
watchItems = signal<WatchHistoryItem[]>([]);
transcriptItems = signal<TranscriptHistoryItem[]>([]);
searchLoading = signal(false);
watchLoading = signal(false);
transcriptLoading = signal(false);
searchHasMore = signal(true);
watchHasMore = signal(true);
transcriptHasMore = signal(true);
loading = signal<boolean>(false);
searchHistory = signal<SearchHistoryItem[]>([]);
watchHistory = signal<WatchHistoryItem[]>([]);
searchQuery = signal<string>('');
isLoading = signal<boolean>(false);
deletingSearchId = signal<string | null>(null);
deletingWatchId = signal<string | null>(null);
deletingTranscriptId = signal<string | null>(null);
clearingTab = signal<HistoryTab | null>(null);
// Historiques complets
filteredSearchHistory = signal<SearchHistoryItem[]>([]);
filteredWatchHistory = signal<WatchHistoryItem[]>([]);
// Sujet pour la recherche avec debounce
private searchTerms = new Subject<string>();
// Obtenir le nom d'affichage d'un provider
getProviderName(providerId: string): string {
const names: Record<string, string> = {
'youtube': 'YouTube',
'dailymotion': 'Dailymotion',
'twitch': 'Twitch',
'peertube': 'PeerTube',
'odysee': 'Odysee',
'rumble': 'Rumble'
};
return names[providerId.toLowerCase()] || providerId;
}
private searchTerms = new Subject<void>();
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: [] });
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();
}
this.isLoading.set(true);
return this.history.searchInSearchHistory(term).pipe(
switchMap(searchResults =>
this.history.searchInWatchHistory(term).pipe(
switchMap(watchResults =>
of({ search: searchResults, watch: watchResults })
)
)
)
// ============ 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.searchLoading.set(false),
});
}
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) => {
const list = items || [];
this.watchItems.update(prev => reset ? list : [...(prev || []), ...list]);
this.watchHasMore.set(list.length >= 50);
this.watchLoading.set(false);
},
error: () => this.watchLoading.set(false),
});
}
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<T>(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<T>(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))
);
})
).subscribe({
next: ({ search, watch }) => {
this.filteredSearchHistory.set(search);
this.filteredWatchHistory.set(watch);
this.isLoading.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);
}
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);
}
reload() {
this.isLoading.set(true);
this.history.getSearchHistory(50).subscribe({
next: (items) => {
this.searchHistory.set(items || []);
if (!this.searchQuery()) {
this.filteredSearchHistory.set(items || []);
}
},
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);
}
});
}
// --- Delete a single search history item
// ============ 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 {
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 };
}
}
+63 -24
View File
@@ -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,36 +1476,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
// 1) Historique d'abord : rejoue le transcript conservé sans régénération.
const fetchFromNetwork = () => {
const opts = this.buildProviderOpts() as Record<string, string>;
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<any>(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);
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();
}
},
next: (data) => this.applyTranscriptSuccess(data),
error: (err) => {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(false);
@@ -1492,6 +1516,21 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
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);
+103 -26
View File
@@ -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,6 +24,37 @@ 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);
@@ -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<string, any>): 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<SearchHistoryItem[]> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${params.toString()}`, { withCredentials: true });
getSearchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable<SearchHistoryItem[]> {
const qs = this.buildParams({ limit, before, q, provider });
return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${qs}`, { withCredentials: true });
}
// --- Watch ---
@@ -92,11 +131,9 @@ export class HistoryService {
);
}
getWatchHistory(limit = 50, before?: string): Observable<WatchHistoryItem[]> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (before) params.set('before', before);
return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${params.toString()}`, { withCredentials: true });
getWatchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable<WatchHistoryItem[]> {
const qs = this.buildParams({ limit, before, q, provider });
return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${qs}`, { withCredentials: true });
}
// --- Delete single items ---
@@ -108,25 +145,65 @@ export class HistoryService {
return this.http.delete<void>(`${this.apiBase()}/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
}
clearSearchHistory(): Observable<void> {
return this.http.delete<void>(`${this.apiBase()}/user/history/search?all=1`, { withCredentials: true });
}
clearWatchHistory(): Observable<void> {
return this.http.delete<void>(`${this.apiBase()}/user/history/watch?all=1`, { withCredentials: true });
}
// Recherche dans l'historique des recherches
searchInSearchHistory(query: string, limit = 100): Observable<SearchHistoryItem[]> {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<SearchHistoryItem[]>(
`${this.apiBase()}/user/history/search?${params.toString()}`,
{ withCredentials: true }
);
searchInSearchHistory(query: string, limit = 100, provider?: string): Observable<SearchHistoryItem[]> {
return this.getSearchHistory(limit, undefined, query, provider);
}
// Recherche dans l'historique de visionnage
searchInWatchHistory(query: string, limit = 100): Observable<WatchHistoryItem[]> {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', String(limit));
return this.http.get<WatchHistoryItem[]>(
`${this.apiBase()}/user/history/watch?${params.toString()}`,
{ withCredentials: true }
searchInWatchHistory(query: string, limit = 100, provider?: string): Observable<WatchHistoryItem[]> {
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<TranscriptHistoryItem | null> {
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<TranscriptHistoryItem>(url, { withCredentials: true }).pipe(
catchError(() => of(null))
);
}
listTranscripts(filters: HistoryFilters = {}): Observable<TranscriptHistoryItem[]> {
const qs = this.buildParams({ limit: filters.limit ?? 50, ...filters });
return this.http.get<TranscriptHistoryItem[]>(`${this.apiBase()}/user/history/transcripts?${qs}`, { withCredentials: true });
}
searchInTranscriptHistory(query: string, limit = 100, provider?: string, lang?: string): Observable<TranscriptHistoryItem[]> {
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<TranscriptHistoryItem | null> {
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<TranscriptHistoryItem>(
`${this.apiBase()}/user/history/transcripts`,
{ ...entry },
{ withCredentials: true }
).pipe(catchError(() => of(null)));
}
deleteTranscriptItem(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiBase()}/user/history/transcripts/${encodeURIComponent(id)}`, { withCredentials: true });
}
clearTranscriptHistory(provider?: string): Observable<void> {
const qs = provider ? `?all=1&provider=${encodeURIComponent(provider)}` : '?all=1';
return this.http.delete<void>(`${this.apiBase()}/user/history/transcripts${qs}`, { withCredentials: true });
}
}