386 lines
15 KiB
JavaScript
386 lines
15 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
import Database from 'better-sqlite3';
|
|
|
|
const root = process.cwd();
|
|
const dbDir = path.join(root, 'db');
|
|
const dbFile = path.join(dbDir, 'newtube.db');
|
|
const schemaFile = path.join(dbDir, 'schema.sql');
|
|
|
|
if (!fs.existsSync(dbDir)) {
|
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
}
|
|
|
|
// Create DB and enable FKs
|
|
const db = new Database(dbFile);
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
// Run schema if present
|
|
if (fs.existsSync(schemaFile)) {
|
|
const ddl = fs.readFileSync(schemaFile, 'utf8');
|
|
if (ddl && ddl.trim().length) {
|
|
db.exec(ddl);
|
|
}
|
|
}
|
|
|
|
// Helpers
|
|
export function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
export function getUserByUsername(username) {
|
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
}
|
|
|
|
export function getUserById(id) {
|
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
}
|
|
|
|
export function insertUser({ id, username, email, passwordHash }) {
|
|
const ts = nowIso();
|
|
db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at)
|
|
VALUES (@id, @username, @email, @passwordHash, 1, @ts, @ts)`).run({ id, username, email, passwordHash, ts });
|
|
// Default preferences row
|
|
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
|
|
VALUES (@id, 'en', 'youtube', 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
|
|
}
|
|
|
|
export function upsertPreferences(userId, patch) {
|
|
const current = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(userId);
|
|
if (!current) {
|
|
const merged = {
|
|
language: patch.language ?? 'en',
|
|
default_provider: patch.defaultProvider ?? 'youtube',
|
|
theme: patch.theme ?? 'system',
|
|
video_quality: patch.videoQuality ?? 'auto',
|
|
region: patch.region ?? 'US',
|
|
version: 1,
|
|
};
|
|
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
|
|
VALUES (@userId, @language, @default_provider, @theme, @video_quality, @region, @version, @updated_at)`)
|
|
.run({ userId, ...merged, updated_at: nowIso() });
|
|
} else {
|
|
const merged = {
|
|
language: patch.language ?? current.language,
|
|
default_provider: patch.defaultProvider ?? current.default_provider,
|
|
theme: patch.theme ?? current.theme,
|
|
video_quality: patch.videoQuality ?? current.video_quality,
|
|
region: patch.region ?? current.region,
|
|
version: (current.version ?? 1) + 1,
|
|
updated_at: nowIso(),
|
|
};
|
|
db.prepare(`UPDATE user_preferences
|
|
SET language=@language, default_provider=@default_provider, theme=@theme,
|
|
video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
|
|
WHERE user_id=@userId`)
|
|
.run({ userId, ...merged });
|
|
}
|
|
}
|
|
|
|
export function getPreferences(userId) {
|
|
return db.prepare('SELECT language, default_provider AS defaultProvider, theme, video_quality AS videoQuality, region, version, updated_at FROM user_preferences WHERE user_id = ?').get(userId);
|
|
}
|
|
|
|
export function insertSession({ id, userId, refreshTokenHash, isRemember, userAgent, deviceInfo, ip, expiresAt }) {
|
|
const ts = nowIso();
|
|
db.prepare(`INSERT INTO sessions (id, user_id, refresh_token_hash, user_agent, device_info, ip_address, is_remember, created_at, last_seen_at, expires_at)
|
|
VALUES (@id, @userId, @refreshTokenHash, @userAgent, @deviceInfo, @ip, @isRemember, @ts, @ts, @expiresAt)`)
|
|
.run({ id, userId, refreshTokenHash, userAgent, deviceInfo, ip, isRemember: isRemember ? 1 : 0, ts, expiresAt });
|
|
}
|
|
|
|
export function getSessionById(id) {
|
|
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
|
}
|
|
|
|
export function updateSessionToken(id, refreshTokenHash, expiresAt) {
|
|
const ts = nowIso();
|
|
db.prepare('UPDATE sessions SET refresh_token_hash = ?, last_seen_at = ?, expires_at = ?, revoked_at = NULL WHERE id = ?')
|
|
.run(refreshTokenHash, ts, expiresAt, id);
|
|
}
|
|
|
|
export function revokeSession(id) {
|
|
const ts = nowIso();
|
|
db.prepare('UPDATE sessions SET revoked_at = ? WHERE id = ?').run(ts, id);
|
|
}
|
|
|
|
export function revokeAllUserSessions(userId) {
|
|
const ts = nowIso();
|
|
db.prepare('UPDATE sessions SET revoked_at = ? WHERE user_id = ?').run(ts, userId);
|
|
}
|
|
|
|
export function listUserSessions(userId) {
|
|
return db.prepare('SELECT id, user_agent AS userAgent, device_info AS deviceInfo, ip_address AS ip, is_remember AS isRemember, created_at AS createdAt, last_seen_at AS lastSeenAt, expires_at AS expiresAt, revoked_at AS revokedAt FROM sessions WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
|
}
|
|
|
|
export function setUserLastLogin(userId) {
|
|
const ts = nowIso();
|
|
db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(ts, ts, userId);
|
|
}
|
|
|
|
export function insertLoginAudit({ userId, username, ip, userAgent, success, reason }) {
|
|
const id = cryptoRandomId();
|
|
const ts = nowIso();
|
|
db.prepare(`INSERT INTO login_audit (id, user_id, username, ip_address, user_agent, success, reason, created_at)
|
|
VALUES (@id, @userId, @username, @ip, @userAgent, @success, @reason, @ts)`)
|
|
.run({ id, userId, username, ip, userAgent, success: success ? 1 : 0, reason: reason || null, ts });
|
|
}
|
|
|
|
export function cryptoRandomId() {
|
|
// simple URL-safe base64 16 bytes id
|
|
return randomBytes(16).toString('base64url');
|
|
}
|
|
|
|
export function cryptoRandomUUID() {
|
|
return randomUUID();
|
|
}
|
|
|
|
export default db;
|
|
|
|
// -------------------- Search History --------------------
|
|
export function insertSearchHistory({ userId, query, filters }) {
|
|
const id = cryptoRandomId();
|
|
const created_at = nowIso();
|
|
const filters_json = filters ? JSON.stringify(filters) : null;
|
|
db.prepare(`INSERT INTO search_history (id, user_id, query, filters_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?)`)
|
|
.run(id, userId, query, filters_json, created_at);
|
|
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);
|
|
}
|
|
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 (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);
|
|
}
|
|
|
|
export function deleteSearchHistoryById(userId, id) {
|
|
db.prepare(`DELETE FROM search_history WHERE id = ? AND user_id = ?`).run(id, userId);
|
|
}
|
|
|
|
export function deleteAllSearchHistory(userId) {
|
|
db.prepare(`DELETE FROM search_history WHERE user_id = ?`).run(userId);
|
|
}
|
|
|
|
// -------------------- Watch History --------------------
|
|
export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
|
|
const now = nowIso();
|
|
const watched_at = watchedAt || now;
|
|
// Insert or update on unique (user_id, provider, video_id)
|
|
db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, thumbnail, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, provider, video_id) DO UPDATE SET
|
|
title=COALESCE(excluded.title, title),
|
|
thumbnail=COALESCE(excluded.thumbnail, watch_history.thumbnail),
|
|
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
|
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
|
last_position_seconds=excluded.last_position_seconds,
|
|
last_watched_at=excluded.last_watched_at`).run(
|
|
cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
|
|
);
|
|
// Return the row id
|
|
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
|
return row;
|
|
}
|
|
|
|
export function updateWatchHistoryById(id, { progressSeconds, lastPositionSeconds }) {
|
|
const now = nowIso();
|
|
const row = db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
|
if (!row) return null;
|
|
const nextProgress = (typeof progressSeconds === 'number') ? Math.max(progressSeconds, row.progress_seconds || 0) : row.progress_seconds;
|
|
const nextLastPos = (typeof lastPositionSeconds === 'number') ? lastPositionSeconds : row.last_position_seconds;
|
|
db.prepare(`UPDATE watch_history SET progress_seconds = ?, last_position_seconds = ?, last_watched_at = ? WHERE id = ?`)
|
|
.run(nextProgress, nextLastPos, now, id);
|
|
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);
|
|
}
|
|
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 (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);
|
|
}
|
|
|
|
export function deleteWatchHistoryById(userId, id) {
|
|
db.prepare(`DELETE FROM watch_history WHERE id = ? AND user_id = ?`).run(id, userId);
|
|
}
|
|
|
|
export function deleteAllWatchHistory(userId) {
|
|
db.prepare(`DELETE FROM watch_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);
|
|
if (tag && tag.id) return tag.id;
|
|
const id = cryptoRandomId();
|
|
db.prepare(`INSERT INTO tags (id, user_id, name, created_at) VALUES (?, ?, ?, ?)`)
|
|
.run(id, userId, name, nowIso());
|
|
return id;
|
|
}
|
|
|
|
export function likeVideo({ userId, provider, videoId }) {
|
|
const tagId = ensureTag(userId, 'like');
|
|
// Upsert-like behavior; ignore if exists
|
|
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id) VALUES (?, ?, ?, ?)`)
|
|
.run(userId, provider, videoId, tagId);
|
|
return { provider, video_id: videoId };
|
|
}
|
|
|
|
export function unlikeVideo({ userId, provider, videoId }) {
|
|
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
|
if (!tag) return { removed: false };
|
|
const info = db.prepare(`DELETE FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
|
.run(userId, provider, videoId, tag.id);
|
|
return { removed: (info.changes || 0) > 0 };
|
|
}
|
|
|
|
export function isVideoLiked({ userId, provider, videoId }) {
|
|
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
|
if (!tag) return false;
|
|
const row = db.prepare(`SELECT 1 FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
|
.get(userId, provider, videoId, tag.id);
|
|
return Boolean(row);
|
|
}
|
|
|
|
export function listLikedVideos({ userId, limit = 100, q }) {
|
|
try {
|
|
console.log(`[listLikedVideos] Récupération des vidéos aimées pour l'utilisateur ${userId}`);
|
|
|
|
// Vérifier que la table tags existe
|
|
const tableExists = db.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='tags'
|
|
`).get();
|
|
|
|
if (!tableExists) {
|
|
console.error('[listLikedVideos] La table tags n\'existe pas');
|
|
return [];
|
|
}
|
|
|
|
// Récupérer le tag 'like' de l'utilisateur
|
|
const tag = db.prepare(`
|
|
SELECT id FROM tags
|
|
WHERE user_id = ? AND name = ?
|
|
`).get(userId, 'like');
|
|
|
|
if (!tag) {
|
|
console.log(`[listLikedVideos] Aucun tag 'like' trouvé pour l'utilisateur ${userId}`);
|
|
return [];
|
|
}
|
|
|
|
console.log(`[listLikedVideos] Tag ID: ${tag.id}`);
|
|
|
|
// Vérifier que la table video_tags existe
|
|
const videoTagsExists = db.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='video_tags'
|
|
`).get();
|
|
|
|
if (!videoTagsExists) {
|
|
console.error('[listLikedVideos] La table video_tags n\'existe pas');
|
|
return [];
|
|
}
|
|
|
|
// Récupérer les vidéos aimées avec les métadonnées de l'historique
|
|
// Note: La colonne thumbnail n'existe pas dans la table watch_history
|
|
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
|
const like = `%${(q || '').trim()}%`;
|
|
const base = `
|
|
SELECT
|
|
vt.provider,
|
|
vt.video_id,
|
|
COALESCE(wh.title, '') AS title,
|
|
COALESCE(wh.thumbnail, '') AS thumbnail,
|
|
wh.last_watched_at AS last_watched_at
|
|
FROM video_tags vt
|
|
LEFT JOIN watch_history wh
|
|
ON wh.user_id = vt.user_id
|
|
AND wh.provider = vt.provider
|
|
AND wh.video_id = vt.video_id
|
|
WHERE vt.user_id = ? AND vt.tag_id = ?
|
|
`;
|
|
const orderLimit = `
|
|
ORDER BY COALESCE(wh.last_watched_at, wh.watched_at) DESC
|
|
LIMIT ?
|
|
`;
|
|
const query = hasQ
|
|
? `${base} AND (COALESCE(wh.title,'') LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
|
${orderLimit}`
|
|
: `${base} ${orderLimit}`;
|
|
|
|
console.log('[listLikedVideos] Exécution de la requête:', query.replace(/\s+/g, ' ').trim());
|
|
|
|
const rows = hasQ
|
|
? db.prepare(query).all(userId, tag.id, like, like, like, limit)
|
|
: db.prepare(query).all(userId, tag.id, limit);
|
|
console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`);
|
|
|
|
return rows;
|
|
} catch (error) {
|
|
console.error('[listLikedVideos] Erreur:', error.message);
|
|
console.error(error.stack);
|
|
throw error; // Renvoyer l'erreur pour qu'elle soit gérée par le routeur
|
|
}
|
|
}
|