"""Couche d'accès SQLite (aiosqlite) — unique source de vérité de l'application.""" import aiosqlite from app.config import get_settings SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_key TEXT NOT NULL, -- clé de déduplication (video_url normalisée) video_url TEXT NOT NULL, page_url TEXT, title TEXT NOT NULL, file_path TEXT, -- relatif au dossier de téléchargement status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','downloading','paused','done','failed','cancelled')), total_bytes INTEGER, downloaded_bytes INTEGER NOT NULL DEFAULT 0, error TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); CREATE UNIQUE INDEX IF NOT EXISTS idx_downloads_active_source ON downloads(source_key) WHERE status IN ('pending','downloading','paused'); CREATE TABLE IF NOT EXISTS metadata_cache ( cache_key TEXT PRIMARY KEY, payload TEXT NOT NULL, -- JSON fetched_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL -- JSON ); CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, source TEXT NOT NULL, source_id TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, payload TEXT, -- JSON métadonnées created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, source, source_id) ); CREATE TABLE IF NOT EXISTS watch_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE, position_seconds REAL NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, download_id) ); """ class Database: """Connexion SQLite partagée, initialisée au démarrage de l'app.""" def __init__(self) -> None: self._conn: aiosqlite.Connection | None = None async def connect(self) -> None: path = get_settings().database_path self._conn = await aiosqlite.connect(path) self._conn.row_factory = aiosqlite.Row await self._conn.execute("PRAGMA journal_mode=WAL") await self._conn.execute("PRAGMA foreign_keys=ON") await self._conn.executescript(SCHEMA) await self._conn.commit() async def close(self) -> None: if self._conn is not None: await self._conn.close() self._conn = None @property def conn(self) -> aiosqlite.Connection: if self._conn is None: raise RuntimeError("Database.connect() n'a pas été appelé") return self._conn async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor: cursor = await self.conn.execute(sql, params) await self.conn.commit() return cursor async def fetchone(self, sql: str, params: tuple = ()) -> aiosqlite.Row | None: cursor = await self.conn.execute(sql, params) row = await cursor.fetchone() await cursor.close() return row async def fetchall(self, sql: str, params: tuple = ()) -> list[aiosqlite.Row]: cursor = await self.conn.execute(sql, params) rows = await cursor.fetchall() await cursor.close() return rows db = Database()