diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 2358688..41469c6 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -1764,6 +1764,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> { bool _busy = false; String? _error; bool _showOffBein = false; + // Dernier diagnostic serveur : source, diffusions, chaînes reliées. + Map? _diag; @override void initState() { @@ -1799,7 +1801,9 @@ class _AutoNbaViewState extends State<_AutoNbaView> { if (!mounted) return; setState(() { _rule = rule; - _games = json.decode(g.body)['games'] as List; + final body = json.decode(g.body); + _games = body['games'] as List; + _diag = body['diagnostic'] as Map?; _isLoading = false; }); } catch (e) { @@ -1866,7 +1870,11 @@ class _AutoNbaViewState extends State<_AutoNbaView> { if (!mounted) return; setState(() => _busy = false); if (r.statusCode == 200) { - setState(() => _games = json.decode(r.body)['games'] as List); + final body = json.decode(r.body); + setState(() { + _games = body['games'] as List; + _diag = body['diagnostic'] as Map?; + }); notifyRecordingsChanged(); } else { _snack('❌ ${r.body}'); @@ -2109,6 +2117,33 @@ class _AutoNbaViewState extends State<_AutoNbaView> { }; } + /// Résumé du dernier passage serveur, pour comprendre une liste vide. + String? _diagText() { + final d = _diag; + if (d == null) return null; + final parts = []; + final calendar = d['games_in_calendar']; + if (calendar == 0) parts.add('Calendrier NBA vide ou injoignable'); + if (d['source'] == 'bein') { + parts.add('${d['live_broadcasts'] ?? 0} direct(s) NBA au guide beIN'); + } else if (d['source'] == 'iptv') { + parts.add('Guide beIN injoignable, repli sur le guide IPTV'); + } + if (d['has_playlist'] == false) parts.add('aucune playlist IPTV'); + final linked = (d['linked_channels'] as List?) ?? const []; + parts.add( + linked.isEmpty + ? 'aucune beIN trouvée dans la playlist' + : '${linked.length} beIN reliée(s) à la playlist', + ); + final missing = (d['missing_channels'] as List?) ?? const []; + if (missing.isNotEmpty) parts.add('absentes : ${missing.join(', ')}'); + if (d['matched_games'] != null) { + parts.add('${d['matched_games']} match(s) rapproché(s)'); + } + return parts.join(' · '); + } + /// Nuit de diffusion : un match à 02:30 appartient à la nuit de la veille. String _nightLabel(DateTime local) { final d = local.subtract(const Duration(hours: 12)); @@ -2186,11 +2221,12 @@ class _AutoNbaViewState extends State<_AutoNbaView> { 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. + // Chaîne beIN reliée à un flux de la playlist : sinon pas d'enregistrement. + final recordable = g['channel_id'] != null; final locked = ['recorded', 'missed', 'not_on_bein'].contains(g['status']) || tipoff.isBefore(DateTime.now()) || - (channel == null && g['wanted'] != true); + (!recordable && g['wanted'] != true); return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( @@ -2229,7 +2265,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_hhmm(tipoff)} · ${channel ?? 'pas annoncé sur beIN'}', + '${_hhmm(tipoff)} · ${channel ?? 'pas annoncé sur beIN'}' + '${channel != null && !recordable ? ' · absente de la playlist' : ''}', style: const TextStyle( color: AppColors.onSurface54, fontSize: 12, @@ -2343,7 +2380,8 @@ class _AutoNbaViewState extends State<_AutoNbaView> { return _emptyState( Icons.event_busy, 'Aucun match NBA annoncé sur beIN Sports', - 'Le programme beIN couvre environ 8 jours et est relu toutes les 30 min', + _diagText() ?? + 'Le programme beIN couvre environ 8 jours et est relu toutes les 30 min', ); } // Liste aplatie : intertitre de nuit puis ses matchs. diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs index 24fbe70..d4c7799 100644 --- a/server/src/api/auto_rules.rs +++ b/server/src/api/auto_rules.rs @@ -267,7 +267,10 @@ fn games(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp { game_json(rule, g, taken) }) .collect(); - json(200, &json!({"rule": rule, "games": list})) + json( + 200, + &json!({"rule": rule, "games": list, "diagnostic": state.nba.diagnostic(&rule.id)}), + ) } async fn decision(state: &SharedState, req: &Req, rule: &AutoRule, game_id: &str) -> Resp { diff --git a/server/src/api/epg.rs b/server/src/api/epg.rs index def06cb..2d2b1af 100644 --- a/server/src/api/epg.rs +++ b/server/src/api/epg.rs @@ -264,17 +264,14 @@ impl EpgApi { self.programmes_with_source(playlist, channel_id).await.0 } - /// Nom de la chaîne dans la table du panneau (cache 6 h). - pub async fn channel_name( - &self, - playlist: &PlaylistConfig, - channel_id: &str, - ) -> Option { + /// Toutes les chaînes du panneau : `(stream_id, nom)` (cache 6 h). + pub async fn channel_names(&self, playlist: &PlaylistConfig) -> Vec<(String, String)> { self.channel_map_for(playlist) .await .names - .get(channel_id) - .cloned() + .iter() + .map(|(id, name)| (id.clone(), name.clone())) + .collect() } async fn programmes_with_source( diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs index b536a86..cfc4402 100644 --- a/server/src/services/nba_autorec.rs +++ b/server/src/services/nba_autorec.rs @@ -269,6 +269,13 @@ pub fn plan_action( None => Action::Status(past_or(game, tipoff, now, "provisional")), }; }; + if live.programme.channel_id.is_empty() { + // Diffusé sur une beIN absente de la playlist : rien à capturer. + return match scheduled { + Some(_) => Action::Status("scheduled"), + None => Action::Status("confirmed"), + }; + } let (start, end) = recording_window(rule, tipoff, &live.programme); if end <= now { return Action::Status("missed"); @@ -431,7 +438,18 @@ pub fn map_bein_broadcasts( previously_shown: !b.live, }), None => { - if !unmapped.contains(&b.channel) { + // Affiché quand même : seul l'enregistrement exige un flux. + mapped.push(EpgProgramme { + channel_id: String::new(), + channel_label: b.channel.clone(), + title: b.title.clone(), + description: String::new(), + start: b.start, + end: b.end, + is_live: b.live, + previously_shown: !b.live, + }); + if b.live && !unmapped.contains(&b.channel) { unmapped.push(b.channel.clone()); } } @@ -440,6 +458,45 @@ pub fn map_bein_broadcasts( (mapped, unmapped) } +/// Flux IPTV à surveiller, avec leur libellé beIN : ceux choisis dans la +/// règle, complétés par les beIN FR détectées dans la playlist pour les +/// chaînes qu'aucun flux choisi ne couvre. +/// +/// POURQUOI : une liste vide (détection ratée à la création) ou un flux +/// renommé par le revendeur faisait ignorer tout le guide beIN. +pub fn channel_labels(rule: &AutoRule, names: &[(String, String)]) -> Vec<(String, String)> { + let table: Vec<(String, String, String)> = names + .iter() + .map(|(id, name)| (id.clone(), name.clone(), String::new())) + .collect(); + let detected = crate::services::bein_channels::detect(&table); + let mut labels: Vec<(String, String)> = Vec::new(); + for id in &rule.channel_ids { + let label = detected + .iter() + .find(|c| &c.stream_id == id) + .map(|c| c.label.clone()) + .or_else(|| { + names + .iter() + .find(|(sid, _)| sid == id) + .map(|(_, n)| n.clone()) + }); + if let Some(label) = label { + labels.push((id.clone(), label)); + } + } + for c in crate::services::bein_channels::preferred_fr(&detected) { + let covered = labels + .iter() + .any(|(_, l)| normalize(l) == normalize(&c.label)); + if !covered { + labels.push((c.stream_id, c.label)); + } + } + labels +} + /// Service : calendrier quotidien + réconciliation toutes les 30 minutes. pub struct NbaAutoRecorder { db: Arc, @@ -448,6 +505,8 @@ pub struct NbaAutoRecorder { timers: Mutex>>, /// Une réconciliation à la fois (timer et API peuvent se croiser). running: tokio::sync::Mutex<()>, + /// Dernier diagnostic par règle (affiché dans l'onglet). + diagnostics: Mutex>, } impl NbaAutoRecorder { @@ -458,6 +517,7 @@ impl NbaAutoRecorder { epg, timers: Mutex::new(Vec::new()), running: tokio::sync::Mutex::new(()), + diagnostics: Mutex::new(HashMap::new()), } } @@ -556,51 +616,63 @@ impl NbaAutoRecorder { }) } - /// Programmes EPG de toutes les chaînes surveillées par la règle. - async fn watched_programmes( + /// Dernier diagnostic de la règle : source, diffusions trouvées, + /// chaînes reliées ou absentes de la playlist. + pub fn diagnostic(&self, rule_id: &str) -> Option { + self.diagnostics.lock().unwrap().get(rule_id).cloned() + } + + /// Diffusions à rapprocher des matchs : guide beIN d'abord (toutes les + /// beIN FR, reliées ou non à un flux), EPG IPTV en repli. + async fn collect_programmes( &self, rule: &AutoRule, - playlist: &PlaylistConfig, - ) -> Vec { - // 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 - .channel_name(playlist, channel_id) - .await - .map(|n| { - crate::services::bein_channels::classify(channel_id, &n, "") - .map(|c| c.label) - .unwrap_or(n) - }) - .unwrap_or_else(|| format!("Chaîne {channel_id}")); - labels.push((channel_id.clone(), label)); - } + playlist: Option<&PlaylistConfig>, + ) -> (Vec, Value) { + let names = match playlist { + Some(p) => self.epg.channel_names(p).await, + None => Vec::new(), + }; + let labels = channel_labels(rule, &names); + let linked: Vec<&str> = labels.iter().map(|(_, l)| l.as_str()).collect(); - // 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() { + let (progs, missing) = map_bein_broadcasts(&broadcasts, &labels); + if !missing.is_empty() { println!( - "[NBA] Chaînes beIN absentes de la règle : {}", - unmapped.join(", ") + "[NBA] beIN absentes de la playlist : {}", + missing.join(", ") ); } - return mapped; + let diag = serde_json::json!({ + "source": "bein", + "broadcasts": broadcasts.len(), + "live_broadcasts": broadcasts.iter().filter(|b| b.live).count(), + "linked_channels": linked, + "missing_channels": missing, + "playlist_channels": names.len(), + }); + return (progs, diag); } - // 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) { - out.push(p); + if let Some(p) = playlist { + for (channel_id, label) in &labels { + for v in self.epg.programmes(p, channel_id).await { + if let Some(prog) = EpgProgramme::from_json(channel_id, label, &v) { + out.push(prog); + } } } } - out + let diag = serde_json::json!({ + "source": "iptv", + "broadcasts": out.len(), + "linked_channels": linked, + "missing_channels": [], + "playlist_channels": names.len(), + }); + (out, diag) } /// Réconcilie calendrier, EPG et enregistrements d'une règle. @@ -622,23 +694,24 @@ impl NbaAutoRecorder { now - Duration::hours(6), now + Duration::days(SCHEDULE_DAYS), ); + let playlist = self.playlist_for(&rule.user_id); if games.is_empty() { + self.diagnostics.lock().unwrap().insert( + rule.id.clone(), + serde_json::json!({"games_in_calendar": 0, "at": utc_iso(now)}), + ); return; } - let Some(playlist) = self.playlist_for(&rule.user_id) else { - println!("[NBA] Règle {} : aucune playlist", rule.id); - return; - }; // 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(BEIN_GUIDE_DAYS)) }); - let programmes = if needs_epg && !rule.channel_ids.is_empty() { - self.watched_programmes(rule, &playlist).await + let (programmes, mut diag) = if needs_epg { + self.collect_programmes(rule, playlist.as_ref()).await } else { - Vec::new() + (Vec::new(), serde_json::json!({"source": "none"})) }; let tipoffs: HashMap> = games .iter() @@ -648,6 +721,8 @@ impl NbaAutoRecorder { .collect(); let mut created = 0; + let mut matched = 0; + let in_calendar = games.len(); for game in games { let Some(&tipoff) = tipoffs.get(&game.game_id) else { continue; @@ -664,7 +739,9 @@ impl NbaAutoRecorder { let action = plan_action(rule, &game, tipoff, live.as_ref(), recording.as_ref(), now); let mut next = game.clone(); if let Some(l) = &live { - next.channel_id = Some(l.programme.channel_id.clone()); + matched += 1; + next.channel_id = + (!l.programme.channel_id.is_empty()).then(|| l.programme.channel_id.clone()); next.channel_name = Some(l.programme.channel_label.clone()); next.epg_title = Some(l.programme.title.clone()); next.epg_start = Some(utc_iso(l.programme.start)); @@ -750,6 +827,16 @@ impl NbaAutoRecorder { if created > 0 { println!("[NBA] Règle {} : {created} match(s) programmé(s)", rule.id); } + if let Value::Object(m) = &mut diag { + m.insert("games_in_calendar".into(), in_calendar.into()); + m.insert("matched_games".into(), matched.into()); + m.insert("has_playlist".into(), playlist.is_some().into()); + m.insert("at".into(), utc_iso(now).into()); + } + self.diagnostics + .lock() + .unwrap() + .insert(rule.id.clone(), diag); } } @@ -1084,13 +1171,63 @@ mod tests { ]; 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")); + // Tout est gardé pour l'affichage ; le flux manque pour MAX 9. + assert_eq!(progs.len(), 3); + assert!(progs[..2].iter().all(|p| p.channel_id == "101")); + assert_eq!(progs[2].channel_id, ""); + assert_eq!(progs[2].channel_label, "beIN SPORTS MAX 9"); 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(); + let live = find_live_broadcast("LAL", "BOS", t(TIP), false, &progs[..2]).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()); + assert!(find_live_broadcast("LAL", "BOS", t(TIP), false, &progs[1..2]).is_none()); + // Diffusion sur une beIN absente de la playlist : affichée, pas programmée. + let miami = find_live_broadcast("MIA", "UTAH", t(TIP), false, &progs).unwrap(); + assert_eq!(miami.programme.channel_label, "beIN SPORTS MAX 9"); + let mut g = game("accepted"); + g.home_tri = "MIA".into(); + g.away_tri = "UTAH".into(); + assert_eq!( + plan_action( + &rule(), + &g, + t(TIP), + Some(&miami), + None, + t("2026-10-21T10:00:00Z") + ), + Action::Status("confirmed") + ); + } + + #[test] + fn chaines_bein_detectees_dans_la_playlist() { + let names: Vec<(String, String)> = [ + ("10", "FR - BEIN SPORTS 1 HD"), + ("11", "FR - BEIN SPORTS 1 FHD"), + ("20", "FR| beIN SPORTS 2"), + ("30", "AR - BEIN SPORTS 3"), + ("40", "FR - TF1"), + ] + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(); + // Règle sans chaîne (détection ratée à la création) : tout est relié. + let mut r = rule(); + r.channel_ids = vec![]; + let labels = channel_labels(&r, &names); + assert_eq!( + labels, + vec![ + ("11".to_string(), "beIN SPORTS 1".to_string()), + ("20".to_string(), "beIN SPORTS 2".to_string()), + ] + ); + // Le flux choisi pour beIN 1 est gardé, beIN 2 complété. + r.channel_ids = vec!["10".into()]; + let labels = channel_labels(&r, &names); + assert_eq!(labels[0], ("10".to_string(), "beIN SPORTS 1".to_string())); + assert_eq!(labels.len(), 2); } #[test]