mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-24 07:28:05 +02:00
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:
1 parent
f291b66087
commit
5a15311f98
3 files changed
+123
No files matched your search
@@ -181,6 +181,11 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp {
|
|||||||
}
|
}
|
||||||
let rule = match state.db.create_auto_rule(&rule) {
|
let rule = match state.db.create_auto_rule(&rule) {
|
||||||
Ok(r) => r,
|
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}")})),
|
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).
|
// Calendrier et première réconciliation en tâche de fond (≈ 20 requêtes).
|
||||||
|
|||||||
@@ -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> {
|
fn json_list(raw: Option<String>) -> Vec<String> {
|
||||||
raw.and_then(|r| serde_json::from_str(&r).ok())
|
raw.and_then(|r| serde_json::from_str(&r).ok())
|
||||||
.unwrap_or_default()
|
.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_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_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_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);
|
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 user = db.create_user("u", "x", false).unwrap();
|
||||||
let mut rule = nba_rule(&db, &user.id);
|
let mut rule = nba_rule(&db, &user.id);
|
||||||
assert_eq!(db.get_enabled_auto_rules().len(), 1);
|
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.enabled = false;
|
||||||
rule.team_filter = vec!["Lakers".into()];
|
rule.team_filter = vec!["Lakers".into()];
|
||||||
db.update_auto_rule(&rule).unwrap();
|
db.update_auto_rule(&rule).unwrap();
|
||||||
|
|||||||
@@ -342,6 +342,42 @@ fn overlapping(recordings: &[Recording], start: DateTime<Utc>, end: DateTime<Utc
|
|||||||
.count()
|
.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.
|
/// Service : calendrier quotidien + réconciliation toutes les 30 minutes.
|
||||||
pub struct NbaAutoRecorder {
|
pub struct NbaAutoRecorder {
|
||||||
db: Arc<AppDatabase>,
|
db: Arc<AppDatabase>,
|
||||||
@@ -495,6 +531,16 @@ impl NbaAutoRecorder {
|
|||||||
/// Réconcilie calendrier, EPG et enregistrements d'une règle.
|
/// Réconcilie calendrier, EPG et enregistrements d'une règle.
|
||||||
pub async fn reconcile(&self, rule: &AutoRule) {
|
pub async fn reconcile(&self, rule: &AutoRule) {
|
||||||
let _guard = self.running.lock().await;
|
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 now = Utc::now();
|
||||||
let games = self.db.get_nba_games(
|
let games = self.db.get_nba_games(
|
||||||
&rule.id,
|
&rule.id,
|
||||||
@@ -622,6 +668,65 @@ impl NbaAutoRecorder {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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> {
|
fn t(s: &str) -> DateTime<Utc> {
|
||||||
DartDateTime::parse(s).unwrap().to_utc()
|
DartDateTime::parse(s).unwrap().to_utc()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user