mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 05:28:05 +02:00
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>
1423 lines
53 KiB
Rust
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']));
|
|
}
|
|
}
|