Auto NBA : guide officiel beIN, choix manuel, un seul enregistrement par créneau

- 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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-18 16:33:08 +02:00
1 parent 5a15311f98
commit 5f0f6abcc6
6 files changed
+621 -175

No files matched your search

+101 -83
View File
@@ -1875,24 +1875,33 @@ class _AutoNbaViewState extends State<_AutoNbaView> {
Future<void> _decide(Map<String, dynamic> 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 = <String>{
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<String, dynamic> 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<Color>(
(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<String, dynamic> 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<String, dynamic> 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,
+46 -21
View File
@@ -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<String>) -> 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<Value> = 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<Value> = 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é"})),
}
}
+1 -6
View File
@@ -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
+166
View File
@@ -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<Utc>,
pub end: DateTime<Utc>,
/// Diffusion en direct selon beIN.
pub live: bool,
}
fn date(v: &Value, key: &str) -> Option<DateTime<Utc>> {
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<BeinBroadcast> {
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<Value, String> {
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::<Value>(&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<Vec<BeinBroadcast>> {
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<String> = 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<Utc>| 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);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod bein_channels;
pub mod bein_guide;
pub mod cleanup;
pub mod ffmpeg_sessions;
pub mod gpu_fallback;
+306 -65
View File
@@ -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<String> {
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<String> = 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<Utc>, end: DateTime<Utc>) -> 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<Utc>,
end: DateTime<Utc>,
) -> 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<DateTime<Utc>> {
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<Utc>, DateTime<Utc>)> {
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<String> {
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<Recording> = 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<EpgProgramme>, Vec<String>) {
let mut mapped = Vec::new();
let mut unmapped: Vec<String> = 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<AppDatabase>,
http: reqwest::Client,
epg: EpgApi,
scheduler: Arc<RecordingScheduler>,
timers: Mutex<Vec<JoinHandle<()>>>,
/// Une réconciliation à la fois (timer et API peuvent se croiser).
running: tokio::sync::Mutex<()>,
}
impl NbaAutoRecorder {
pub fn new(
db: Arc<AppDatabase>,
http: reqwest::Client,
epg: EpgApi,
scheduler: Arc<RecordingScheduler>,
) -> Self {
pub fn new(db: Arc<AppDatabase>, 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<EpgProgramme> {
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 { .. }
));
}