- Colonne users.content_preference (anime|serie|both, défaut both) avec migration automatique des bases existantes. - GET /auth/me expose la préférence ; PUT /auth/preferences la met à jour (422 si valeur invalide). - /api/search : filtre les résultats par media_type ET ignore les sources hors périmètre (une source anime-only n'est pas interrogée en mode séries). Les films restent associés aux animés. - /api/discover : nouveautés filtrées selon la même règle. - Sélecteur compact dans la topbar (⛩/📺/✨), persisté serveur. - 7 tests dédiés (roundtrip, rejet invalide, filtrage search/discover, skip de sources) — 114 au total, ruff clean. Prête pour la première source de séries : il ne reste qu'un module SourceScraper avec media_types=("serie",) à écrire.
136 lines
4.8 KiB
Python
136 lines
4.8 KiB
Python
"""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,
|
|
content_preference TEXT NOT NULL DEFAULT 'both'
|
|
CHECK (content_preference IN ('anime','serie','both')),
|
|
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._migrate()
|
|
await self._conn.commit()
|
|
|
|
async def _migrate(self) -> None:
|
|
"""Migrations légères : colonnes ajoutées après coup pour les bases existantes."""
|
|
cursor = await self._conn.execute("PRAGMA table_info(users)")
|
|
columns = {row["name"] for row in await cursor.fetchall()}
|
|
if "content_preference" not in columns:
|
|
await self._conn.execute(
|
|
"ALTER TABLE users ADD COLUMN content_preference TEXT NOT NULL DEFAULT 'both'"
|
|
)
|
|
|
|
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()
|