From 5a15311f98391c512e62ac6d175b1281bd3bf2a8 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 18 Oct 2026 12:35:05 +0200 Subject: [PATCH] =?UTF-8?q?Auto=20NBA=20:=20r=C3=A8gle=20en=20pause=20sans?= =?UTF-8?q?=20enregistrement,=20une=20r=C3=A8gle=20par=20utilisateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Une règle désactivée ne programme plus rien : la réconciliation relit l'état en base et, en pause, supprime les enregistrements encore planifiés (un enregistrement en cours n'est pas coupé). - Index unique (user_id, kind) : deux activations concurrentes ne créent plus deux règles NBA ; le conflit répond 409. Co-Authored-By: Claude Opus 5.5 --- server/src/api/auto_rules.rs | 5 ++ server/src/db.rs | 13 ++++ server/src/services/nba_autorec.rs | 105 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs index 7436d5e..431a7ec 100644 --- a/server/src/api/auto_rules.rs +++ b/server/src/api/auto_rules.rs @@ -181,6 +181,11 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp { } let rule = match state.db.create_auto_rule(&rule) { Ok(r) => r, + // Activation concurrente (deux onglets, relance) : l'index unique + // (user_id, kind) a tranché. + Err(e) if crate::db::is_unique_violation(&e) => { + return json(409, &json!({"error": "La règle NBA existe déjà"})); + } Err(e) => return json(500, &json!({"error": format!("Création impossible : {e}")})), }; // Calendrier et première réconciliation en tâche de fond (≈ 20 requêtes). diff --git a/server/src/db.rs b/server/src/db.rs index d0c6ca0..5f940d7 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -72,6 +72,15 @@ fn recording_from_row(row: &Row) -> DbResult { }) } +/// 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() @@ -300,6 +309,7 @@ impl AppDatabase { 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 UNIQUE INDEX IF NOT EXISTS idx_auto_rules_user_kind ON auto_rules(user_id, kind); CREATE INDEX IF NOT EXISTS idx_nba_games_tipoff ON nba_games(rule_id, tipoff_utc); "#, ) @@ -1238,6 +1248,9 @@ mod tests { 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(); diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs index 095bd74..49d5b30 100644 --- a/server/src/services/nba_autorec.rs +++ b/server/src/services/nba_autorec.rs @@ -342,6 +342,42 @@ fn overlapping(recordings: &[Recording], start: DateTime, end: DateTime, @@ -495,6 +531,16 @@ impl NbaAutoRecorder { /// Réconcilie calendrier, EPG et enregistrements d'une règle. pub async fn reconcile(&self, rule: &AutoRule) { let _guard = self.running.lock().await; + // État relu en base : l'appelant peut tenir une copie antérieure à + // une mise en pause. + let Some(rule) = self.db.get_auto_rule(&rule.id) else { + return; + }; + let rule = &rule; + if !rule.enabled { + suspend_rule(&self.db, rule); + return; + } let now = Utc::now(); let games = self.db.get_nba_games( &rule.id, @@ -622,6 +668,65 @@ impl NbaAutoRecorder { mod tests { use super::*; + #[test] + fn mise_en_pause_supprime_les_enregistrements_planifies() { + let path = std::env::temp_dir().join(format!("xf-nba-{}.db", uuid::Uuid::new_v4())); + let db = AppDatabase::open(path.to_str().unwrap()).unwrap(); + let user = db.create_user("u", "x", false).unwrap(); + let mut r = rule(); + r.user_id = user.id.clone(); + let r = db.create_auto_rule(&r).unwrap(); + let soon = Utc::now() + Duration::hours(10); + for id in ["planned", "live"] { + db.upsert_scheduled_game( + &r.id, + &crate::models::ScheduledGame { + game_id: id.into(), + home: "Los Angeles Lakers".into(), + away: "Boston Celtics".into(), + home_id: "13".into(), + away_id: "2".into(), + home_tri: "LAL".into(), + away_tri: "BOS".into(), + tipoff_utc: soon, + }, + ) + .unwrap(); + let rec = db + .create_recording( + &user.id, + "1", + "/api/live/1.ts", + id, + soon, + soon + Duration::hours(3), + ) + .unwrap(); + if id == "live" { + db.update_recording_status(&rec.id, "recording", None, None, false); + } + let mut g = db.get_nba_game(&r.id, id).unwrap(); + g.recording_id = Some(rec.id); + g.channel_id = Some("1".into()); + g.status = "scheduled".into(); + db.update_nba_game_state(&g).unwrap(); + } + + suspend_rule(&db, &r); + + let planned = db.get_nba_game(&r.id, "planned").unwrap(); + assert_eq!(planned.recording_id, None); + assert_eq!(planned.status, "confirmed"); + // L'enregistrement en cours reste, lié à son match. + let live = db.get_nba_game(&r.id, "live").unwrap(); + let live_rec = db + .get_recording_by_id(live.recording_id.as_deref().unwrap()) + .unwrap(); + assert_eq!(live_rec.status, "recording"); + assert_eq!(db.get_pending_recordings().len(), 1); + let _ = std::fs::remove_file(path); + } + fn t(s: &str) -> DateTime { DartDateTime::parse(s).unwrap().to_utc() }