mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-18 12:49:54 +02:00
Le calendrier NBA (API ESPN, 21 jours) annonce les matchs à l'avance ; l'EPG des beIN Sports françaises confirme la chaîne ~48 h avant. Seule la diffusion qui démarre avec le coup d'envoi est retenue : les rediffusions sont ignorées, sans dépendre des titres. Le CDN nba.com refuse les requêtes serveur (403), d'où ESPN. - Serveur : tables auto_rules / nba_games (migration v3 : source_rule_id), réconciliation toutes les 30 min, marges, filtre d'équipes, conflits de créneaux, rétention, API /api/auto-rules (oui/non par match). - Détection des beIN FR (hors MENA, TR, ES…), meilleure qualité par chaîne. - XMLTV : sous-titre, catégories, drapeaux live/new/premiere/ previously-shown, horizon EPG_HORIZON_HOURS. - Plex/Jellyfin : vignette et affiche avec logos des équipes, .nfo, pochette intégrée au MKV, nom de fichier lisible. - UI : onglet « Auto NBA » et pastille sur les enregistrements. - Docker : fonts-dejavu-core (texte des vignettes) et tzdata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.3 KiB
Rust
168 lines
6.3 KiB
Rust
//! Calendrier NBA (API publique ESPN « scoreboard »).
|
|
//!
|
|
//! POURQUOI : l'EPG ne voit qu'environ 48 h devant. Le calendrier officiel
|
|
//! donne les matchs des semaines à l'avance, avec leur coup d'envoi exact :
|
|
//! c'est lui qui permet d'afficher les futurs enregistrements et de
|
|
//! distinguer la diffusion en direct (qui colle au coup d'envoi) des
|
|
//! rediffusions. Le CDN de nba.com refuse les requêtes serveur (403), ESPN
|
|
//! les accepte et fournit en plus abréviations, couleurs et logos.
|
|
|
|
use crate::models::ScheduledGame;
|
|
use crate::services::nba_teams;
|
|
use chrono::{DateTime, Duration, NaiveDate, Utc};
|
|
use serde_json::Value;
|
|
|
|
const SCOREBOARD: &str = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard";
|
|
|
|
/// Date ESPN (`2025-11-06T00:00Z`, sans secondes) ou RFC 3339 complète.
|
|
fn parse_espn_date(raw: &str) -> Option<DateTime<Utc>> {
|
|
if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
|
|
return Some(dt.with_timezone(&Utc));
|
|
}
|
|
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%MZ")
|
|
.ok()
|
|
.map(|n| n.and_utc())
|
|
}
|
|
|
|
/// Extrait les matchs d'une réponse « scoreboard ». Les événements
|
|
/// incomplets (équipe inconnue, date illisible) sont ignorés.
|
|
pub fn parse_scoreboard(json: &Value) -> Vec<ScheduledGame> {
|
|
let Some(events) = json.get("events").and_then(Value::as_array) else {
|
|
return Vec::new();
|
|
};
|
|
events
|
|
.iter()
|
|
.filter_map(|event| {
|
|
let game_id = event.get("id")?.as_str()?.to_string();
|
|
let date = parse_espn_date(event.get("date")?.as_str()?)?;
|
|
let competitors = event
|
|
.get("competitions")?
|
|
.get(0)?
|
|
.get("competitors")?
|
|
.as_array()?;
|
|
let side = |which: &str| {
|
|
competitors
|
|
.iter()
|
|
.find(|c| c.get("homeAway").and_then(Value::as_str) == Some(which))
|
|
.and_then(|c| c.get("team"))
|
|
};
|
|
let (home, away) = (side("home")?, side("away")?);
|
|
let text = |team: &Value, key: &str| {
|
|
team.get(key)
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
};
|
|
let home_abbr = text(home, "abbreviation");
|
|
let away_abbr = text(away, "abbreviation");
|
|
// Normalise vers la table locale (noms complets cohérents) ;
|
|
// une abréviation inconnue (match d'exhibition) garde le texte ESPN.
|
|
let name = |team: &Value, abbr: &str| {
|
|
nba_teams::by_abbr(abbr)
|
|
.map(|t| t.full_name())
|
|
.unwrap_or_else(|| text(team, "displayName"))
|
|
};
|
|
Some(ScheduledGame {
|
|
game_id,
|
|
home: name(home, &home_abbr),
|
|
away: name(away, &away_abbr),
|
|
home_id: text(home, "id"),
|
|
away_id: text(away, "id"),
|
|
home_tri: home_abbr,
|
|
away_tri: away_abbr,
|
|
tipoff_utc: date,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Matchs des journées ESPN `[first, first + days)`. Une journée en échec
|
|
/// est sautée : mieux vaut un calendrier partiel que rien.
|
|
pub async fn fetch_days(
|
|
client: &reqwest::Client,
|
|
first: NaiveDate,
|
|
days: i64,
|
|
) -> Vec<ScheduledGame> {
|
|
let mut games = Vec::new();
|
|
for offset in 0..days {
|
|
let day = first + Duration::days(offset);
|
|
let url = format!("{SCOREBOARD}?dates={}", day.format("%Y%m%d"));
|
|
let response = client
|
|
.get(&url)
|
|
.header("User-Agent", "Mozilla/5.0 (XtremFlow)")
|
|
.timeout(std::time::Duration::from_secs(20))
|
|
.send()
|
|
.await;
|
|
match response {
|
|
Ok(r) if r.status().is_success() => match r
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| e.to_string()))
|
|
{
|
|
Ok(json) => games.extend(parse_scoreboard(&json)),
|
|
Err(e) => println!("[NBA] Calendrier {day} illisible: {e}"),
|
|
},
|
|
Ok(r) => println!("[NBA] Calendrier {day}: HTTP {}", r.status()),
|
|
Err(e) => println!("[NBA] Calendrier {day} injoignable: {e}"),
|
|
}
|
|
}
|
|
games.sort_by_key(|g| g.tipoff_utc);
|
|
games.dedup_by(|a, b| a.game_id == b.game_id);
|
|
games
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SAMPLE: &str = r#"{
|
|
"events": [
|
|
{
|
|
"id": "401810026",
|
|
"date": "2025-11-06T00:00Z",
|
|
"name": "Philadelphia 76ers at Cleveland Cavaliers",
|
|
"competitions": [{
|
|
"competitors": [
|
|
{"homeAway": "home", "team": {"id": "5", "abbreviation": "CLE", "displayName": "Cleveland Cavaliers"}},
|
|
{"homeAway": "away", "team": {"id": "20", "abbreviation": "PHI", "displayName": "Philadelphia 76ers"}}
|
|
]
|
|
}]
|
|
},
|
|
{
|
|
"id": "401810027",
|
|
"date": "2025-11-06T03:00Z",
|
|
"competitions": [{
|
|
"competitors": [
|
|
{"homeAway": "home", "team": {"id": "9", "abbreviation": "GS", "displayName": "Golden State Warriors"}},
|
|
{"homeAway": "away", "team": {"id": "99", "abbreviation": "WLD", "displayName": "World Team"}}
|
|
]
|
|
}]
|
|
},
|
|
{"id": "broken", "date": "n/a", "competitions": []}
|
|
]
|
|
}"#;
|
|
|
|
#[test]
|
|
fn lit_le_scoreboard_espn() {
|
|
let json: Value = serde_json::from_str(SAMPLE).unwrap();
|
|
let games = parse_scoreboard(&json);
|
|
assert_eq!(games.len(), 2);
|
|
let g = &games[0];
|
|
assert_eq!(g.game_id, "401810026");
|
|
assert_eq!(g.home, "Cleveland Cavaliers");
|
|
assert_eq!(g.away, "Philadelphia 76ers");
|
|
assert_eq!((g.home_tri.as_str(), g.away_tri.as_str()), ("CLE", "PHI"));
|
|
assert_eq!(g.tipoff_utc.to_rfc3339(), "2025-11-06T00:00:00+00:00");
|
|
// Équipe inconnue de la table locale : nom ESPN conservé.
|
|
assert_eq!(games[1].away, "World Team");
|
|
}
|
|
|
|
#[test]
|
|
fn dates_espn() {
|
|
assert!(parse_espn_date("2026-10-21T01:30Z").is_some());
|
|
assert!(parse_espn_date("2026-10-21T01:30:00Z").is_some());
|
|
assert!(parse_espn_date("demain").is_none());
|
|
}
|
|
}
|