Auto NBA : règle en pause sans enregistrement, une règle par utilisateur

- 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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-18 12:35:05 +02:00
1 parent f291b66087
commit 5a15311f98
3 files changed
+123

No files matched your search

+5
View File
@@ -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).
+13
View File
@@ -72,6 +72,15 @@ fn recording_from_row(row: &Row) -> DbResult<Recording> {
})
}
/// 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()
@@ -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();
+105
View File
@@ -342,6 +342,42 @@ fn overlapping(recordings: &[Recording], start: DateTime<Utc>, end: DateTime<Utc
.count()
}
/// Règle en pause : supprime ses enregistrements encore planifiés (le
/// planificateur les capturerait sinon) et remet les matchs à l'état
/// « confirmé » ou « prévu ». Un enregistrement déjà en cours n'est pas coupé.
pub fn suspend_rule(db: &AppDatabase, rule: &AutoRule) {
let now = Utc::now();
let games = db.get_nba_games(
&rule.id,
now - Duration::hours(6),
now + Duration::days(SCHEDULE_DAYS * 3),
);
for game in games {
let Some(rec_id) = game.recording_id.as_deref() else {
continue;
};
let scheduled = db
.get_recording_by_id(rec_id)
.is_none_or(|r| r.status == "scheduled");
if !scheduled {
continue;
}
db.delete_recording(rec_id);
let mut next = game.clone();
next.recording_id = None;
next.conflict = false;
next.status = if game.channel_id.is_some() {
"confirmed"
} else {
"provisional"
}
.into();
if let Err(e) = db.update_nba_game_state(&next) {
println!("[NBA] Match {} non remis en attente : {e}", game.game_id);
}
}
}
/// Service : calendrier quotidien + réconciliation toutes les 30 minutes.
pub struct NbaAutoRecorder {
db: Arc<AppDatabase>,
@@ -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<Utc> {
DartDateTime::parse(s).unwrap().to_utc()
}