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
+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