mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 20:59:59 +02:00
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:
1 parent
5a15311f98
commit
5f0f6abcc6
6 files changed
+621
-175
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod bein_channels;
|
||||
pub mod bein_guide;
|
||||
pub mod cleanup;
|
||||
pub mod ffmpeg_sessions;
|
||||
pub mod gpu_fallback;
|
||||
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user