Files
ohm_streaming/app/db.py
T
Roman 28f1bbde31 Préférence de contenus par compte : animés / séries / les deux
- 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.
2026-09-23 15:02:59 +00:00

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()