feat(history): refonte page historique en tabs + transcripts conserves et rejoues sans regeneration
This commit is contained in:
+172
-65
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user