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); } } // Lightweight schema upgrades for existing databases (SQLite is permissive) (function ensurePlaylistSchemaUpgrades() { try { const colsPlaylists = db.prepare(`PRAGMA table_info(playlists)`).all(); const have = new Set(colsPlaylists.map(c => c.name)); if (!have.has('description')) db.exec(`ALTER TABLE playlists ADD COLUMN description TEXT`); if (!have.has('thumbnail')) db.exec(`ALTER TABLE playlists ADD COLUMN thumbnail TEXT`); if (!have.has('is_private')) db.exec(`ALTER TABLE playlists ADD COLUMN is_private INTEGER NOT NULL DEFAULT 1`); } catch {} try { const colsItems = db.prepare(`PRAGMA table_info(playlist_items)`).all(); const have2 = new Set(colsItems.map(c => c.name)); if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`); } catch {} try { db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, action TEXT NOT NULL, meta_json TEXT, created_at TEXT NOT NULL );`); db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_user_time ON playlist_metrics(user_id, created_at DESC);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_playlist_time ON playlist_metrics(playlist_id, created_at DESC);`); } catch {} })(); // (duplicate schema exec removed) // 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 }) { 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=COALESCE(excluded.last_position_seconds, watch_history.last_position_seconds), last_watched_at=excluded.last_watched_at`).run( cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), 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, title, thumbnail }) { 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, created_at) VALUES (?, ?, ?, ?, ?)`) .run(userId, provider, videoId, tagId, nowIso()); // Also update the watch_history table with the title and thumbnail if (title || thumbnail) { upsertWatchHistory({ userId, provider, videoId, title, thumbnail }); } 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, vt.created_at, 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 vt.created_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 } } // -------------------- Playlists -------------------- export function recordPlaylistMetric({ userId, playlistId, action, meta }) { const id = cryptoRandomId(); const created_at = nowIso(); const meta_json = meta ? JSON.stringify(meta) : null; db.prepare(`INSERT INTO playlist_metrics (id, user_id, playlist_id, action, meta_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) .run(id, userId, playlistId, action, meta_json, created_at); return { id, created_at }; } export function createPlaylist({ userId, title, description, thumbnail, isPrivate = true }) { const id = cryptoRandomUUID(); const now = nowIso(); const name = String(title || '').trim(); if (!name) throw new Error('title_required'); db.prepare(`INSERT INTO playlists (id, user_id, name, description, thumbnail, is_private, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) .run(id, userId, name, description || null, thumbnail || null, isPrivate ? 1 : 0, now, now); recordPlaylistMetric({ userId, playlistId: id, action: 'create', meta: { title: name } }); return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt, (SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount FROM playlists WHERE id = ?`).get(id); } export function listPlaylists({ userId, limit = 50, offset = 0, q }) { const like = `%${(q || '').trim()}%`; const hasQ = typeof q === 'string' && q.trim().length > 0; const base = `SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt, (SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount FROM playlists WHERE user_id = ?`; const sql = hasQ ? `${base} AND (name LIKE ? OR COALESCE(description,'') LIKE ?) ORDER BY updated_at DESC LIMIT ? OFFSET ?` : `${base} ORDER BY updated_at DESC LIMIT ? OFFSET ?`; return hasQ ? db.prepare(sql).all(userId, like, like, limit, offset) : db.prepare(sql).all(userId, limit, offset); } export function getPlaylistRaw(id) { return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt FROM playlists WHERE id = ?`).get(id); } export function updatePlaylist({ userId, id, patch }) { const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id); if (!cur) return null; if (cur.user_id !== userId) return 'forbidden'; const next = { name: patch.title != null ? String(patch.title).trim() : cur.name, description: patch.description != null ? String(patch.description).trim() : cur.description, thumbnail: patch.thumbnail != null ? String(patch.thumbnail).trim() : cur.thumbnail, is_private: typeof patch.isPrivate === 'boolean' ? (patch.isPrivate ? 1 : 0) : cur.is_private, updated_at: nowIso(), }; db.prepare(`UPDATE playlists SET name=@name, description=@description, thumbnail=@thumbnail, is_private=@is_private, updated_at=@updated_at WHERE id = ?`) .run(id, next); recordPlaylistMetric({ userId, playlistId: id, action: 'update', meta: { title: next.name } }); return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt FROM playlists WHERE id = ?`).get(id); } export function deletePlaylist({ userId, id }) { const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id); if (!cur) return { removed: false }; if (cur.user_id !== userId) return 'forbidden'; const info = db.prepare(`DELETE FROM playlists WHERE id = ?`).run(id); recordPlaylistMetric({ userId, playlistId: id, action: 'delete' }); return { removed: (info.changes || 0) > 0 }; } export function listPlaylistItems({ playlistId, limit = 200, offset = 0 }) { return db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position FROM playlist_items WHERE playlist_id = ? ORDER BY position ASC LIMIT ? OFFSET ?`).all(playlistId, limit, offset); } export function addPlaylistVideo({ userId, playlistId, provider, videoId, title, thumbnail }) { const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId); if (!pl) return 'not_found'; if (pl.user_id !== userId) return 'forbidden'; const now = nowIso(); const posRow = db.prepare(`SELECT COALESCE(MAX(position), 0) + 1 AS nextPos FROM playlist_items WHERE playlist_id = ?`).get(playlistId); const position = Number(posRow?.nextPos || 1); const id = cryptoRandomId(); db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position); recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } }); // Return the row (if it existed we need to fetch by key) const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).get(playlistId, provider, videoId); return row; } export function removePlaylistVideo({ userId, playlistId, provider, videoId }) { const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId); if (!pl) return 'not_found'; if (pl.user_id !== userId) return 'forbidden'; const info = db.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).run(playlistId, provider, videoId); recordPlaylistMetric({ userId, playlistId, action: 'remove_video', meta: { provider, videoId } }); return { removed: (info.changes || 0) > 0 }; } export function reorderPlaylistVideos({ userId, playlistId, order }) { const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId); if (!pl) return 'not_found'; if (pl.user_id !== userId) return 'forbidden'; if (!Array.isArray(order) || order.length === 0) return { changed: 0 }; const tx = db.transaction((rows) => { let i = 1, changed = 0; for (const it of rows) { // Support either item ids or provider+videoId pairs if (it && typeof it === 'string') { const info = db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it, playlistId); changed += info.changes || 0; } else if (it && (it.id || (it.provider && it.videoId))) { const info = it.id ? db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it.id, playlistId) : db.prepare(`UPDATE playlist_items SET position = ? WHERE provider = ? AND video_id = ? AND playlist_id = ?`).run(i, it.provider, it.videoId, playlistId); changed += info.changes || 0; } i++; } return changed; }); const changed = tx(order); recordPlaylistMetric({ userId, playlistId, action: 'reorder', meta: { count: order.length } }); return { changed }; }