diff --git a/server/src/api/auto_rules.rs b/server/src/api/auto_rules.rs index b056e31..dd61461 100644 --- a/server/src/api/auto_rules.rs +++ b/server/src/api/auto_rules.rs @@ -181,7 +181,7 @@ async fn create(state: &SharedState, req: &Req, user: &User) -> Resp { if !body.contains_key("channel_ids") && let Some(playlist) = state.get_playlist(req) { - let table = bein_channels::fetch_live_table(&state.http, &playlist).await; + let table = state.epg.channel_table(&playlist).await; rule.channel_ids = bein_channels::preferred_fr(&bein_channels::detect(&table)) .into_iter() .map(|c| c.stream_id) @@ -370,7 +370,7 @@ async fn channels(state: &SharedState, req: &Req, rule: &AutoRule) -> Resp { let Some(playlist) = state.get_playlist(req) else { return json(403, &json!({"error": "Playlist non trouvée"})); }; - let table = bein_channels::fetch_live_table(&state.http, &playlist).await; + let table = state.epg.channel_table(&playlist).await; let detected = bein_channels::detect(&table); let list: Vec = detected .iter() diff --git a/server/src/api/epg.rs b/server/src/api/epg.rs index fe06b1a..fc1a342 100644 --- a/server/src/api/epg.rs +++ b/server/src/api/epg.rs @@ -29,6 +29,10 @@ pub type PanelXmltvBuilder = Arc XmltvEpgService + Se struct ChannelMap { epg_ids: HashMap, names: HashMap, + /// `stream_id` → `category_id` (le nom de catégorie est dans [categories]). + category_ids: HashMap, + /// `category_id` → nom (« |AR| BEIN SPORTS 4K »). + categories: HashMap, expires_at: Option, } @@ -79,10 +83,19 @@ fn value_to_string(v: &Value) -> Option { } } -/// `stream_id` → (`epg_channel_id`, nom) depuis `get_live_streams`. -fn parse_channel_table(body: &[u8]) -> (HashMap, HashMap) { +/// Tables par `stream_id` tirées de `get_live_streams`. +#[derive(Default)] +struct ChannelTable { + epg_ids: HashMap, + names: HashMap, + category_ids: HashMap, +} + +/// `stream_id` → `epg_channel_id`, nom et `category_id`. +fn parse_channel_table(body: &[u8]) -> ChannelTable { let mut epg_ids = HashMap::new(); let mut names = HashMap::new(); + let mut category_ids = HashMap::new(); // UTF-8 explicite : sans charset annoncé, un décodage latin-1 abîmait // les noms (« Chérie », « ◉ ») et donc leur correspondance au guide. let text = String::from_utf8_lossy(body); @@ -100,6 +113,11 @@ fn parse_channel_table(body: &[u8]) -> (HashMap, HashMap (HashMap, HashMap HashMap { + let text = String::from_utf8_lossy(body); + let Ok(Value::Array(items)) = serde_json::from_str::(&text) else { + return HashMap::new(); + }; + items + .iter() + .filter_map(|c| { + let id = c.get("category_id").and_then(value_to_string)?; + let name = c.get("category_name").and_then(value_to_string)?; + Some((id, name)) + }) + .collect() } /// Epoch (secondes, texte ou entier) → ISO-8601 UTC. @@ -265,12 +303,21 @@ impl EpgApi { } /// 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 + /// `(stream_id, nom, catégorie)` des chaînes live de la playlist. + /// Catégorie vide si le panneau ne l'a pas fournie. + pub async fn channel_table(&self, playlist: &PlaylistConfig) -> Vec<(String, String, String)> { + let map = self.channel_map_for(playlist).await; + map.names .iter() - .map(|(id, name)| (id.clone(), name.clone())) + .map(|(id, name)| { + let category = map + .category_ids + .get(id) + .and_then(|c| map.categories.get(c)) + .cloned() + .unwrap_or_default(); + (id.clone(), name.clone(), category) + }) .collect() } @@ -461,13 +508,13 @@ async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) - let status = response.status().as_u16(); Ok::<_, reqwest::Error>((status, response.bytes().await?)) }; - let (mut epg_ids, mut names) = (HashMap::new(), HashMap::new()); + let mut table = ChannelTable::default(); match tokio::time::timeout(Duration::from_secs(90), fetch).await { Ok(Ok((200, body))) => { // Décodage hors du runtime : 7 Mo de JSON gelaient sinon le relais // des flux en cours. match tokio::task::spawn_blocking(move || parse_channel_table(&body)).await { - Ok(parsed) => (epg_ids, names) = parsed, + Ok(parsed) => table = parsed, Err(e) => println!("[EpgApi] table des chaînes illisible : {e}"), } } @@ -480,20 +527,54 @@ async fn load_channel_map(client: &reqwest::Client, playlist: &PlaylistConfig) - "[EpgApi] table des chaînes indisponible : TimeoutException after 0:01:30.000000" ), } + // Catégories (quelques Ko) : elles disent le pays d'un flux quand son nom + // ment (« BEIN SPORTS 1 FR ULTRA FHD » rangée en « |AR| BEIN SPORTS 4K »). + let categories = if table.names.is_empty() { + HashMap::new() + } else { + load_categories(client, playlist).await + }; // Une table vide (panneau en échec) n'est gardée que 2 minutes : la // garder 6 h privait TOUTES les chaînes du repli XMLTV pendant 6 h. - let ttl = if names.is_empty() { + let ttl = if table.names.is_empty() { Duration::from_secs(120) } else { Duration::from_secs(6 * 3600) }; ChannelMap { - epg_ids, - names, + epg_ids: table.epg_ids, + names: table.names, + category_ids: table.category_ids, + categories, expires_at: Some(Instant::now() + ttl), } } +async fn load_categories( + client: &reqwest::Client, + playlist: &PlaylistConfig, +) -> HashMap { + let url = format!( + "{}/player_api.php?username={}&password={}&action=get_live_categories", + playlist.dns, playlist.username, playlist.password + ); + let fetch = async { client.get(&url).send().await?.bytes().await }; + match tokio::time::timeout(Duration::from_secs(30), fetch).await { + Ok(Ok(body)) => parse_categories(&body), + Ok(Err(e)) => { + println!( + "[EpgApi] catégories indisponibles : {}", + redact_url(&format!("{e:?}")) + ); + HashMap::new() + } + Err(_) => { + println!("[EpgApi] catégories indisponibles : délai dépassé"); + HashMap::new() + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -634,7 +715,28 @@ mod tests { assert_eq!(resp.headers()["x-epg-source"], "panel-xmltv"); let body = body_json(resp).await; assert_eq!(body["programmes"][0]["title"], "Journal de 20h"); - assert_eq!(*actions.lock().unwrap(), vec!["get_live_streams"]); + // Table des chaînes + catégories, une fois par compte : aucun appel + // par chaîne. + assert_eq!( + *actions.lock().unwrap(), + vec!["get_live_streams", "get_live_categories"] + ); + } + + #[test] + fn table_des_chaines_avec_categories() { + let table = parse_channel_table( + json!([{"stream_id": 550403, "name": "BEIN SPORTS 1 FR ULTRA FHD", "category_id": "789"}]) + .to_string() + .as_bytes(), + ); + assert_eq!(table.category_ids["550403"], "789"); + let cats = parse_categories( + json!([{"category_id": "789", "category_name": "|AR| BEIN SPORTS 4K", "parent_id": 0}]) + .to_string() + .as_bytes(), + ); + assert_eq!(cats["789"], "|AR| BEIN SPORTS 4K"); } #[tokio::test] diff --git a/server/src/services/bein_channels.rs b/server/src/services/bein_channels.rs index 90ee77a..f837831 100644 --- a/server/src/services/bein_channels.rs +++ b/server/src/services/bein_channels.rs @@ -6,12 +6,9 @@ //! les chaînes françaises, une seule fois par chaîne logique (beIN SPORTS 1, //! 2, 3, MAX 4…), dans la meilleure qualité disponible. -use crate::models::PlaylistConfig; use crate::services::nba_teams::normalize; use regex::Regex; use serde::Serialize; -use serde_json::Value; -use std::collections::HashMap; use std::sync::LazyLock; /// Une chaîne beIN trouvée dans la liste du panneau. @@ -143,20 +140,30 @@ pub fn classify(stream_id: &str, name: &str, category: &str) -> Option ("bein-sports".to_string(), "beIN SPORTS".to_string()), }; - let prefix = COUNTRY_PREFIX - .captures(name) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_ascii_lowercase()); - // Le préfixe du nom fait foi ; sinon les mots du nom (hors « bein sports - // N ») puis ceux de la catégorie. + let country_prefix = |text: &str| { + COUNTRY_PREFIX + .captures(text) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_ascii_lowercase()) + }; + // Ordre de confiance : préfixe pays du nom, puis préfixe pays de la + // catégorie, puis mots du nom (hors « bein sports N »), puis mots de la + // catégorie. Le préfixe de catégorie passe avant les mots du nom : vu en + // prod, « BEIN SPORTS 1 FR ULTRA FHD » rangée en « |AR| BEIN SPORTS 4K » + // est refusée par le panneau (HTTP 407) — ce n'est pas la beIN française. let rest_of_name = BEIN.replace(&norm, " "); let name_words = words(&rest_of_name); let cat_words = words(category); let is_marked = |list: &[&str], ws: &[String]| ws.iter().any(|w| list.contains(&w.as_str())); - let is_fr = match prefix.as_deref() { - Some(p) if FRENCH.contains(&p) => true, - Some(p) if FOREIGN.contains(&p) => false, - _ => { + let explicit = |p: Option| match p.as_deref() { + Some(p) if FRENCH.contains(&p) => Some(true), + Some(p) if FOREIGN.contains(&p) => Some(false), + _ => None, + }; + let is_fr = match explicit(country_prefix(name)).or_else(|| explicit(country_prefix(category))) + { + Some(v) => v, + None => { if is_marked(FOREIGN, &name_words) { false } else if is_marked(FRENCH, &name_words) { @@ -215,67 +222,6 @@ pub fn preferred_fr(detected: &[BeinChannel]) -> Vec { .collect() } -/// `(stream_id, nom, catégorie)` de toutes les chaînes live du panneau. -pub async fn fetch_live_table( - client: &reqwest::Client, - playlist: &PlaylistConfig, -) -> Vec<(String, String, String)> { - let api = |action: &str| { - format!( - "{}/player_api.php?username={}&password={}&action={action}", - playlist.dns, playlist.username, playlist.password - ) - }; - let get = |url: String| async move { - let r = client - .get(&url) - .timeout(std::time::Duration::from_secs(90)) - .send() - .await - .ok()?; - let body = r.bytes().await.ok()?; - tokio::task::spawn_blocking(move || serde_json::from_slice::(&body).ok()) - .await - .ok() - .flatten() - }; - let as_text = |v: Option<&Value>| match v { - Some(Value::String(s)) => s.clone(), - Some(Value::Null) | None => String::new(), - Some(other) => other.to_string(), - }; - let categories: HashMap = get(api("get_live_categories")) - .await - .and_then(|v| v.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|c| { - ( - as_text(c.get("category_id")), - as_text(c.get("category_name")), - ) - }) - .collect(); - get(api("get_live_streams")) - .await - .and_then(|v| v.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|s| { - let id = as_text(s.get("stream_id")); - let name = as_text(s.get("name")); - if id.is_empty() || name.is_empty() { - return None; - } - let cat = categories - .get(&as_text(s.get("category_id"))) - .cloned() - .unwrap_or_default(); - Some((id, name, cat)) - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -309,6 +255,36 @@ mod tests { assert!(!c("US: beIN Sports", "USA SPORTS").unwrap().is_fr); // Aucune indication de pays : on ne devine pas. assert!(!c("beIN SPORTS 1", "SPORTS").unwrap().is_fr); + // Vu en prod : « FR » dans le nom, mais rangée dans une catégorie + // arabe hors abonnement — le panneau refuse le flux (HTTP 407). + assert!( + !c("BEIN SPORTS 1 FR ULTRA FHD", "|AR| BEIN SPORTS 4K") + .unwrap() + .is_fr + ); + } + + #[test] + fn une_categorie_etrangere_ne_prend_pas_le_pas_sur_la_vraie_fr() { + // Ordre et IDs de la playlist de prod : la fausse FR (550403) gagnait + // l'égalité de qualité face à la vraie FHD (554021). + let items: Vec<(String, String, String)> = [ + ( + "550403", + "BEIN SPORTS 1 FR ULTRA FHD", + "|AR| BEIN SPORTS 4K", + ), + ("554021", "FR - BEIN SPORTS 1 FHD", "FR| SPORTS"), + ("543395", "FR - BEIN SPORTS 1", "FR| SPORTS"), + ] + .iter() + .map(|(id, n, cat)| (id.to_string(), n.to_string(), cat.to_string())) + .collect(); + let preferred: Vec = preferred_fr(&detect(&items)) + .into_iter() + .map(|c| c.stream_id) + .collect(); + assert_eq!(preferred, ["554021"]); } #[test] diff --git a/server/src/services/nba_autorec.rs b/server/src/services/nba_autorec.rs index 9607e84..254c5c9 100644 --- a/server/src/services/nba_autorec.rs +++ b/server/src/services/nba_autorec.rs @@ -464,24 +464,33 @@ pub fn map_bein_broadcasts( /// /// 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); +/// +/// [names] : `(stream_id, nom, catégorie)` de la playlist. Un flux choisi +/// reconnu comme beIN étrangère (nom ou catégorie) est ignoré : le guide +/// beIN FR ne décrit pas ses programmes, et le panneau peut le refuser +/// (vu en prod : « BEIN SPORTS 1 FR ULTRA FHD » en « |AR| BEIN SPORTS 4K », +/// HTTP 407 sur tout un match). +pub fn channel_labels( + rule: &AutoRule, + names: &[(String, String, String)], +) -> Vec<(String, String)> { + let detected = crate::services::bein_channels::detect(names); 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()) - }); + let label = match detected.iter().find(|c| &c.stream_id == id) { + Some(c) if !c.is_fr => { + println!( + "[NBA] Flux {id} ignoré : « {} » n'est pas une beIN française ({})", + c.name, c.category + ); + None + } + Some(c) => Some(c.label.clone()), + None => names + .iter() + .find(|(sid, _, _)| sid == id) + .map(|(_, n, _)| n.clone()), + }; if let Some(label) = label { labels.push((id.clone(), label)); } @@ -628,7 +637,7 @@ impl NbaAutoRecorder { playlist: Option<&PlaylistConfig>, ) -> (Vec, Value) { let names = match playlist { - Some(p) => self.epg.channel_names(p).await, + Some(p) => self.epg.channel_table(p).await, None => Vec::new(), }; let labels = channel_labels(rule, &names); @@ -1210,7 +1219,7 @@ mod tests { #[test] fn chaines_bein_detectees_dans_la_playlist() { - let names: Vec<(String, String)> = [ + let names: Vec<(String, String, String)> = [ ("10", "FR - BEIN SPORTS 1 HD"), ("11", "FR - BEIN SPORTS 1 FHD"), ("20", "FR| beIN SPORTS 2"), @@ -1218,7 +1227,7 @@ mod tests { ("40", "FR - TF1"), ] .iter() - .map(|(a, b)| (a.to_string(), b.to_string())) + .map(|(a, b)| (a.to_string(), b.to_string(), String::new())) .collect(); // Règle sans chaîne (détection ratée à la création) : tout est relié. let mut r = rule(); @@ -1238,6 +1247,30 @@ mod tests { assert_eq!(labels.len(), 2); } + #[test] + fn un_flux_choisi_d_une_categorie_etrangere_est_remplace() { + // Vu en prod : la règle visait 550403, « FR » dans le nom mais rangée + // en « |AR| BEIN SPORTS 4K », hors abonnement (HTTP 407 à chaque + // tentative). Le guide beIN FR ne décrit pas cette chaîne. + let names: Vec<(String, String, String)> = [ + ( + "550403", + "BEIN SPORTS 1 FR ULTRA FHD", + "|AR| BEIN SPORTS 4K", + ), + ("554021", "FR - BEIN SPORTS 1 FHD", "FR| SPORTS"), + ] + .iter() + .map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())) + .collect(); + let mut r = rule(); + r.channel_ids = vec!["550403".into()]; + assert_eq!( + channel_labels(&r, &names), + vec![("554021".to_string(), "beIN SPORTS 1".to_string())] + ); + } + #[test] fn un_seul_enregistrement_par_creneau() { let r = rule();