Files
xtremflow/server/src/db.rs
T
MichaelandClaude Opus 5.5 4f978755fd Multi-IPTV : chaque fonction utilise la playlist sélectionnée
Le serveur ignorait l'IPTV choisie : direct, VOD, guide, enregistrements,
season passes et Auto NBA passaient tous par la playlist la plus récente
(ou celle du premier utilisateur pour la capture FFmpeg), et les
enregistrements s'affichaient sous toutes les playlists.

- get_playlist respecte ?playlist=<id> (prioritaire) puis l'en-tête
  X-Playlist-Id, avec contrôle du propriétaire (admin : toutes).
- Sessions FFmpeg HLS/VOD séparées par IPTV (même stream_id possible) ;
  le m3u8 propage ?playlist= aux segments, redirections et bascule
  .ts → HLS du lecteur web aussi.
- tv_recordings, season_passes, auto_rules : colonne playlist_id
  (migrations v4-v6) ; lignes existantes rattachées à la playlist la
  plus récente de leur propriétaire ; URL de capture avec ?playlist= ;
  listes filtrées par IPTV ; une règle NBA par IPTV.
- Proxy /api/xtream : cache d'allowlist par playlist, paramètre retiré
  avant l'appel au panneau ; CORS autorise X-Playlist-Id.
- Client : AuthedHttp envoie X-Playlist-Id ; XtreamService ajoute
  ?playlist= aux flux, à l'EPG et aux appels xtream-api (cache distinct).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:53:45 +02:00

1423 lines
53 KiB
Rust

//! 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<Connection>,
}
type DbResult<T> = Result<T, rusqlite::Error>;
fn parse_date(raw: Option<String>) -> DartDateTime {
raw.as_deref()
.and_then(DartDateTime::parse)
.unwrap_or_else(DartDateTime::now_local)
}
fn user_from_row(row: &Row) -> DbResult<User> {
Ok(User {
id: row.get("id")?,
username: row.get("username")?,
is_admin: row.get::<_, Option<i64>>("is_admin")?.unwrap_or(0) == 1,
created_at: parse_date(row.get("created_at")?),
})
}
fn playlist_from_row(row: &Row) -> DbResult<Playlist> {
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<Recording> {
let text = |name: &str| -> DbResult<String> {
Ok(row.get::<_, Option<String>>(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<String>>("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<String>) -> Vec<String> {
raw.and_then(|r| serde_json::from_str(&r).ok())
.unwrap_or_default()
}
fn auto_rule_from_row(row: &Row) -> DbResult<AutoRule> {
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<String>>("created_at")?
.unwrap_or_default(),
updated_at: row
.get::<_, Option<String>>("updated_at")?
.unwrap_or_default(),
})
}
fn nba_game_from_row(row: &Row) -> DbResult<NbaGame> {
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<String>>("updated_at")?
.unwrap_or_default(),
})
}
/// Ligne brute → objet JSON (colonnes dans l'ordre de la table).
fn row_to_json(row: &Row) -> DbResult<Map<String, Value>> {
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<Regex> =
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<Self> {
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<User> {
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<User> {
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<User> {
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<User> {
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<User> {
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<Session> {
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<Session> {
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<Playlist> {
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<Playlist> {
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<Playlist> {
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<Option<Playlist>> {
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<String> {
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<String> = 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<Utc>,
end: DateTime<Utc>,
) -> DbResult<Recording> {
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<Recording> {
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<Recording> {
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<Recording> {
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<Recording> {
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<Recording> {
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<Recording> {
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<Utc>, end: DateTime<Utc>) {
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<Map<String, Value>> {
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<Map<String, Value>> {
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<Map<String, Value>> {
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<Map<String, Value>> {
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<Map<String, Value>> {
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<Utc>,
tolerance: Duration,
) -> Option<Recording> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<AutoRule> {
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<NbaGame> {
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<Utc>,
to: DateTime<Utc>,
) -> Vec<NbaGame> {
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<NbaGame> {
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<NbaGame> {
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']));
}
}