//! Base SQLite (`/app/data/xtremflow.db`). //! //! Schéma, migrations et formats de dates identiques à l'ancien serveur //! Dart : la base de production est reprise telle quelle au premier //! démarrage du serveur Rust. use crate::dart_time::{DartDateTime, now_local_iso, utc_iso}; use crate::models::{AutoRule, NbaGame, Playlist, Recording, ScheduledGame, Session, User}; use crate::util::password; use chrono::{DateTime, Duration, Utc}; use regex::Regex; use rusqlite::types::ValueRef; use rusqlite::{Connection, OptionalExtension, Row, params}; use serde_json::{Map, Value}; use std::sync::{LazyLock, Mutex}; pub struct AppDatabase { conn: Mutex, } type DbResult = Result; fn parse_date(raw: Option) -> DartDateTime { raw.as_deref() .and_then(DartDateTime::parse) .unwrap_or_else(DartDateTime::now_local) } fn user_from_row(row: &Row) -> DbResult { Ok(User { id: row.get("id")?, username: row.get("username")?, is_admin: row.get::<_, Option>("is_admin")?.unwrap_or(0) == 1, created_at: parse_date(row.get("created_at")?), }) } fn playlist_from_row(row: &Row) -> DbResult { Ok(Playlist { id: row.get("id")?, user_id: row.get("user_id")?, name: row.get("name")?, server_url: row.get("server_url")?, username: row.get("username")?, password: row.get("password")?, dns: row.get("dns")?, created_at: parse_date(row.get("created_at")?), updated_at: parse_date(row.get("updated_at")?), }) } fn recording_from_row(row: &Row) -> DbResult { let text = |name: &str| -> DbResult { Ok(row.get::<_, Option>(name)?.unwrap_or_default()) }; Ok(Recording { id: text("id")?, user_id: text("user_id")?, channel_id: text("channel_id")?, stream_url: text("stream_url")?, title: text("title")?, start_time: parse_date(row.get("start_time")?), end_time: parse_date(row.get("end_time")?), status: row .get::<_, Option>("status")? .unwrap_or_else(|| "scheduled".into()), file_path: row.get("file_path")?, error_reason: row.get("error_reason")?, source_rule_id: row.get("source_rule_id")?, playlist_id: row.get("playlist_id")?, created_at: parse_date(row.get("created_at")?), updated_at: parse_date(row.get("updated_at")?), }) } /// Violation d'une contrainte d'unicité (ex. deuxième règle NBA concurrente). pub fn is_unique_violation(e: &rusqlite::Error) -> bool { matches!( e, rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE ) } fn json_list(raw: Option) -> Vec { raw.and_then(|r| serde_json::from_str(&r).ok()) .unwrap_or_default() } fn auto_rule_from_row(row: &Row) -> DbResult { Ok(AutoRule { id: row.get("id")?, user_id: row.get("user_id")?, kind: row.get("kind")?, enabled: row.get::<_, i64>("enabled")? == 1, default_decision: row.get("default_decision")?, channel_ids: json_list(row.get("channel_ids")?), pad_before_min: row.get("pad_before_min")?, pad_after_min: row.get("pad_after_min")?, team_filter: json_list(row.get("team_filter")?), keep_days: row.get("keep_days")?, playlist_id: row.get("playlist_id")?, created_at: row .get::<_, Option>("created_at")? .unwrap_or_default(), updated_at: row .get::<_, Option>("updated_at")? .unwrap_or_default(), }) } fn nba_game_from_row(row: &Row) -> DbResult { Ok(NbaGame { game_id: row.get("game_id")?, rule_id: row.get("rule_id")?, home: row.get("home")?, away: row.get("away")?, home_id: row.get("home_id")?, away_id: row.get("away_id")?, home_tri: row.get("home_tri")?, away_tri: row.get("away_tri")?, tipoff_utc: row.get("tipoff_utc")?, status: row.get("status")?, decision: row.get("decision")?, channel_id: row.get("channel_id")?, channel_name: row.get("channel_name")?, epg_title: row.get("epg_title")?, epg_start: row.get("epg_start")?, epg_end: row.get("epg_end")?, recording_id: row.get("recording_id")?, confidence: row.get("confidence")?, conflict: row.get::<_, i64>("conflict")? == 1, updated_at: row .get::<_, Option>("updated_at")? .unwrap_or_default(), }) } /// Ligne brute → objet JSON (colonnes dans l'ordre de la table). fn row_to_json(row: &Row) -> DbResult> { let mut map = Map::new(); let stmt = row.as_ref(); for i in 0..stmt.column_count() { let name = stmt.column_name(i)?.to_string(); let value = match row.get_ref(i)? { ValueRef::Null => Value::Null, ValueRef::Integer(v) => Value::from(v), ValueRef::Real(v) => Value::from(v), ValueRef::Text(v) => Value::from(String::from_utf8_lossy(v).into_owned()), ValueRef::Blob(v) => Value::from(String::from_utf8_lossy(v).into_owned()), }; map.insert(name, value); } Ok(map) } /// Mot de passe initial sans caractères ambigus (0/O, 1/l/I) : il est /// recopié depuis les logs du conteneur. fn generate_password(length: usize) -> String { const CHARS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let mut out = String::with_capacity(length); while out.len() < length { // Octets aléatoires du générateur système (via UUID v4), tirage par // rejet pour une distribution uniforme. for byte in uuid::Uuid::new_v4().into_bytes() { let limit = 256 - (256 % CHARS.len()); if (byte as usize) < limit && out.len() < length { out.push(CHARS[byte as usize % CHARS.len()] as char); } } } out } static GPU_SETTING: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)"enable_nvidia_gpu"\s*:\s*true"#).unwrap()); impl AppDatabase { /// Ouvre (ou crée) la base et applique schéma et migrations. pub fn open(path: &str) -> DbResult { if let Some(dir) = std::path::Path::new(path).parent() { let _ = std::fs::create_dir_all(dir); } let conn = Connection::open(path)?; // Sans foreign_keys, les ON DELETE CASCADE déclarés dans le schéma sont // ignorés par SQLite : supprimer un utilisateur laissait ses sessions // (donc des jetons valides), playlists et enregistrements orphelins. conn.execute_batch( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;", )?; let db = Self { conn: Mutex::new(conn), }; db.create_tables()?; db.run_migrations()?; db.attach_legacy_rows_to_playlists()?; println!("Database initialized: {path}"); Ok(db) } fn lock(&self) -> std::sync::MutexGuard<'_, Connection> { self.conn.lock().unwrap_or_else(|e| e.into_inner()) } fn create_tables(&self) -> DbResult<()> { self.lock().execute_batch( r#" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, server_url TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, dns TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token TEXT UNIQUE NOT NULL, expires_at TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS user_settings ( user_id TEXT PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS tv_recordings ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, channel_id TEXT NOT NULL, stream_url TEXT NOT NULL, title TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled', file_path TEXT, error_reason TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS season_passes ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, show_title TEXT NOT NULL, channel_id TEXT NOT NULL, stream_url TEXT NOT NULL, enabled INTEGER DEFAULT 1, match_mode TEXT NOT NULL DEFAULT 'exact', created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS auto_rules ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'nba', enabled INTEGER NOT NULL DEFAULT 1, default_decision TEXT NOT NULL DEFAULT 'auto', channel_ids TEXT NOT NULL DEFAULT '[]', pad_before_min INTEGER NOT NULL DEFAULT 5, pad_after_min INTEGER NOT NULL DEFAULT 45, team_filter TEXT NOT NULL DEFAULT '[]', keep_days INTEGER, created_at TEXT, updated_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS nba_games ( game_id TEXT NOT NULL, rule_id TEXT NOT NULL, home TEXT NOT NULL DEFAULT '', away TEXT NOT NULL DEFAULT '', home_id TEXT NOT NULL DEFAULT '', away_id TEXT NOT NULL DEFAULT '', home_tri TEXT NOT NULL DEFAULT '', away_tri TEXT NOT NULL DEFAULT '', tipoff_utc TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'provisional', decision TEXT NOT NULL DEFAULT 'auto', channel_id TEXT, channel_name TEXT, epg_title TEXT, epg_start TEXT, epg_end TEXT, recording_id TEXT, confidence INTEGER, conflict INTEGER NOT NULL DEFAULT 0, updated_at TEXT, PRIMARY KEY (game_id, rule_id), FOREIGN KEY (rule_id) REFERENCES auto_rules(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id); CREATE INDEX IF NOT EXISTS idx_recordings_status ON tv_recordings(status); CREATE INDEX IF NOT EXISTS idx_recordings_user ON tv_recordings(user_id); CREATE INDEX IF NOT EXISTS idx_recordings_start ON tv_recordings(start_time); CREATE INDEX IF NOT EXISTS idx_season_passes_user ON season_passes(user_id); CREATE INDEX IF NOT EXISTS idx_auto_rules_user ON auto_rules(user_id); CREATE INDEX IF NOT EXISTS idx_nba_games_tipoff ON nba_games(rule_id, tipoff_utc); "#, ) } /// Migrations de schéma pour les bases créées par une version antérieure. /// /// `CREATE TABLE IF NOT EXISTS` n'ajoute jamais de colonne à une table /// existante : chaque colonne introduite après coup a sa migration, /// numérotée et rejouée uniquement si nécessaire. fn run_migrations(&self) -> DbResult<()> { let conn = self.lock(); conn.execute( "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)", [], )?; let mut current: i64 = conn.query_row( "SELECT COALESCE(MAX(version), 0) AS v FROM schema_version", [], |r| r.get(0), )?; let migrations: [(i64, &str, &str, &str); 6] = [ // v1 : colonne error_reason absente des bases d'avant son introduction. (1, "tv_recordings", "error_reason", "TEXT"), // v2 : mode de correspondance des season passes. 'contains' pour les // lignes existantes (comportement historique) ; les nouvelles // créations passent par l'API qui choisit 'exact' par défaut. ( 2, "season_passes", "match_mode", "TEXT NOT NULL DEFAULT 'contains'", ), // v3 : lien vers la règle automatique (badge « Auto NBA », sidecars). (3, "tv_recordings", "source_rule_id", "TEXT"), // v4-v6 : IPTV de rattachement (plusieurs playlists par compte). (4, "tv_recordings", "playlist_id", "TEXT"), (5, "season_passes", "playlist_id", "TEXT"), (6, "auto_rules", "playlist_id", "TEXT"), ]; for (version, table, column, definition) in migrations { if version <= current { continue; } let exists = { let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; let names = stmt.query_map([], |r| r.get::<_, String>("name"))?; let mut found = false; for name in names { if name? == column { found = true; } } found }; if !exists { conn.execute( &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"), [], )?; } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [version])?; current = version; println!("[DB] Migration v{version} appliquée"); } Ok(()) } /// Lignes antérieures au multi-IPTV : rattachées à la playlist la plus /// récente de leur propriétaire, celle qu'elles utilisaient de fait. /// Sans cela elles s'afficheraient sous toutes les playlists. Rejoué à /// chaque démarrage, sans effet une fois tout rattaché. fn attach_legacy_rows_to_playlists(&self) -> DbResult<()> { let conn = self.lock(); for table in ["tv_recordings", "season_passes", "auto_rules"] { conn.execute( &format!( "UPDATE {table} SET playlist_id = (SELECT p.id FROM playlists p WHERE p.user_id = {table}.user_id ORDER BY p.created_at DESC LIMIT 1) WHERE playlist_id IS NULL" ), [], )?; } // Une règle NBA par utilisateur ET par IPTV. conn.execute_batch( "DROP INDEX IF EXISTS idx_auto_rules_user_kind; CREATE UNIQUE INDEX IF NOT EXISTS idx_auto_rules_user_kind_playlist ON auto_rules(user_id, kind, IFNULL(playlist_id, ''));", )?; Ok(()) } /// Crée le compte admin s'il n'existe aucun utilisateur. /// /// Le mot de passe initial vient de ADMIN_INITIAL_PASSWORD, ou est généré /// aléatoirement et affiché UNE FOIS dans les logs de démarrage. pub fn seed_admin(&self) -> DbResult<()> { let count: i64 = self .lock() .query_row("SELECT COUNT(*) as count FROM users", [], |r| r.get(0))?; if count != 0 { return Ok(()); } let env_password = std::env::var("ADMIN_INITIAL_PASSWORD").unwrap_or_default(); let generated = env_password.is_empty(); let password = if generated { generate_password(16) } else { env_password }; let hash = password::hash(&password).expect("bcrypt"); self.lock().execute( "INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, 1)", params![uuid::Uuid::new_v4().to_string(), "admin", hash], )?; if generated { println!("╔══════════════════════════════════════════════════════════╗"); println!(" Compte admin créé — mot de passe initial (affiché une"); println!(" seule fois, changez-le après la première connexion) :"); println!(" utilisateur: admin"); println!(" mot de passe: {password}"); println!("╚══════════════════════════════════════════════════════════╝"); } else { println!( "Default admin user created (username: admin, password: ADMIN_INITIAL_PASSWORD)" ); } Ok(()) } // ==================== Users ==================== pub fn find_user_by_username(&self, username: &str) -> Option { self.lock() .query_row( "SELECT * FROM users WHERE username = ?", [username], user_from_row, ) .optional() .ok() .flatten() } pub fn find_user_by_id(&self, user_id: &str) -> Option { self.lock() .query_row("SELECT * FROM users WHERE id = ?", [user_id], user_from_row) .optional() .ok() .flatten() } /// Vérifie les identifiants. Le hachage bcrypt (coûteux) est fait hors /// du verrou de la base : à appeler depuis `spawn_blocking`. pub fn verify_credentials(&self, username: &str, password: &str) -> Option { let (user, stored) = self .lock() .query_row( "SELECT * FROM users WHERE username = ?", [username], |row| Ok((user_from_row(row)?, row.get::<_, String>("password_hash")?)), ) .optional() .ok() .flatten()?; if !password::verify(password, &stored) { return None; } // Migration paresseuse : les anciens hachages SHA-256 sont refaits en // bcrypt à la connexion (seul moment où le mot de passe est connu). if password::is_legacy(&stored) && let Ok(hash) = password::hash(password) { self.update_password_hash(&user.id, &hash); println!("[Auth] Migrated legacy password hash for user: {username}"); } Some(user) } pub fn update_password_hash(&self, user_id: &str, hash: &str) { let _ = self.lock().execute( "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", params![hash, user_id], ); } /// Crée un utilisateur ; [password_hash] est déjà calculé par l'appelant. pub fn create_user( &self, username: &str, password_hash: &str, is_admin: bool, ) -> DbResult { let id = uuid::Uuid::new_v4().to_string(); self.lock().execute( "INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)", params![id, username, password_hash, is_admin as i64], )?; Ok(User { id, username: username.to_string(), is_admin, created_at: DartDateTime::now_local(), }) } pub fn get_all_users(&self) -> Vec { let conn = self.lock(); let Ok(mut stmt) = conn.prepare("SELECT * FROM users ORDER BY username ASC") else { return Vec::new(); }; stmt.query_map([], user_from_row) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } pub fn update_user_admin_status(&self, user_id: &str, is_admin: bool) -> DbResult<()> { self.lock().execute( "UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", params![is_admin as i64, user_id], )?; Ok(()) } pub fn delete_user(&self, user_id: &str) -> DbResult<()> { self.lock() .execute("DELETE FROM users WHERE id = ?", [user_id])?; Ok(()) } // ==================== Sessions ==================== pub fn create_session(&self, user_id: &str) -> DbResult { let token = uuid::Uuid::new_v4().to_string(); let expires_at = DartDateTime::now_local().add(Duration::days(7)); self.lock().execute( "INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)", params![ uuid::Uuid::new_v4().to_string(), user_id, token, expires_at.to_iso() ], )?; Ok(Session { user_id: user_id.to_string(), token, expires_at, }) } /// Session valide pour [token] ; une session expirée est supprimée. pub fn find_session_by_token(&self, token: &str) -> Option { let session = self .lock() .query_row("SELECT * FROM sessions WHERE token = ?", [token], |row| { Ok(Session { user_id: row.get("user_id")?, token: row.get("token")?, expires_at: parse_date(row.get("expires_at")?), }) }) .optional() .ok() .flatten()?; if session.is_expired() { self.delete_session(token); return None; } Some(session) } pub fn delete_session(&self, token: &str) { let _ = self .lock() .execute("DELETE FROM sessions WHERE token = ?", [token]); } pub fn clean_expired_sessions(&self) { let _ = self.lock().execute( "DELETE FROM sessions WHERE expires_at < ?", [now_local_iso()], ); } // ==================== Playlists ==================== pub fn get_playlists(&self, user_id: &str) -> Vec { let conn = self.lock(); let Ok(mut stmt) = conn.prepare("SELECT * FROM playlists WHERE user_id = ? ORDER BY created_at DESC") else { return Vec::new(); }; stmt.query_map([user_id], playlist_from_row) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } pub fn get_playlist_by_id(&self, id: &str) -> Option { self.lock() .query_row( "SELECT * FROM playlists WHERE id = ?", [id], playlist_from_row, ) .optional() .ok() .flatten() } #[allow(clippy::too_many_arguments)] pub fn create_playlist( &self, user_id: &str, name: &str, server_url: &str, username: &str, password: &str, dns: Option<&str>, ) -> DbResult { let id = uuid::Uuid::new_v4().to_string(); let now = DartDateTime::now_local(); let now_iso = now.to_iso(); self.lock().execute( "INSERT INTO playlists (id, user_id, name, server_url, username, password, dns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", params![id, user_id, name, server_url, username, password, dns, now_iso, now_iso], )?; Ok(Playlist { id, user_id: user_id.into(), name: name.into(), server_url: server_url.into(), username: username.into(), password: password.into(), dns: dns.map(str::to_string), created_at: now, updated_at: now, }) } pub fn update_playlist( &self, id: &str, name: &str, server_url: &str, username: &str, password: &str, dns: Option<&str>, ) -> DbResult> { self.lock().execute( "UPDATE playlists SET name = ?, server_url = ?, username = ?, password = ?, dns = ?, updated_at = ? WHERE id = ?", params![name, server_url, username, password, dns, now_local_iso(), id], )?; Ok(self.get_playlist_by_id(id)) } pub fn delete_playlist(&self, id: &str) -> DbResult<()> { self.lock() .execute("DELETE FROM playlists WHERE id = ?", [id])?; Ok(()) } // ==================== User Settings ==================== pub fn get_user_settings(&self, user_id: &str) -> Option { self.lock() .query_row( "SELECT settings_json FROM user_settings WHERE user_id = ?", [user_id], |r| r.get(0), ) .optional() .ok() .flatten() } pub fn update_user_settings(&self, user_id: &str, json: &str) -> DbResult<()> { self.lock().execute( "INSERT INTO user_settings (user_id, settings_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at", params![user_id, json, now_local_iso()], )?; Ok(()) } /// Réglage GPU NVIDIA, global au serveur : celui des derniers réglages /// enregistrés, tous utilisateurs confondus. pub fn is_nvidia_gpu_enabled(&self) -> bool { let settings: Option = self .lock() .query_row( "SELECT settings_json FROM user_settings ORDER BY updated_at DESC LIMIT 1", [], |r| r.get(0), ) .optional() .ok() .flatten(); settings.is_some_and(|json| GPU_SETTING.is_match(&json)) } // ==================== TV Recordings ==================== pub fn create_recording( &self, user_id: &str, channel_id: &str, stream_url: &str, title: &str, start: DateTime, end: DateTime, ) -> DbResult { let id = uuid::Uuid::new_v4().to_string(); let now = DartDateTime::now_local(); // Toujours stocker en UTC avec suffixe 'Z' : les comparaisons du // scheduler et la déduplication des season passes reposent sur ce // format unique. let start = DartDateTime::from_utc(start); let end = DartDateTime::from_utc(end); self.lock().execute( "INSERT INTO tv_recordings (id, user_id, channel_id, stream_url, title, start_time, end_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ id, user_id, channel_id, stream_url, title, start.to_iso(), end.to_iso(), now.to_iso(), now.to_iso() ], )?; Ok(Recording { id, user_id: user_id.into(), channel_id: channel_id.into(), stream_url: stream_url.into(), title: title.into(), start_time: start, end_time: end, status: "scheduled".into(), file_path: None, error_reason: None, source_rule_id: None, playlist_id: None, created_at: now, updated_at: now, }) } fn query_recordings(&self, sql: &str, args: &[&str]) -> Vec { let conn = self.lock(); let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new(); }; stmt.query_map(rusqlite::params_from_iter(args.iter()), recording_from_row) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } pub fn get_all_recordings(&self) -> Vec { self.query_recordings("SELECT * FROM tv_recordings ORDER BY start_time ASC", &[]) } /// Enregistrements qui intéressent le scheduler : à lancer ou en cours. pub fn get_pending_recordings(&self) -> Vec { self.query_recordings( "SELECT * FROM tv_recordings WHERE status IN ('scheduled', 'recording') ORDER BY start_time ASC", &[], ) } /// Enregistrements terminés, du plus ancien au plus récent (rotation disque). pub fn get_finished_recordings_oldest_first(&self) -> Vec { self.query_recordings( "SELECT * FROM tv_recordings WHERE status IN ('completed', 'failed', 'cancelled') ORDER BY start_time ASC", &[], ) } pub fn get_user_recordings(&self, user_id: &str) -> Vec { self.query_recordings( "SELECT * FROM tv_recordings WHERE user_id = ? ORDER BY start_time ASC", &[user_id], ) } pub fn get_recording_by_id(&self, id: &str) -> Option { self.query_recordings("SELECT * FROM tv_recordings WHERE id = ?", &[id]) .into_iter() .next() } /// Met à jour le statut et éventuellement le chemin d'un enregistrement. /// /// [clear_error] efface le motif d'erreur au lieu de conserver l'ancien : /// utile quand un enregistrement précédemment en échec est relancé. pub fn update_recording_status( &self, id: &str, status: &str, file_path: Option<&str>, error_reason: Option<&str>, clear_error: bool, ) { let error_expr = if clear_error { "?" } else { "COALESCE(?, error_reason)" }; let sql = format!( "UPDATE tv_recordings SET status = ?, file_path = COALESCE(?, file_path), error_reason = {error_expr}, updated_at = ? WHERE id = ?" ); if let Err(e) = self.lock().execute( &sql, params![status, file_path, error_reason, now_local_iso(), id], ) { println!("[DB] Mise à jour du statut de {id} impossible: {e}"); } } /// Réaligne la fenêtre d'un enregistrement planifié (programme déplacé /// dans l'EPG depuis sa création par un season pass). pub fn update_recording_window(&self, id: &str, start: DateTime, end: DateTime) { let _ = self.lock().execute( "UPDATE tv_recordings SET start_time = ?, end_time = ?, updated_at = ? WHERE id = ?", params![utc_iso(start), utc_iso(end), now_local_iso(), id], ); } /// Supprime l'enregistrement de la base (pas le fichier). pub fn delete_recording(&self, id: &str) { let _ = self .lock() .execute("DELETE FROM tv_recordings WHERE id = ?", [id]); } // ==================== Season Passes ==================== pub fn create_season_pass( &self, user_id: &str, show_title: &str, channel_id: &str, stream_url: &str, match_mode: &str, ) -> DbResult> { let id = uuid::Uuid::new_v4().to_string(); let now = now_local_iso(); self.lock().execute( "INSERT INTO season_passes (id, user_id, show_title, channel_id, stream_url, match_mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", params![id, user_id, show_title, channel_id, stream_url, match_mode, now], )?; let mut map = Map::new(); map.insert("id".into(), Value::from(id)); map.insert("user_id".into(), Value::from(user_id)); map.insert("show_title".into(), Value::from(show_title)); map.insert("channel_id".into(), Value::from(channel_id)); map.insert("stream_url".into(), Value::from(stream_url)); map.insert("match_mode".into(), Value::from(match_mode)); map.insert("enabled".into(), Value::from(1)); map.insert("created_at".into(), Value::from(now)); Ok(map) } fn query_json(&self, sql: &str, args: &[&str]) -> Vec> { let conn = self.lock(); let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new(); }; stmt.query_map(rusqlite::params_from_iter(args.iter()), row_to_json) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } /// Season Passes actifs (pour le scheduler). pub fn get_all_season_passes(&self) -> Vec> { self.query_json( "SELECT * FROM season_passes WHERE enabled = 1 ORDER BY created_at DESC", &[], ) } pub fn get_season_passes_for_user(&self, user_id: &str) -> Vec> { self.query_json( "SELECT * FROM season_passes WHERE user_id = ? ORDER BY created_at DESC", &[user_id], ) } pub fn get_season_pass_by_id(&self, id: &str) -> Option> { self.query_json("SELECT * FROM season_passes WHERE id = ?", &[id]) .into_iter() .next() } pub fn delete_season_pass(&self, id: &str) { let _ = self .lock() .execute("DELETE FROM season_passes WHERE id = ?", [id]); } /// Enregistrement existant pour cet épisode (déduplication des season /// passes) : même titre, début à ±[tolerance] près. /// /// Les dates sont stockées en ISO-8601 UTC : l'ordre lexicographique /// correspond à l'ordre chronologique, un BETWEEN sur chaînes suffit. pub fn find_recording_for_episode( &self, title: &str, start: DateTime, tolerance: Duration, ) -> Option { let low = utc_iso(start - tolerance); let high = utc_iso(start + tolerance); self.query_recordings( "SELECT * FROM tv_recordings WHERE LOWER(title) = LOWER(?) AND start_time BETWEEN ? AND ? AND status NOT IN ('failed', 'cancelled') LIMIT 1", &[title, &low, &high], ) .into_iter() .next() } /// Marque un enregistrement comme créé par une règle automatique. /// Rattache un enregistrement à l'IPTV qui le capture. pub fn set_recording_playlist(&self, id: &str, playlist_id: &str) { let _ = self.lock().execute( "UPDATE tv_recordings SET playlist_id = ? WHERE id = ?", params![playlist_id, id], ); } /// Rattache un season pass à son IPTV. pub fn set_season_pass_playlist(&self, id: &str, playlist_id: &str) { let _ = self.lock().execute( "UPDATE season_passes SET playlist_id = ? WHERE id = ?", params![playlist_id, id], ); } pub fn set_recording_source(&self, id: &str, rule_id: &str) { let _ = self.lock().execute( "UPDATE tv_recordings SET source_rule_id = ? WHERE id = ?", params![rule_id, id], ); } // ==================== Règles automatiques ==================== pub fn create_auto_rule(&self, rule: &AutoRule) -> DbResult { let mut rule = rule.clone(); rule.id = uuid::Uuid::new_v4().to_string(); let now = utc_iso(Utc::now()); rule.created_at = now.clone(); rule.updated_at = now; self.lock().execute( "INSERT INTO auto_rules (id, user_id, kind, enabled, default_decision, channel_ids, pad_before_min, pad_after_min, team_filter, keep_days, created_at, updated_at, playlist_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ rule.id, rule.user_id, rule.kind, rule.enabled as i64, rule.default_decision, serde_json::to_string(&rule.channel_ids).unwrap_or_else(|_| "[]".into()), rule.pad_before_min, rule.pad_after_min, serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()), rule.keep_days, rule.created_at, rule.updated_at, rule.playlist_id ], )?; Ok(rule) } /// Réécrit les réglages modifiables d'une règle (id et propriétaire fixes). pub fn update_auto_rule(&self, rule: &AutoRule) -> DbResult<()> { self.lock().execute( "UPDATE auto_rules SET enabled = ?, default_decision = ?, channel_ids = ?, pad_before_min = ?, pad_after_min = ?, team_filter = ?, keep_days = ?, updated_at = ? WHERE id = ?", params![ rule.enabled as i64, rule.default_decision, serde_json::to_string(&rule.channel_ids).unwrap_or_else(|_| "[]".into()), rule.pad_before_min, rule.pad_after_min, serde_json::to_string(&rule.team_filter).unwrap_or_else(|_| "[]".into()), rule.keep_days, utc_iso(Utc::now()), rule.id ], )?; Ok(()) } fn query_auto_rules(&self, sql: &str, args: &[&str]) -> Vec { let conn = self.lock(); let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new(); }; stmt.query_map(rusqlite::params_from_iter(args.iter()), auto_rule_from_row) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } /// Règles actives (pour le scheduler). pub fn get_enabled_auto_rules(&self) -> Vec { self.query_auto_rules( "SELECT * FROM auto_rules WHERE enabled = 1 ORDER BY created_at ASC", &[], ) } pub fn get_auto_rules_for_user(&self, user_id: &str) -> Vec { self.query_auto_rules( "SELECT * FROM auto_rules WHERE user_id = ? ORDER BY created_at ASC", &[user_id], ) } /// Règles d'un utilisateur pour une IPTV donnée. pub fn get_auto_rules_for_playlist(&self, user_id: &str, playlist_id: &str) -> Vec { self.query_auto_rules( "SELECT * FROM auto_rules WHERE user_id = ? AND playlist_id = ? ORDER BY created_at ASC", &[user_id, playlist_id], ) } pub fn get_auto_rule(&self, id: &str) -> Option { self.query_auto_rules("SELECT * FROM auto_rules WHERE id = ?", &[id]) .into_iter() .next() } /// Supprime la règle et ses matchs suivis (cascade). Les enregistrements /// déjà créés restent : ils appartiennent à l'utilisateur. pub fn delete_auto_rule(&self, id: &str) { let _ = self .lock() .execute("DELETE FROM auto_rules WHERE id = ?", [id]); } // ==================== Matchs NBA suivis ==================== /// Ajoute un match du calendrier, ou met à jour équipes et coup d'envoi /// d'un match déjà connu sans toucher à la décision de l'utilisateur ni /// à l'état de programmation. pub fn upsert_scheduled_game(&self, rule_id: &str, g: &ScheduledGame) -> DbResult<()> { self.lock().execute( "INSERT INTO nba_games (game_id, rule_id, home, away, home_id, away_id, home_tri, away_tri, tipoff_utc, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(game_id, rule_id) DO UPDATE SET home = excluded.home, away = excluded.away, home_id = excluded.home_id, away_id = excluded.away_id, home_tri = excluded.home_tri, away_tri = excluded.away_tri, tipoff_utc = excluded.tipoff_utc, updated_at = excluded.updated_at", params![ g.game_id, rule_id, g.home, g.away, g.home_id, g.away_id, g.home_tri, g.away_tri, utc_iso(g.tipoff_utc), utc_iso(Utc::now()) ], )?; Ok(()) } fn query_nba_games(&self, sql: &str, args: &[&str]) -> Vec { let conn = self.lock(); let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new(); }; stmt.query_map(rusqlite::params_from_iter(args.iter()), nba_game_from_row) .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } /// Matchs d'une règle dont le coup d'envoi tombe dans [from, to]. pub fn get_nba_games( &self, rule_id: &str, from: DateTime, to: DateTime, ) -> Vec { self.query_nba_games( "SELECT * FROM nba_games WHERE rule_id = ? AND tipoff_utc BETWEEN ? AND ? ORDER BY tipoff_utc ASC", &[rule_id, &utc_iso(from), &utc_iso(to)], ) } pub fn get_nba_game(&self, rule_id: &str, game_id: &str) -> Option { self.query_nba_games( "SELECT * FROM nba_games WHERE rule_id = ? AND game_id = ?", &[rule_id, game_id], ) .into_iter() .next() } pub fn get_nba_game_by_recording(&self, recording_id: &str) -> Option { self.query_nba_games( "SELECT * FROM nba_games WHERE recording_id = ? LIMIT 1", &[recording_id], ) .into_iter() .next() } /// Réécrit l'état de réconciliation d'un match (statut, chaîne, EPG, /// enregistrement lié). La décision a sa propre fonction. pub fn update_nba_game_state(&self, g: &NbaGame) -> DbResult<()> { self.lock().execute( "UPDATE nba_games SET status = ?, channel_id = ?, channel_name = ?, epg_title = ?, epg_start = ?, epg_end = ?, recording_id = ?, confidence = ?, conflict = ?, updated_at = ? WHERE rule_id = ? AND game_id = ?", params![ g.status, g.channel_id, g.channel_name, g.epg_title, g.epg_start, g.epg_end, g.recording_id, g.confidence, g.conflict as i64, utc_iso(Utc::now()), g.rule_id, g.game_id ], )?; Ok(()) } /// Oui / non de l'utilisateur sur un match : 'accepted', 'refused' ou /// 'auto' (revenir au comportement par défaut de la règle). pub fn set_nba_decision(&self, rule_id: &str, game_id: &str, decision: &str) -> bool { self.lock() .execute( "UPDATE nba_games SET decision = ?, updated_at = ? WHERE rule_id = ? AND game_id = ?", params![decision, utc_iso(Utc::now()), rule_id, game_id], ) .map(|n| n > 0) .unwrap_or(false) } } #[cfg(test)] mod tests { use super::*; fn temp_db() -> (AppDatabase, std::path::PathBuf) { let path = std::env::temp_dir().join(format!("xf-test-{}.db", uuid::Uuid::new_v4())); let db = AppDatabase::open(path.to_str().unwrap()).unwrap(); (db, path) } #[test] fn sessions_et_cascade() { let (db, _path) = temp_db(); let hash = password::hash("password1").unwrap(); let user = db.create_user("bob", &hash, false).unwrap(); let session = db.create_session(&user.id).unwrap(); assert!(db.find_session_by_token(&session.token).is_some()); assert!(db.verify_credentials("bob", "password1").is_some()); assert!(db.verify_credentials("bob", "nope").is_none()); // ON DELETE CASCADE actif : la session disparaît avec l'utilisateur. db.delete_user(&user.id).unwrap(); assert!(db.find_session_by_token(&session.token).is_none()); } #[test] fn migration_paresseuse_du_sha256() { let (db, _path) = temp_db(); let legacy = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; let user = db.create_user("old", legacy, false).unwrap(); assert!(db.verify_credentials("old", "admin").is_some()); let stored: String = db .lock() .query_row( "SELECT password_hash FROM users WHERE id = ?", [&user.id], |r| r.get(0), ) .unwrap(); assert!(!password::is_legacy(&stored)); assert!(db.verify_credentials("old", "admin").is_some()); } #[test] fn deduplication_des_episodes_a_deux_minutes_pres() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); let start = DartDateTime::parse("2026-08-11T18:00:00Z") .unwrap() .to_utc(); let end = start + Duration::hours(1); db.create_recording(&user.id, "1", "/api/live/1.ts", "Journal", start, end) .unwrap(); assert!( db.find_recording_for_episode( "JOURNAL", start + Duration::seconds(90), Duration::minutes(2) ) .is_some() ); assert!( db.find_recording_for_episode( "Journal", start + Duration::minutes(5), Duration::minutes(2) ) .is_none() ); let rec = db.get_all_recordings().pop().unwrap(); assert_eq!(rec.start_time.to_iso(), "2026-08-11T18:00:00.000Z"); assert_eq!(rec.status, "scheduled"); } #[test] fn statut_et_motif_d_erreur() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); let now = Utc::now(); let rec = db .create_recording(&user.id, "1", "/x", "T", now, now + Duration::hours(1)) .unwrap(); db.update_recording_status(&rec.id, "failed", None, Some("boom"), false); db.update_recording_status(&rec.id, "failed", Some("/f.mkv"), None, false); let r = db.get_recording_by_id(&rec.id).unwrap(); assert_eq!(r.error_reason.as_deref(), Some("boom")); assert_eq!(r.file_path.as_deref(), Some("/f.mkv")); db.update_recording_status(&rec.id, "recording", None, None, true); let r = db.get_recording_by_id(&rec.id).unwrap(); assert_eq!(r.error_reason, None); assert_eq!(r.file_path.as_deref(), Some("/f.mkv")); } #[test] fn reglage_gpu() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); assert!(!db.is_nvidia_gpu_enabled()); db.update_user_settings(&user.id, r#"{"enable_nvidia_gpu": true}"#) .unwrap(); assert!(db.is_nvidia_gpu_enabled()); } fn nba_rule(db: &AppDatabase, user_id: &str) -> AutoRule { db.create_auto_rule(&AutoRule { id: String::new(), user_id: user_id.into(), kind: "nba".into(), enabled: true, default_decision: "auto".into(), channel_ids: vec!["101".into(), "102".into()], pad_before_min: 5, pad_after_min: 45, team_filter: vec![], keep_days: None, playlist_id: None, created_at: String::new(), updated_at: String::new(), }) .unwrap() } fn game(id: &str, tipoff: &str) -> ScheduledGame { ScheduledGame { game_id: id.into(), home: "Los Angeles Lakers".into(), away: "Boston Celtics".into(), home_id: "1610612747".into(), away_id: "1610612738".into(), home_tri: "LAL".into(), away_tri: "BOS".into(), tipoff_utc: DartDateTime::parse(tipoff).unwrap().to_utc(), } } #[test] fn regle_auto_aller_retour() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); let mut rule = nba_rule(&db, &user.id); assert_eq!(db.get_enabled_auto_rules().len(), 1); // Une seule règle NBA par utilisateur, garantie par la base. let dup = db.create_auto_rule(&rule).unwrap_err(); assert!(is_unique_violation(&dup), "{dup:?}"); rule.enabled = false; rule.team_filter = vec!["Lakers".into()]; db.update_auto_rule(&rule).unwrap(); let back = db.get_auto_rule(&rule.id).unwrap(); assert!(!back.enabled); assert_eq!(back.team_filter, vec!["Lakers".to_string()]); assert_eq!(back.channel_ids, vec!["101".to_string(), "102".to_string()]); assert!(db.get_enabled_auto_rules().is_empty()); } #[test] fn upsert_du_calendrier_preserve_la_decision() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); let rule = nba_rule(&db, &user.id); db.upsert_scheduled_game(&rule.id, &game("0022600001", "2026-10-21T23:30:00Z")) .unwrap(); assert!(db.set_nba_decision(&rule.id, "0022600001", "refused")); // Le calendrier décale le match d'une heure : la décision reste. db.upsert_scheduled_game(&rule.id, &game("0022600001", "2026-10-22T00:30:00Z")) .unwrap(); let g = db.get_nba_game(&rule.id, "0022600001").unwrap(); assert_eq!(g.decision, "refused"); assert_eq!(g.status, "provisional"); assert_eq!(g.tipoff_utc, "2026-10-22T00:30:00.000Z"); let from = DartDateTime::parse("2026-10-21T00:00:00Z") .unwrap() .to_utc(); assert_eq!( db.get_nba_games(&rule.id, from, from + Duration::days(2)) .len(), 1 ); // Supprimer la règle supprime ses matchs suivis. db.delete_auto_rule(&rule.id); assert!(db.get_nba_game(&rule.id, "0022600001").is_none()); } #[test] fn etat_de_match_et_lien_enregistrement() { let (db, _path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); let rule = nba_rule(&db, &user.id); db.upsert_scheduled_game(&rule.id, &game("g1", "2026-10-21T23:30:00Z")) .unwrap(); let now = Utc::now(); let rec = db .create_recording( &user.id, "101", "/api/live/101.ts", "NBA", now, now + Duration::hours(3), ) .unwrap(); db.set_recording_source(&rec.id, &rule.id); let mut g = db.get_nba_game(&rule.id, "g1").unwrap(); g.status = "scheduled".into(); g.recording_id = Some(rec.id.clone()); g.channel_name = Some("beIN SPORTS 1".into()); g.conflict = true; db.update_nba_game_state(&g).unwrap(); let back = db.get_nba_game_by_recording(&rec.id).unwrap(); assert_eq!(back.status, "scheduled"); assert!(back.conflict); assert_eq!( db.get_recording_by_id(&rec.id) .unwrap() .source_rule_id .as_deref(), Some(rule.id.as_str()) ); } #[test] fn une_regle_nba_par_iptv_et_rattachement_des_anciennes_lignes() { let (db, path) = temp_db(); let user = db.create_user("u", "x", false).unwrap(); // Ligne « ancienne » : règle sans playlist, créée avant les playlists. let legacy = nba_rule(&db, &user.id); let a = db .create_playlist(&user.id, "A", "http://a", "u", "p", None) .unwrap(); // Au redémarrage, l'ancienne règle est rattachée à la playlist A. drop(db); let db = AppDatabase::open(path.to_str().unwrap()).unwrap(); assert_eq!( db.get_auto_rule(&legacy.id).unwrap().playlist_id.as_deref(), Some(a.id.as_str()) ); let b = db .create_playlist(&user.id, "B", "http://b", "u", "p", None) .unwrap(); // Une seconde règle NBA est permise sur l'autre IPTV… let mut on_b = legacy.clone(); on_b.playlist_id = Some(b.id.clone()); db.create_auto_rule(&on_b).unwrap(); assert_eq!(db.get_auto_rules_for_playlist(&user.id, &b.id).len(), 1); assert_eq!(db.get_auto_rules_for_playlist(&user.id, &a.id).len(), 1); // …mais pas deux sur la même. assert!(is_unique_violation( &db.create_auto_rule(&on_b).unwrap_err() )); } #[test] fn mot_de_passe_genere() { let p = generate_password(16); assert_eq!(p.len(), 16); assert!(!p.contains(['0', 'O', '1', 'l', 'I'])); } }