mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 20:59:59 +02:00
Auto NBA : afficher les matchs beIN même sans chaîne reliée, diagnostic
L'onglet restait vide alors que le guide beIN annonçait des matchs : une diffusion n'était prise en compte que si sa chaîne beIN était reliée à un flux de la règle (liste vide si la détection avait échoué à la création, ou nom différent chez le revendeur). - Toute diffusion en direct du guide beIN est rapprochée et affichée ; seul l'enregistrement exige un flux IPTV (sinon « absente de la playlist », bouton inactif, aucun enregistrement créé). - Les beIN FR de la playlist sont redétectées à chaque passage et complètent les flux choisis dans la règle. - Plus besoin de playlist pour afficher le programme. - Diagnostic par règle (source, directs au guide, chaînes reliées ou absentes, matchs rapprochés) renvoyé par l'API et affiché quand la liste est vide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f0f6abcc6
commit
29b4e11d13
4 files changed
+235
-60
No files matched your search
@@ -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<String, dynamic>? _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<String, dynamic>?;
|
||||
_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<String, dynamic>?;
|
||||
});
|
||||
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 = <String>[];
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String> {
|
||||
/// 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(
|
||||
|
||||
@@ -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<AppDatabase>,
|
||||
@@ -448,6 +505,8 @@ pub struct NbaAutoRecorder {
|
||||
timers: Mutex<Vec<JoinHandle<()>>>,
|
||||
/// 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<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<EpgProgramme> {
|
||||
// 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<EpgProgramme>, 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<String, DateTime<Utc>> = 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]
|
||||
|
||||
Reference in new issue
Block a user