From 5f0f6abcc6acaf28e6ac0a4e776bee8eeb0616e2 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 18 Oct 2026 16:33:08 +0200 Subject: [PATCH] =?UTF-8?q?Auto=20NBA=20:=20guide=20officiel=20beIN,=20cho?= =?UTF-8?q?ix=20manuel,=20un=20seul=20enregistrement=20par=20cr=C3=A9neau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Programme officiel beIN Sports France (api/opta, ~8 jours) comme source : chaîne exacte et direct/rediffusion explicites, recoupés avec le coup d'envoi ESPN (un faux « direct » du guide est écarté). Repli sur l'EPG IPTV si le site est injoignable. Seuls les matchs annoncés en direct sur beIN sont affichés. - Plus aucun enregistrement automatique : seul un « Enregistrer » explicite programme un match ; les programmations sans choix sont annulées. - Un seul flux IPTV : un match qui chevauche un match choisi ou un autre enregistrement est refusé (409) et affiché « Créneau pris » ; la liste est relue après chaque choix et les boutons sont figés pendant la requête. - Interrupteurs lisibles (curseur et piste distincts), boutons Enregistrer / Choisi à la place des interrupteurs par match. Co-Authored-By: Claude Opus 5.5 --- lib/features/iptv/widgets/recordings_tab.dart | 184 +++++---- server/src/api/auto_rules.rs | 67 +++- server/src/main.rs | 7 +- server/src/services/bein_guide.rs | 166 ++++++++ server/src/services/mod.rs | 1 + server/src/services/nba_autorec.rs | 371 +++++++++++++++--- 6 files changed, 621 insertions(+), 175 deletions(-) create mode 100644 server/src/services/bein_guide.rs diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index f278962..2358688 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -1875,24 +1875,33 @@ class _AutoNbaViewState extends State<_AutoNbaView> { Future _decide(Map game, bool yes) async { final id = _rule?['id']; - if (id == null) return; - final r = await AuthedHttp.post( - Uri.parse('/api/auto-rules/$id/games/${game['game_id']}/decision'), - headers: {'Content-Type': 'application/json'}, - body: json.encode({'decision': yes ? 'accepted' : 'refused'}), - ); - if (!mounted) return; - if (r.statusCode == 200) { - final updated = json.decode(r.body); - setState(() { - _games = [ - for (final g in _games) - g['game_id'] == updated['game_id'] ? updated : g, - ]; - }); - notifyRecordingsChanged(); - } else { - _snack('❌ ${r.body}'); + if (id == null || _busy) return; + // Tous les boutons sont figés pendant la requête : pas de double clic ni + // de second choix sur le même créneau avant la réponse. + setState(() => _busy = true); + try { + final r = await AuthedHttp.post( + Uri.parse('/api/auto-rules/$id/games/${game['game_id']}/decision'), + headers: {'Content-Type': 'application/json'}, + // Retirer son choix remet le match en attente : rien n'est + // enregistré sans un oui explicite. + body: json.encode({'decision': yes ? 'accepted' : 'auto'}), + ); + if (!mounted) return; + if (r.statusCode == 200) { + notifyRecordingsChanged(); + } else { + String message = r.body; + try { + message = json.decode(r.body)['error'] ?? r.body; + } catch (_) {} + _snack('❌ $message'); + } + // Relire toute la liste : le serveur recalcule « Créneau pris » pour + // les autres matchs du créneau, qui deviennent inactifs. + await _load(); + } finally { + if (mounted) setState(() => _busy = false); } } @@ -1913,14 +1922,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> { final selected = { for (final c in (rule['channel_ids'] as List)) c.toString(), }; - var askFirst = rule['default_decision'] == 'ask'; final beforeCtrl = TextEditingController(text: '${rule['pad_before_min'] ?? 5}'); final afterCtrl = TextEditingController(text: '${rule['pad_after_min'] ?? 45}'); - final teamsCtrl = TextEditingController( - text: (rule['team_filter'] as List).join(', '), - ); final keepCtrl = TextEditingController( text: rule['keep_days'] == null ? '' : '${rule['keep_days']}', ); @@ -1951,7 +1956,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> { ), ), value: selected.contains(c['stream_id'].toString()), - activeThumbColor: AppColors.primaryContainer, + activeThumbColor: AppColors.primary, + trackColor: _switchTrack, onChanged: (v) => setDialog(() { final id = c['stream_id'].toString(); v ? selected.add(id) : selected.remove(id); @@ -1976,29 +1982,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text( - 'Demander avant chaque match', - style: GoogleFonts.fraunces( - color: AppColors.onSurface, - fontSize: 14, - ), - ), - subtitle: Text( - askFirst - ? "Rien n'est enregistré sans votre oui" - : 'Tout est enregistré, vous pouvez refuser', - style: const TextStyle( - color: AppColors.onSurface54, - fontSize: 12, - ), - ), - value: askFirst, - activeThumbColor: AppColors.primaryContainer, - onChanged: (v) => setDialog(() => askFirst = v), - ), - const SizedBox(height: 8), Row( children: [ Expanded( @@ -2019,11 +2002,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> { ], ), const SizedBox(height: 8), - _underlineField( - teamsCtrl, - 'Équipes suivies (vide = toutes, ex: Lakers, Celtics)', - ), - const SizedBox(height: 8), _underlineField( keepCtrl, 'Garder les matchs (jours, vide = toujours)', @@ -2079,16 +2057,10 @@ class _AutoNbaViewState extends State<_AutoNbaView> { Navigator.pop(ctx); final keep = int.tryParse(keepCtrl.text.trim()); _updateRule({ - 'default_decision': askFirst ? 'ask' : 'auto', 'channel_ids': selected.toList(), 'pad_before_min': int.tryParse(beforeCtrl.text.trim()) ?? 5, 'pad_after_min': int.tryParse(afterCtrl.text.trim()) ?? 45, - 'team_filter': teamsCtrl.text - .split(',') - .map((t) => t.trim()) - .where((t) => t.isNotEmpty) - .toList(), 'keep_days': (keep != null && keep > 0) ? keep : null, }); }, @@ -2127,10 +2099,6 @@ class _AutoNbaViewState extends State<_AutoNbaView> { (String, Color) _status(Map g) { if (g['conflict'] == true) return ('Conflit', AppColors.warning); - if (g['decision'] == 'refused' && - !['recorded', 'missed'].contains(g['status'])) { - return ('Refusé', AppColors.onSurface38); - } return switch (g['status']) { 'scheduled' => ('Programmé', AppColors.primaryContainer), 'confirmed' => ('Confirmé', AppColors.tertiary), @@ -2150,6 +2118,53 @@ class _AutoNbaViewState extends State<_AutoNbaView> { String _hhmm(DateTime d) => '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + /// Piste lisible quel que soit l'état (même rendu que l'interrupteur de + /// thème des Réglages) : sans elle, curseur et piste orange se confondent. + static final _switchTrack = WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.primary.withOpacity(0.5) + : AppColors.surfaceContainerHigh, + ); + + /// Choix explicite par match : rien n'est enregistré sans ce bouton. + Widget _choiceButton(Map g, bool locked) { + const compact = VisualDensity.compact; + if (g['wanted'] == true) { + return Tooltip( + message: locked ? 'Choisi' : 'Cliquer pour ne plus enregistrer', + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.secondaryContainer, + foregroundColor: AppColors.onSecondaryContainer, + visualDensity: compact, + ), + icon: const Icon(Icons.check, size: 16), + label: const Text('Choisi'), + onPressed: (locked || _busy) ? null : () => _decide(g, false), + ), + ); + } + final taken = g['slot_taken_by'] as String?; + if (taken != null) { + return Tooltip( + message: 'Créneau déjà pris par « $taken » : un seul enregistrement ' + 'à la fois', + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(visualDensity: compact), + icon: const Icon(Icons.block, size: 16), + label: const Text('Créneau pris'), + onPressed: null, + ), + ); + } + return OutlinedButton.icon( + style: OutlinedButton.styleFrom(visualDensity: compact), + icon: const Icon(Icons.fiber_manual_record, size: 16), + label: const Text('Enregistrer'), + onPressed: (locked || _busy) ? null : () => _decide(g, true), + ); + } + Widget _pill(String label, Color color) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -2170,10 +2185,12 @@ class _AutoNbaViewState extends State<_AutoNbaView> { Widget _gameCard(Map g) { final tipoff = DateTime.parse(g['tipoff_utc']).toLocal(); final (label, color) = _status(g); + final channel = g['channel_name'] as String?; + // Pas annoncé en direct sur beIN : rien à enregistrer. final locked = ['recorded', 'missed', 'not_on_bein'].contains(g['status']) || - tipoff.isBefore(DateTime.now()); - final channel = g['channel_name'] as String?; + tipoff.isBefore(DateTime.now()) || + (channel == null && g['wanted'] != true); return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( @@ -2212,7 +2229,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_hhmm(tipoff)} · ${channel ?? 'chaîne confirmée ~48 h avant'}', + '${_hhmm(tipoff)} · ${channel ?? 'pas annoncé sur beIN'}', style: const TextStyle( color: AppColors.onSurface54, fontSize: 12, @@ -2233,16 +2250,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> { children: [ _pill(label, color), const SizedBox(width: 8), - Tooltip( - message: g['wanted'] == true - ? 'Sera enregistré — désactiver pour refuser' - : 'Ne sera pas enregistré — activer pour accepter', - child: Switch( - value: g['wanted'] == true, - activeThumbColor: AppColors.primaryContainer, - onChanged: locked ? null : (v) => _decide(g, v), - ), - ), + _choiceButton(g, locked), ], ), ), @@ -2308,7 +2316,7 @@ class _AutoNbaViewState extends State<_AutoNbaView> { return _emptyState( Icons.sports_basketball, 'Auto NBA désactivé', - 'Enregistre chaque match NBA diffusé en direct sur beIN Sports FR, ' + 'Choisissez les matchs NBA à enregistrer en direct sur beIN Sports FR, ' 'sans les rediffusions.', action: ElevatedButton.icon( style: ElevatedButton.styleFrom( @@ -2322,13 +2330,20 @@ class _AutoNbaViewState extends State<_AutoNbaView> { ); } final visible = _games - .where((g) => _showOffBein || g['status'] != 'not_on_bein') + // Par défaut, seuls les matchs annoncés en direct sur une beIN FR + // (ou déjà choisis) : le reste du calendrier NBA n'est pas diffusé. + .where( + (g) => + _showOffBein || + g['channel_name'] != null || + g['wanted'] == true, + ) .toList(); if (visible.isEmpty) { return _emptyState( Icons.event_busy, - 'Aucun match à venir', - 'Le calendrier NBA est rechargé toutes les 12 h', + 'Aucun match NBA annoncé sur beIN Sports', + 'Le programme beIN couvre environ 8 jours et est relu toutes les 30 min', ); } // Liste aplatie : intertitre de nuit puis ses matchs. @@ -2388,7 +2403,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> { : 'Auto NBA en pause', child: Switch( value: rule['enabled'] == true, - activeThumbColor: AppColors.primaryContainer, + activeThumbColor: AppColors.primary, + trackColor: _switchTrack, onChanged: (v) => _updateRule({'enabled': v}), ), ), @@ -2439,10 +2455,12 @@ class _AutoNbaViewState extends State<_AutoNbaView> { const SizedBox(width: 12), Expanded( child: Text( - 'Seule la diffusion qui démarre avec le match est ' - 'enregistrée : les rediffusions sont ignorées. La chaîne ' - 'est confirmée par le guide TV environ 48 h avant ; vous ' - 'pouvez refuser un match à tout moment avant son début.', + 'Matchs NBA diffusés en direct sur beIN Sports FR, ' + "d'après le programme officiel beIN (~8 jours). Rien n'est " + 'enregistré sans votre choix : cliquez sur « Enregistrer ». ' + 'Les rediffusions sont ignorées. Un seul ' + "enregistrement à la fois : le flux IPTV ne permet qu'une " + 'lecture.', style: GoogleFonts.fraunces( color: AppColors.secondaryFixed, fontSize: 12, diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs index 431a7ec..24fbe70 100644 --- a/server/src/api/auto_rules.rs +++ b/server/src/api/auto_rules.rs @@ -14,7 +14,7 @@ use crate::http::{self, Req, Resp, json, segments}; use crate::models::{AutoRule, NbaGame, User}; use crate::services::recording_scheduler::recordings_dir_path; -use crate::services::{bein_channels, nba_artwork}; +use crate::services::{bein_channels, nba_artwork, nba_autorec}; use crate::state::SharedState; use chrono::{Duration, Utc}; use serde_json::{Map, Value, json}; @@ -157,7 +157,8 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp { user_id: user.id.clone(), kind: "nba".into(), enabled: true, - default_decision: "auto".into(), + // Plus de programmation automatique : seul un oui enregistre. + default_decision: "ask".into(), channel_ids: Vec::new(), pad_before_min: 5, pad_after_min: 45, @@ -224,7 +225,7 @@ async fn update(state: &SharedState, req: &Req, mut rule: AutoRule) -> Resp { json(200, &json!(rule)) } -fn game_json(rule: &AutoRule, g: &NbaGame) -> Value { +fn game_json(rule: &AutoRule, g: &NbaGame, slot_taken_by: Option) -> Value { let rule_id = &rule.id; let mut v = json!(g); if let Value::Object(map) = &mut v { @@ -235,15 +236,11 @@ fn game_json(rule: &AutoRule, g: &NbaGame) -> Value { g.game_id )), ); - // Oui / non effectif (décision, mode de la règle, filtre d'équipes). - map.insert( - "wanted".into(), - json!(crate::services::nba_autorec::wants(rule, g)), - ); - map.insert( - "title".into(), - json!(crate::services::nba_autorec::recording_title(g)), - ); + // Choisi par l'utilisateur : seul un oui explicite enregistre. + map.insert("wanted".into(), json!(nba_autorec::wants(g))); + // Un seul flux IPTV : ce qui occupe déjà le créneau, s'il est pris. + map.insert("slot_taken_by".into(), json!(slot_taken_by)); + map.insert("title".into(), json!(nba_autorec::recording_title(g))); } v } @@ -255,15 +252,20 @@ fn games(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp { .unwrap_or(21) .clamp(1, 60); let now = Utc::now(); - let list: Vec = state - .db - .get_nba_games( - &rule.id, - now - Duration::hours(6), - now + Duration::days(days), - ) + let all = state.db.get_nba_games( + &rule.id, + now - Duration::hours(6), + now + Duration::days(days), + ); + let pending = state.db.get_pending_recordings(); + let list: Vec = all .iter() - .map(|g| game_json(rule, g)) + .map(|g| { + let taken = (!nba_autorec::wants(g)) + .then(|| nba_autorec::slot_taken_by(rule, g, &all, &pending)) + .flatten(); + game_json(rule, g, taken) + }) .collect(); json(200, &json!({"rule": rule, "games": list})) } @@ -282,6 +284,29 @@ async fn decision(state: &SharedState, req: &Req, rule: &AutoRule, game_id: &str ); } }; + // Un seul flux IPTV à la fois : refuser un oui sur un créneau occupé. + if decision == "accepted" { + let Some(game) = state.db.get_nba_game(&rule.id, game_id) else { + return json(404, &json!({"error": "Match non trouvé"})); + }; + let now = Utc::now(); + let all = + state + .db + .get_nba_games(&rule.id, now - Duration::hours(6), now + Duration::days(60)); + let pending = state.db.get_pending_recordings(); + if let Some(taken) = nba_autorec::slot_taken_by(rule, &game, &all, &pending) { + return json( + 409, + &json!({ + "error": format!( + "Créneau déjà pris par « {taken} » : un seul enregistrement à la fois" + ), + "slot_taken_by": taken, + }), + ); + } + } if !state.db.set_nba_decision(&rule.id, game_id, decision) { return json(404, &json!({"error": "Match non trouvé"})); } @@ -293,7 +318,7 @@ async fn decision(state: &SharedState, req: &Req, rule: &AutoRule, game_id: &str ) .await; match state.db.get_nba_game(&rule.id, game_id) { - Some(g) => json(200, &game_json(rule, &g)), + Some(g) => json(200, &game_json(rule, &g, None)), None => json(404, &json!({"error": "Match non trouvé"})), } } diff --git a/server/src/main.rs b/server/src/main.rs index 0b918e2..f071140 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -129,12 +129,7 @@ async fn main() { let epg = EpgApi::new(xmltv, http.clone(), default_panel_xmltv(http.clone())); // Enregistrement automatique des matchs NBA en direct sur beIN Sports FR. - let nba = Arc::new(NbaAutoRecorder::new( - db.clone(), - http.clone(), - epg.clone(), - scheduler.clone(), - )); + let nba = Arc::new(NbaAutoRecorder::new(db.clone(), http.clone(), epg.clone())); nba.start(); // Purge des temporaires. Jamais le dossier temporaire système en diff --git a/server/src/services/bein_guide.rs b/server/src/services/bein_guide.rs new file mode 100644 index 0000000..ec88e31 --- /dev/null +++ b/server/src/services/bein_guide.rs @@ -0,0 +1,166 @@ +//! Programme officiel des chaînes beIN Sports France (API du site +//! beinsports.com, `api/opta`). +//! +//! POURQUOI : le calendrier NBA liste tous les matchs, mais beIN n'en +//! diffuse qu'une partie, et l'EPG du panneau IPTV ne voit qu'environ 48 h +//! devant. Le guide beIN couvre ~8 jours, nomme la chaîne exacte et indique +//! pour chaque diffusion si elle est en direct (`live`) ou une rediffusion +//! (`replay`, attribut « Rediffusion »). + +use crate::services::nba_teams::normalize; +use chrono::{DateTime, Duration, Utc}; +use serde_json::Value; + +const API: &str = "https://www.beinsports.com/api/opta"; + +/// Une diffusion du guide beIN. +#[derive(Clone, Debug, PartialEq)] +pub struct BeinBroadcast { + /// Nom de la chaîne dans le guide (« beIN SPORTS 1 », « beIN SPORTS MAX 4 »). + pub channel: String, + pub title: String, + pub start: DateTime, + pub end: DateTime, + /// Diffusion en direct selon beIN. + pub live: bool, +} + +fn date(v: &Value, key: &str) -> Option> { + v.get(key) + .and_then(Value::as_str) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|d| d.with_timezone(&Utc)) +} + +/// Réponse `tv-event` → diffusions. Une diffusion est « en direct » +/// seulement si beIN le dit et qu'elle n'est marquée ni rediffusion ni +/// replay. +pub fn parse_events(json: &Value) -> Vec { + let Some(rows) = json.get("rows").and_then(Value::as_array) else { + return Vec::new(); + }; + rows.iter() + .filter_map(|r| { + let channel = r + .pointer("/channel/name") + .or_else(|| r.pointer("/channel/data/name")) + .and_then(Value::as_str)? + .to_string(); + let rerun_attr = r + .pointer("/data/airingAttributes") + .and_then(Value::as_array) + .is_some_and(|a| { + a.iter().any(|x| { + x.get("name") + .and_then(Value::as_str) + .is_some_and(|n| normalize(n).contains("rediffusion")) + }) + }); + let flag = |k: &str| r.get(k).and_then(Value::as_bool).unwrap_or(false); + Some(BeinBroadcast { + channel, + title: r.get("title").and_then(Value::as_str)?.to_string(), + start: date(r, "startDate")?, + end: date(r, "endDate")?, + live: flag("live") && !flag("replay") && !rerun_attr, + }) + }) + .collect() +} + +async fn get_json(client: &reqwest::Client, url: &str) -> Result { + let r = client + .get(url) + .header("User-Agent", "Mozilla/5.0 (XtremFlow)") + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| e.to_string())?; + if !r.status().is_success() { + return Err(format!("HTTP {}", r.status())); + } + let body = r.bytes().await.map_err(|e| e.to_string())?; + tokio::task::spawn_blocking(move || serde_json::from_slice::(&body)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +/// Diffusions NBA des `days` prochains jours sur toutes les beIN France. +/// `None` si le guide est injoignable (l'appelant se rabat sur l'EPG IPTV). +pub async fn fetch_nba(client: &reqwest::Client, days: i64) -> Option> { + let channels = match get_json(client, &format!("{API}/tv-channel?region=fr-fr")).await { + Ok(v) => v, + Err(e) => { + println!("[beIN] Chaînes indisponibles : {e}"); + return None; + } + }; + let ids: Vec = channels + .get("rows") + .and_then(Value::as_array)? + .iter() + .filter_map(|c| c.get("id").and_then(Value::as_str).map(str::to_string)) + .collect(); + if ids.is_empty() { + return None; + } + let now = Utc::now(); + let fmt = |d: DateTime| d.format("%Y-%m-%dT%H:%M:%S.000Z").to_string(); + // Sérialiseur dans son propre bloc : il n'est pas `Send` et ne doit pas + // vivre au-delà d'un `await`. + let query = { + let mut q = url::form_urlencoded::Serializer::new(String::new()); + q.append_pair("searchKey", "NBA") + .append_pair("startBefore", &fmt(now + Duration::days(days))) + .append_pair("endAfter", &fmt(now - Duration::hours(6))) + .append_pair("limit", "3000"); + for id in &ids { + q.append_pair("channelIds", id); + } + q.finish() + }; + match get_json(client, &format!("{API}/tv-event?{query}")).await { + Ok(v) => Some(parse_events(&v)), + Err(e) => { + println!("[beIN] Programme indisponible : {e}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{"count": 4, "rows": [ + {"title": "Basket-ball : NBA - Detroit Pistons / Boston Celtics", + "startDate": "2026-10-20T19:00:00.000Z", "endDate": "2026-10-20T21:45:00.000Z", + "live": true, "replay": false, "channel": {"name": "beIN SPORTS 1"}, + "data": {"airingAttributes": [{"id": 48, "name": "Inédit crypté"}]}}, + {"title": "Basket-ball : NBA - Detroit Pistons / Boston Celtics", + "startDate": "2026-10-21T07:15:00.000Z", "endDate": "2026-10-21T09:00:00.000Z", + "live": false, "replay": true, "channel": {"name": "beIN SPORTS 1"}, + "data": {"airingAttributes": [{"id": 12, "name": "Rediffusion"}]}}, + {"title": "Basket-ball : NBA - Charlotte Hornets / Miami Heat", + "startDate": "2026-10-25T20:00:00.000Z", "endDate": "2026-10-25T22:00:00.000Z", + "live": true, "replay": false, "channel": {"name": "beIN SPORTS 3"}, + "data": {"airingAttributes": [{"id": 12, "name": "Rediffusion"}]}}, + {"title": "sans chaîne", "startDate": "2026-10-25T20:00:00.000Z", + "endDate": "2026-10-25T22:00:00.000Z", "live": true} + ]}"#; + + #[test] + fn lit_le_guide_bein() { + let v: Value = serde_json::from_str(SAMPLE).unwrap(); + let b = parse_events(&v); + assert_eq!(b.len(), 3); + assert_eq!(b[0].channel, "beIN SPORTS 1"); + assert!(b[0].live); + assert_eq!(b[0].start.to_rfc3339(), "2026-10-20T19:00:00+00:00"); + // Rediffusion explicite. + assert!(!b[1].live); + // « live » contredit par l'attribut Rediffusion : pas en direct. + assert!(!b[2].live); + } +} diff --git a/server/src/services/mod.rs b/server/src/services/mod.rs index 5ae206e..9d076f2 100644 --- a/server/src/services/mod.rs +++ b/server/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod bein_channels; +pub mod bein_guide; pub mod cleanup; pub mod ffmpeg_sessions; pub mod gpu_fallback; diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs index 49d5b30..b536a86 100644 --- a/server/src/services/nba_autorec.rs +++ b/server/src/services/nba_autorec.rs @@ -17,6 +17,7 @@ use crate::api::epg::EpgApi; use crate::dart_time::{DartDateTime, utc_iso}; use crate::db::AppDatabase; use crate::models::{AutoRule, NbaGame, PlaylistConfig, Recording}; +use crate::services::bein_guide; use crate::services::nba_artwork; use crate::services::nba_schedule; use crate::services::nba_teams::{self, Team, normalize}; @@ -178,39 +179,11 @@ pub fn find_live_broadcast( }) } -/// Le match passe-t-il le filtre d'équipes de la règle (vide = tous) ? -pub fn passes_team_filter(rule: &AutoRule, home: &str, away: &str) -> bool { - if rule.team_filter.is_empty() { - return true; - } - let keys = |abbr: &str| -> Vec { - match nba_teams::by_abbr(abbr) { - Some(t) => vec![ - normalize(t.abbr), - normalize(t.nickname), - normalize(&t.full_name()), - normalize(t.city), - ], - None => vec![normalize(abbr)], - } - }; - let game_keys: Vec = keys(home).into_iter().chain(keys(away)).collect(); - rule.team_filter - .iter() - .map(|f| normalize(f)) - .any(|f| game_keys.contains(&f)) -} - -/// L'utilisateur veut-il ce match ? -pub fn wants(rule: &AutoRule, game: &NbaGame) -> bool { - match game.decision.as_str() { - "accepted" => true, - "refused" => false, - _ => { - rule.default_decision == "auto" - && passes_team_filter(rule, &game.home_tri, &game.away_tri) - } - } +/// L'utilisateur a-t-il choisi ce match ? Rien n'est enregistré sans son +/// oui explicite : aucune programmation automatique, quels que soient les +/// anciens réglages de la règle (`default_decision`, `team_filter`). +pub fn wants(game: &NbaGame) -> bool { + game.decision == "accepted" } /// Fenêtre d'enregistrement : marge avant, au moins `MIN_GAME_MINUTES` @@ -281,7 +254,7 @@ pub fn plan_action( } } let scheduled = recording.filter(|r| r.status == "scheduled"); - if !wants(rule, game) { + if !wants(game) { return match scheduled { Some(_) => Action::Cancel, None if live.is_some() => Action::Status("confirmed"), @@ -333,13 +306,66 @@ fn past_or( } } -/// Créneaux simultanés déjà pris sur [start, end]. -fn overlapping(recordings: &[Recording], start: DateTime, end: DateTime) -> usize { +/// Enregistrement en attente ou en cours qui chevauche [start, end]. +/// +/// POURQUOI : l'abonnement IPTV n'autorise qu'un flux à la fois ; deux +/// captures simultanées échoueraient toutes les deux. +fn overlapping( + recordings: &[Recording], + start: DateTime, + end: DateTime, +) -> Option<&Recording> { recordings .iter() .filter(|r| matches!(r.status.as_str(), "scheduled" | "recording")) - .filter(|r| r.start_utc() < end && r.end_utc() > start) - .count() + .find(|r| r.start_utc() < end && r.end_utc() > start) +} + +fn parse_utc(raw: Option<&str>) -> Option> { + raw.and_then(DartDateTime::parse).map(DartDateTime::to_utc) +} + +/// Fenêtre d'enregistrement prévisible d'un match : créneau EPG s'il est +/// connu, sinon coup d'envoi + durée minimale ; marges de la règle autour. +pub fn expected_window(rule: &AutoRule, game: &NbaGame) -> Option<(DateTime, DateTime)> { + let tipoff = parse_utc(Some(&game.tipoff_utc))?; + let min_end = tipoff + Duration::minutes(MIN_GAME_MINUTES); + let (start, end) = match ( + parse_utc(game.epg_start.as_deref()), + parse_utc(game.epg_end.as_deref()), + ) { + (Some(s), Some(e)) => (s.min(tipoff), e.max(min_end)), + _ => (tipoff, min_end), + }; + Some(( + start - Duration::minutes(rule.pad_before_min.max(0)), + end + Duration::minutes(rule.pad_after_min.max(0)), + )) +} + +/// Un seul flux IPTV à la fois : titre de ce qui occupe déjà le créneau du +/// match (autre match choisi, ou autre enregistrement en attente / en cours). +pub fn slot_taken_by( + rule: &AutoRule, + game: &NbaGame, + games: &[NbaGame], + recordings: &[Recording], +) -> Option { + let (start, end) = expected_window(rule, game)?; + let chosen = games + .iter() + .filter(|o| o.game_id != game.game_id && wants(o)) + .find(|o| expected_window(rule, o).is_some_and(|(s, e)| s < end && e > start)); + if let Some(other) = chosen { + return Some(recording_title(other)); + } + let own = game.recording_id.as_deref(); + let others: Vec = recordings + .iter() + .filter(|r| Some(r.id.as_str()) != own) + .cloned() + .collect(); + overlapping(&others, start, end).map(|r| r.title.clone()) } /// Règle en pause : supprime ses enregistrements encore planifiés (le @@ -378,29 +404,58 @@ pub fn suspend_rule(db: &AppDatabase, rule: &AutoRule) { } } +/// Jours de guide beIN demandés (le site publie environ 8 jours). +const BEIN_GUIDE_DAYS: i64 = 10; + +/// Diffusions du guide beIN → programmes des flux IPTV surveillés, par +/// libellé de chaîne (« beIN SPORTS MAX 4 » ↔ « FR| beIN SPORTS MAX 4 FHD »). +/// Second élément : chaînes beIN du guide sans flux correspondant. +pub fn map_bein_broadcasts( + broadcasts: &[bein_guide::BeinBroadcast], + labels: &[(String, String)], +) -> (Vec, Vec) { + let mut mapped = Vec::new(); + let mut unmapped: Vec = Vec::new(); + for b in broadcasts { + let key = normalize(&b.channel); + match labels.iter().find(|(_, label)| normalize(label) == key) { + Some((stream_id, label)) => mapped.push(EpgProgramme { + channel_id: stream_id.clone(), + channel_label: label.clone(), + title: b.title.clone(), + description: String::new(), + start: b.start, + end: b.end, + is_live: b.live, + // beIN dit explicitement ce qui n'est pas du direct. + previously_shown: !b.live, + }), + None => { + if !unmapped.contains(&b.channel) { + unmapped.push(b.channel.clone()); + } + } + } + } + (mapped, unmapped) +} + /// Service : calendrier quotidien + réconciliation toutes les 30 minutes. pub struct NbaAutoRecorder { db: Arc, http: reqwest::Client, epg: EpgApi, - scheduler: Arc, timers: Mutex>>, /// Une réconciliation à la fois (timer et API peuvent se croiser). running: tokio::sync::Mutex<()>, } impl NbaAutoRecorder { - pub fn new( - db: Arc, - http: reqwest::Client, - epg: EpgApi, - scheduler: Arc, - ) -> Self { + pub fn new(db: Arc, http: reqwest::Client, epg: EpgApi) -> Self { Self { db, http, epg, - scheduler, timers: Mutex::new(Vec::new()), running: tokio::sync::Mutex::new(()), } @@ -507,7 +562,8 @@ impl NbaAutoRecorder { rule: &AutoRule, playlist: &PlaylistConfig, ) -> Vec { - let mut out = Vec::new(); + // Libellé beIN de chaque flux surveillé (« beIN SPORTS 1 »). + let mut labels: Vec<(String, String)> = Vec::new(); for channel_id in &rule.channel_ids { let label = self .epg @@ -519,8 +575,27 @@ impl NbaAutoRecorder { .unwrap_or(n) }) .unwrap_or_else(|| format!("Chaîne {channel_id}")); + labels.push((channel_id.clone(), label)); + } + + // Source principale : le guide officiel beIN (≈ 8 jours, direct ou + // rediffusion explicites, chaîne exacte). + if let Some(broadcasts) = bein_guide::fetch_nba(&self.http, BEIN_GUIDE_DAYS).await { + let (mapped, unmapped) = map_bein_broadcasts(&broadcasts, &labels); + if !unmapped.is_empty() { + println!( + "[NBA] Chaînes beIN absentes de la règle : {}", + unmapped.join(", ") + ); + } + return mapped; + } + + // Repli : l'EPG du panneau IPTV (≈ 48 h). + let mut out = Vec::new(); + for (channel_id, label) in &labels { for v in self.epg.programmes(playlist, channel_id).await { - if let Some(p) = EpgProgramme::from_json(channel_id, &label, &v) { + if let Some(p) = EpgProgramme::from_json(channel_id, label, &v) { out.push(p); } } @@ -557,7 +632,8 @@ impl NbaAutoRecorder { // L'EPG ne couvre que quelques jours : inutile de le charger pour // des matchs lointains seulement. let needs_epg = games.iter().any(|g| { - DartDateTime::parse(&g.tipoff_utc).is_some_and(|t| t.to_utc() < now + Duration::days(4)) + DartDateTime::parse(&g.tipoff_utc) + .is_some_and(|t| t.to_utc() < now + Duration::days(BEIN_GUIDE_DAYS)) }); let programmes = if needs_epg && !rule.channel_ids.is_empty() { self.watched_programmes(rule, &playlist).await @@ -594,6 +670,18 @@ impl NbaAutoRecorder { next.epg_start = Some(utc_iso(l.programme.start)); next.epg_end = Some(utc_iso(l.programme.end)); next.confidence = Some(l.confidence); + } else if !programmes.is_empty() + && tipoff < now + Duration::days(BEIN_GUIDE_DAYS) + && recording.is_none() + { + // Le guide couvre ce match et ne l'annonce plus en direct : + // oublier la chaîne d'un passage précédent. + next.channel_id = None; + next.channel_name = None; + next.epg_title = None; + next.epg_start = None; + next.epg_end = None; + next.confidence = None; } next.conflict = false; match action { @@ -621,8 +709,9 @@ impl NbaAutoRecorder { self.db.delete_recording(&rec.id); next.recording_id = None; } - let busy = overlapping(&self.db.get_pending_recordings(), start, end); - if self.scheduler.max_concurrent > 0 && busy >= self.scheduler.max_concurrent { + // Un seul flux IPTV : tout chevauchement bloque. + let pending = self.db.get_pending_recordings(); + if overlapping(&pending, start, end).is_some() { next.status = "confirmed".into(); next.conflict = true; } else if let Some(l) = &live { @@ -883,7 +972,7 @@ mod tests { confidence: 90, }; let now = t("2026-10-21T10:00:00Z"); - let action = plan_action(&rule(), &game("auto"), t(TIP), Some(&b), None, now); + let action = plan_action(&rule(), &game("accepted"), t(TIP), Some(&b), None, now); // Début : 02:15 - 5 min ; fin : max(04:00, 02:30 + 2h30) + 45 min. assert_eq!( action, @@ -920,28 +1009,159 @@ mod tests { ); } + /// Rapprochement réel (réseau) : `cargo test croisement_reel -- --ignored --nocapture`. + #[tokio::test] + #[ignore] + async fn croisement_reel_guide_bein_calendrier_nba() { + let http = reqwest::Client::new(); + let guide = bein_guide::fetch_nba(&http, BEIN_GUIDE_DAYS).await.unwrap(); + let labels: Vec<(String, String)> = guide + .iter() + .map(|b| (b.channel.clone(), b.channel.clone())) + .collect(); + let (progs, _) = map_bein_broadcasts(&guide, &labels); + let first = (Utc::now() - Duration::days(1)).date_naive(); + let games = nba_schedule::fetch_days(&http, first, 10).await; + let mut found = 0; + for g in &games { + let nearby = games.iter().any(|o| { + o.game_id != g.game_id && (o.tipoff_utc - g.tipoff_utc).num_minutes().abs() < 60 + }); + if let Some(l) = + find_live_broadcast(&g.home_tri, &g.away_tri, g.tipoff_utc, nearby, &progs) + { + found += 1; + println!( + "{} {} @ {} → {} {} ({})", + g.tipoff_utc, + g.away, + g.home, + l.programme.channel_label, + l.programme.start, + l.confidence + ); + } + } + println!( + "{found} match(s) en direct sur beIN / {} au calendrier", + games.len() + ); + assert!(found > 0); + } + #[test] - fn mode_validation_et_filtre_d_equipes() { + fn guide_bein_vers_flux_iptv_et_direct_seulement() { + let b = + |ch: &str, title: &str, start: &str, end: &str, live: bool| bein_guide::BeinBroadcast { + channel: ch.into(), + title: title.into(), + start: t(start), + end: t(end), + live, + }; + let guide = vec![ + b( + "beIN SPORTS 1", + "Basket-ball : NBA - Los Angeles Lakers / Boston Celtics", + "2026-10-22T02:30:00Z", + "2026-10-22T05:00:00Z", + true, + ), + b( + "beIN SPORTS 1", + "Basket-ball : NBA - Los Angeles Lakers / Boston Celtics", + "2026-10-22T09:00:00Z", + "2026-10-22T10:45:00Z", + false, + ), + b( + "beIN SPORTS MAX 9", + "Basket-ball : NBA - Miami Heat / Utah Jazz", + "2026-10-22T02:30:00Z", + "2026-10-22T05:00:00Z", + true, + ), + ]; + let labels = vec![("101".to_string(), "beIN SPORTS 1".to_string())]; + let (progs, unmapped) = map_bein_broadcasts(&guide, &labels); + assert_eq!(progs.len(), 2); + assert!(progs.iter().all(|p| p.channel_id == "101")); + assert_eq!(unmapped, vec!["beIN SPORTS MAX 9".to_string()]); + // Le direct est retenu ; la rediffusion du matin ne l'est pas. + let live = find_live_broadcast("LAL", "BOS", t(TIP), false, &progs).unwrap(); + assert_eq!(live.programme.start, t("2026-10-22T02:30:00Z")); + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &progs[1..]).is_none()); + } + + #[test] + fn un_seul_enregistrement_par_creneau() { + let r = rule(); + let mut chosen = game("accepted"); + chosen.game_id = "a".into(); + // Coup d'envoi 1 h plus tard : chevauche (fenêtre de 2 h 30 + marges). + let mut later = game("auto"); + later.game_id = "b".into(); + later.home_tri = "GS".into(); + later.away_tri = "PHX".into(); + later.tipoff_utc = "2026-10-22T03:30:00.000Z".into(); + let games = vec![chosen.clone(), later.clone()]; + assert_eq!( + slot_taken_by(&r, &later, &games, &[]).as_deref(), + Some("NBA - Celtics @ Lakers") + ); + // Le lendemain soir : libre. + later.tipoff_utc = "2026-10-23T02:30:00.000Z".into(); + let games = vec![chosen.clone(), later.clone()]; + assert_eq!(slot_taken_by(&r, &later, &games, &[]), None); + // Un autre enregistrement (manuel) sur le créneau bloque aussi. + let manual = rec( + "9", + "scheduled", + "2026-10-23T03:00:00Z", + "2026-10-23T04:00:00Z", + ); + assert!(slot_taken_by(&r, &later, &[later.clone()], &[manual]).is_some()); + // Son propre enregistrement ne compte pas. + let mut own = rec( + "1", + "scheduled", + "2026-10-23T02:25:00Z", + "2026-10-23T05:45:00Z", + ); + own.id = "own".into(); + later.recording_id = Some("own".into()); + assert_eq!(slot_taken_by(&r, &later, &[later.clone()], &[own]), None); + } + + #[test] + fn rien_n_est_enregistre_sans_un_oui() { let b = LiveBroadcast { programme: prog("1", "NBA", "2026-10-22T02:30:00Z", "2026-10-22T05:00:00Z"), confidence: 90, }; let now = t("2026-10-21T10:00:00Z"); - let mut ask = rule(); - ask.default_decision = "ask".into(); + // Même une ancienne règle en mode 'auto' ne programme rien seule. + let mut legacy = rule(); + legacy.default_decision = "auto".into(); assert_eq!( - plan_action(&ask, &game("auto"), t(TIP), Some(&b), None, now), + plan_action(&legacy, &game("auto"), t(TIP), Some(&b), None, now), Action::Status("confirmed") ); assert!(matches!( - plan_action(&ask, &game("accepted"), t(TIP), Some(&b), None, now), + plan_action(&legacy, &game("accepted"), t(TIP), Some(&b), None, now), Action::Create { .. } )); - let mut only_knicks = rule(); - only_knicks.team_filter = vec!["Knicks".into()]; - assert!(!wants(&only_knicks, &game("auto"))); - only_knicks.team_filter = vec!["celtics".into()]; - assert!(wants(&only_knicks, &game("auto"))); + // Un enregistrement programmé sans choix explicite est annulé. + let r = rec( + "1", + "scheduled", + "2026-10-22T02:10:00Z", + "2026-10-22T05:45:00Z", + ); + assert_eq!( + plan_action(&legacy, &game("auto"), t(TIP), Some(&b), Some(&r), now), + Action::Cancel + ); } #[test] @@ -958,7 +1178,14 @@ mod tests { "2026-10-22T05:45:00Z", ); assert_eq!( - plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&same), now), + plan_action( + &rule(), + &game("accepted"), + t(TIP), + Some(&b), + Some(&same), + now + ), Action::Status("scheduled") ); let moved = rec( @@ -968,7 +1195,14 @@ mod tests { "2026-10-22T05:45:00Z", ); assert!(matches!( - plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&moved), now), + plan_action( + &rule(), + &game("accepted"), + t(TIP), + Some(&b), + Some(&moved), + now + ), Action::Realign { .. } )); let other = rec( @@ -978,7 +1212,14 @@ mod tests { "2026-10-22T05:45:00Z", ); assert!(matches!( - plan_action(&rule(), &game("auto"), t(TIP), Some(&b), Some(&other), now), + plan_action( + &rule(), + &game("accepted"), + t(TIP), + Some(&b), + Some(&other), + now + ), Action::Recreate { .. } )); }