Files
xtremflow/server/src/services/xmltv.rs
T
MichaelandClaude Opus 5.5 3ea6fa0ae3 Serveur réécrit en Rust
Le serveur Dart (bin/) est remplacé par un serveur Rust (server/) :
tokio + axum, reqwest/rustls, SQLite embarqué via rusqlite. Même API,
mêmes routes et mêmes réponses : le front Flutter ne change pas.

La base de production est reprise telle quelle : schéma, migrations et
formats de dates (DateTime.toIso8601String de Dart) sont reproduits à
l'identique, les sessions et hachages existants restent valides.

Comportement conservé : cascade de routage, anti-SSRF, logs masqués,
rate limits, transcodage HLS/turbo.ts avec repli GPU, quotas fournisseur,
enregistrements (reprise, relances, fusion), season passes, EPG, logos de
repli, arrêt gracieux sur SIGTERM. Tests Dart portés (132 tests Rust) ;
la CI exécute fmt, test et clippy -D warnings, l'image Docker compile le
serveur dans un étage rust:1.98.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:57 +02:00

1635 lines
60 KiB
Rust

//! Guide TV construit à partir de dumps XMLTV publics.
//!
//! Beaucoup de panneaux Xtream servent un EPG figé depuis plusieurs jours, ou
//! n'en servent aucun : `get_simple_data_table` répond alors correctement mais
//! avec des programmes périmés. Ce service télécharge des dumps XMLTV
//! indépendants, les indexe par chaîne, et permet de compléter le guide.
//!
//! Le parsing est fait en flux (`quick-xml`, événement par événement) : un
//! dump national pèse couramment 50 Mo décompressés, en charger l'arbre DOM
//! complet coûterait plusieurs centaines de mégaoctets dans le conteneur.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use chrono::{DateTime, NaiveDate, TimeDelta, Utc};
use futures::future::{BoxFuture, FutureExt, Shared};
use quick_xml::Reader;
use quick_xml::events::{BytesStart, Event};
use regex::Regex;
use crate::dart_time::utc_iso;
use crate::util::log_redactor::redact_url;
/// Index : clé de chaîne normalisée → programmes triés par heure de début.
///
/// Les clés d'alias (nom affiché, clé souple) partagent la même liste via
/// l'`Arc` : pas de copie des programmes par alias.
pub type XmltvIndex = HashMap<String, Arc<Vec<XmltvProgramme>>>;
/// Un programme du guide, horaires en UTC.
#[derive(Clone, Debug, PartialEq)]
pub struct XmltvProgramme {
pub title: String,
pub description: String,
pub start: DateTime<Utc>,
pub stop: DateTime<Utc>,
}
impl XmltvProgramme {
/// Forme JSON servie par l'API EPG (mêmes clés que le guide du panneau).
pub fn to_json(&self, channel_id: &str) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"description": self.description,
"start": utc_iso(self.start),
"end": utc_iso(self.stop),
"channel_id": channel_id,
})
}
}
/// Réglages du service.
#[derive(Clone, Debug)]
pub struct XmltvConfig {
/// Dumps XMLTV à agréger, dans l'ordre de priorité décroissante.
pub source_urls: Vec<String>,
/// Fréquence de rafraîchissement de l'index.
pub refresh_interval: Duration,
/// Les programmes terminés depuis plus longtemps que cette durée sont
/// écartés à l'indexation : personne ne consulte le guide d'hier, et les
/// garder double la taille de l'index.
pub retention: Duration,
/// Les programmes qui commencent au-delà de cet horizon sont écartés à
/// l'indexation.
///
/// Un dump national couvre souvent sept jours pour un millier de chaînes ;
/// tout garder en mémoire dans le conteneur coûte des centaines de
/// mégaoctets pour un guide qui n'affiche que le programme courant et les
/// suivants.
pub horizon: Duration,
/// Délai minimal entre deux tentatives quand aucune source n'a répondu.
///
/// Sans ce recul, une source morte était retéléchargée à chaque
/// consultation du guide — une requête sortante par chaîne affichée, soit
/// exactement le flot qu'un index est censé éviter.
pub retry_backoff: Duration,
/// Plafond de téléchargement d'un dump. Le guide est facultatif : mieux
/// vaut abandonner et servir le panneau que faire attendre l'utilisateur.
pub download_timeout: Duration,
}
impl XmltvConfig {
/// Réglages par défaut : rafraîchissement 6 h, rétention 6 h, horizon
/// 48 h, recul 15 min après échec, téléchargement plafonné à 5 min.
pub fn new(source_urls: Vec<String>) -> Self {
Self {
source_urls,
refresh_interval: Duration::from_secs(6 * 3600),
retention: Duration::from_secs(6 * 3600),
horizon: Duration::from_secs(48 * 3600),
retry_backoff: Duration::from_secs(15 * 60),
download_timeout: Duration::from_secs(5 * 60),
}
}
}
type Logger = Arc<dyn Fn(&str) + Send + Sync>;
type RefreshFuture = Shared<BoxFuture<'static, ()>>;
/// Service d'index XMLTV, partagé via `Arc<XmltvEpgService>`.
pub struct XmltvEpgService {
inner: Arc<Inner>,
}
struct Inner {
config: XmltvConfig,
client: reqwest::Client,
state: Mutex<State>,
log: Logger,
}
#[derive(Default)]
struct State {
index: Arc<XmltvIndex>,
indexed_at: Option<Instant>,
failed_at: Option<Instant>,
/// Rafraîchissement en cours, partagé par les appels concurrents.
refresh_in_flight: Option<RefreshFuture>,
}
impl Inner {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Libère la place du rafraîchissement en cours quand il se termine — même
/// s'il panique, sinon plus aucun rafraîchissement ne serait jamais relancé.
struct RefreshGuard(Arc<Inner>);
impl Drop for RefreshGuard {
fn drop(&mut self) {
self.0.lock().refresh_in_flight = None;
}
}
/// Préfixe pays des noms de panneau : `FR - `, `FR: `, `|FR| `, `[FR] `…
static COUNTRY_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"^{W}*[\|\[\(]?{W}*([A-Za-z]{{2,3}}){W}*[\|\]\)]?{W}*[-:|]{W}*|^{W}*[\|\[\(]{W}*([A-Za-z]{{2,3}}){W}*[\|\]\)]{W}*",
W = JS_SPACE_CLASS
))
.unwrap()
});
/// Marqueurs de qualité ou de source en fin de nom, sans rapport avec la
/// chaîne elle-même.
static QUALITY_SUFFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?i)({W}+|^)(fhd|uhd|hd|sd|hq|lq|4k|8k|hevc|h\.?265|h\.?264|1080p?|720p?|50fps|60fps|backup|raw|vip|multi|\(backup\)|\(multi\)){W}*$",
W = JS_SPACE_CLASS
))
.unwrap()
});
/// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
/// chiffre, ni ponctuation utile.
static DECORATIVE_SYMBOLS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!(r"[^\p{{L}}\p{{N}}{JS_SPACE_CHARS}+.&\-()]")).unwrap());
/// Abréviations du panneau pour les chaînes publiques (« F3 ALPES »), là
/// où les dumps écrivent `France.3.-.Alpes.fr`.
///
/// Le `\b` final de l'expression Dart (`^F\s?([2-5])\b`) est une limite de
/// mot ASCII : il est vérifié à la main dans [`XmltvEpgService::clean_channel_name`],
/// le `\b` de `regex` étant Unicode.
static FRANCE_ABBREV: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!(r"(?i)^F{JS_SPACE_CLASS}?([2-5])")).unwrap());
/// Caractères de `\s` en JavaScript/Dart (diffère du `\s` Unicode de
/// `regex` : U+FEFF en plus, U+0085 en moins).
const JS_SPACE_CHARS: &str =
r"\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}";
const JS_SPACE_CLASS: &str = r"[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]";
fn is_js_space(c: char) -> bool {
matches!(
c,
'\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}
/// `String.trim()` de Dart : espaces Unicode plus le BOM.
fn dart_trim(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || c == '\u{FEFF}')
}
/// Jetons sans valeur distinctive pour reconnaître une chaîne.
const LOOSE_NOISE: &[&str] = &["channel", "tv", "hd", "fhd", "uhd", "sd", "4k", "hevc"];
/// Codes pays en fin d'identifiant XMLTV (`TF1.fr`, `RTS1.ch`).
const COUNTRY_CODES: &[&str] = &[
"fr", "be", "ch", "lu", "mc", "ca", "uk", "us", "es", "it", "de", "pt", "nl",
];
fn fold_accent(c: char) -> char {
match c {
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a',
'è' | 'é' | 'ê' | 'ë' => 'e',
'ì' | 'í' | 'î' | 'ï' => 'i',
'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
'ù' | 'ú' | 'û' | 'ü' => 'u',
'ç' => 'c',
'ñ' => 'n',
other => other,
}
}
fn loose_index_key(loose: &str) -> String {
if loose.is_empty() {
String::new()
} else {
format!("~{loose}")
}
}
/// Positions des « +1 » (`\+\s*1(?![0-9])` côté Dart, écrit à la main faute
/// de look-ahead dans `regex`). Correspondances disjointes, de gauche à
/// droite, comme `replaceAll`.
fn plus_one_spans(s: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut i = 0;
while i < s.len() {
if s[i..].starts_with('+') {
let mut j = i + 1;
while let Some(c) = s[j..].chars().next() {
if !is_js_space(c) {
break;
}
j += c.len_utf8();
}
if s[j..].starts_with('1') {
let end = j + 1;
if !matches!(s[end..].chars().next(), Some('0'..='9')) {
spans.push((i, end));
i = end;
continue;
}
}
}
i += s[i..].chars().next().map_or(1, char::len_utf8);
}
spans
}
fn replace_plus_one(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last = 0;
for (start, end) in plus_one_spans(s) {
out.push_str(&s[last..start]);
out.push(' ');
last = end;
}
out.push_str(&s[last..]);
out
}
impl XmltvEpgService {
pub fn new(config: XmltvConfig, client: reqwest::Client) -> Self {
Self::with_logger(config, client, Arc::new(|line: &str| println!("{line}")))
}
fn with_logger(config: XmltvConfig, client: reqwest::Client, log: Logger) -> Self {
Self {
inner: Arc::new(Inner {
config,
client,
state: Mutex::new(State::default()),
log,
}),
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn has_data(&self) -> bool {
!self.inner.lock().index.is_empty()
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn channel_count(&self) -> usize {
self.inner.lock().index.len()
}
/// Programmes d'une chaîne, ou liste vide si inconnue.
///
/// `channel_id` est l'`epg_channel_id` renvoyé par Xtream ; `display_name`
/// est le nom de la chaîne, utilisé en second recours car les dumps publics
/// construisent souvent leur identifiant à partir du nom affiché.
pub async fn programmes_for(
&self,
channel_id: Option<&str>,
display_name: Option<&str>,
) -> Vec<XmltvProgramme> {
self.ensure_fresh().await;
let index = Arc::clone(&self.inner.lock().index);
if let Some(direct) = lookup(&index, channel_id, display_name) {
return direct.as_ref().clone();
}
// Chaîne décalée sans guide propre (« TF1 +1 » absent des dumps) : le
// guide de la chaîne principale, une heure plus tard, est exact.
if let Some(name) = display_name
&& !plus_one_spans(name).is_empty()
{
let base = replace_plus_one(name);
let hour = TimeDelta::hours(1);
return lookup(&index, None, Some(&base))
.map(|hits| {
hits.iter()
.map(|p| XmltvProgramme {
title: p.title.clone(),
description: p.description.clone(),
start: p.start + hour,
stop: p.stop + hour,
})
.collect()
})
.unwrap_or_default();
}
Vec::new()
}
/// Clé « souple » : sans code pays final ni mots génériques
/// (« AB3.Channel.fr » et « AB3 » → `ab3`).
///
/// Servie dans un espace de noms à part, consultée en tout dernier : elle
/// rattrape les variantes d'écriture sans fausser les correspondances
/// exactes.
pub fn loose_key(raw: Option<&str>) -> String {
let Some(raw) = raw else {
return String::new();
};
let folded: String = raw.to_lowercase().chars().map(fold_accent).collect();
let mut tokens: Vec<&str> = folded
.split(|c: char| !matches!(c, 'a'..='z' | '0'..='9'))
.filter(|t| !t.is_empty())
.collect();
if tokens.len() > 1 && tokens.last().is_some_and(|t| COUNTRY_CODES.contains(t)) {
tokens.pop();
}
tokens.retain(|t| !LOOSE_NOISE.contains(t));
tokens.concat()
}
/// Clés candidates pour retrouver une chaîne du panneau par son nom.
///
/// POURQUOI : en prod, 59 % des chaînes françaises n'ont pas
/// d'`epg_channel_id`. Leur nom brut (« FR - TF1 FHD ◉ » → `frtf1fhd`) ne
/// correspond à aucune entrée XMLTV (`TF1.fr`, « TF1 »). On retire le
/// préfixe pays, les marqueurs de qualité et les symboles, puis on tente
/// aussi la forme `nom.pays` qu'utilisent les dumps publics.
///
/// Le décalage horaire (« +1 ») est conservé : TF1 +1 n'a pas le guide
/// de TF1.
pub fn name_keys(name: &str) -> Vec<String> {
let mut candidates = vec![Self::normalize_key(Some(name))];
let (cleaned, country) = Self::clean_channel_name(name);
let base = Self::normalize_key(Some(&cleaned));
if !base.is_empty() {
let suffixed = format!("{base}{}", country.as_deref().unwrap_or("fr"));
candidates.push(base);
candidates.push(suffixed);
}
// Dédoublonnage en gardant l'ordre (le `toSet()` de Dart est ordonné).
let mut seen = HashSet::new();
candidates
.into_iter()
.filter(|k| !k.is_empty() && seen.insert(k.clone()))
.collect()
}
/// Nom de chaîne débarrassé du préfixe pays, des symboles et des
/// marqueurs de qualité ; second élément = code pays du préfixe, s'il y
/// en a.
pub fn clean_channel_name(raw: &str) -> (String, Option<String>) {
let mut cleaned: &str = raw;
let mut country = None;
if let Some(caps) = COUNTRY_PREFIX.captures(raw) {
country = caps
.get(1)
.or_else(|| caps.get(2))
.map(|m| m.as_str().to_lowercase());
cleaned = &raw[caps.get(0).map_or(0, |m| m.end())..];
}
// Symboles décoratifs (◉, ᴴᴰ, ★…) : tout ce qui n'est ni lettre, ni
// chiffre, ni ponctuation utile.
let mut cleaned = dart_trim(&DECORATIVE_SYMBOLS.replace_all(cleaned, " ")).to_string();
loop {
let previous = cleaned.clone();
cleaned = dart_trim(&QUALITY_SUFFIX.replacen(&cleaned, 1, "")).to_string();
if cleaned == previous || cleaned.is_empty() {
break;
}
}
if let Some(caps) = FRANCE_ABBREV.captures(&cleaned) {
let whole = caps.get(0).unwrap();
// Limite de mot ASCII après le chiffre (le `\b` de Dart).
let boundary = !matches!(
cleaned[whole.end()..].chars().next(),
Some('a'..='z' | 'A'..='Z' | '0'..='9' | '_')
);
if boundary {
cleaned = format!("France {}{}", &caps[1], &cleaned[whole.end()..]);
}
}
(cleaned, country)
}
/// Recharge l'index s'il est absent ou périmé. Les appels concurrents
/// partagent le même téléchargement.
///
/// Le rafraîchissement tourne dans sa propre tâche : comme une `Future`
/// Dart, il va au bout même si l'appelant abandonne (requête HTTP coupée).
pub async fn ensure_fresh(&self) {
// Sans source, `_refresh` Dart ne faisait rien : inutile de lancer une
// tâche.
if self.inner.config.source_urls.is_empty() {
return;
}
let pending = {
let mut state = self.inner.lock();
if state
.indexed_at
.is_some_and(|at| at.elapsed() < self.inner.config.refresh_interval)
{
return;
}
if state
.failed_at
.is_some_and(|at| at.elapsed() < self.inner.config.retry_backoff)
{
return;
}
match &state.refresh_in_flight {
Some(pending) => pending.clone(),
None => {
let inner = Arc::clone(&self.inner);
let task = tokio::spawn(async move {
let _guard = RefreshGuard(Arc::clone(&inner));
refresh(&inner).await;
});
let pending = async move {
let _ = task.await;
}
.boxed()
.shared();
state.refresh_in_flight = Some(pending.clone());
pending
}
}
};
pending.await;
}
/// Exposé pour les tests. Un XML illisible (où Dart levait une exception)
/// donne un index vide.
#[cfg_attr(not(test), allow(dead_code))]
pub fn parse_for_test(&self, xml: &str) -> XmltvIndex {
parse(xml, self.inner.config.retention, self.inner.config.horizon).unwrap_or_default()
}
/// Clé de correspondance : minuscules, sans ponctuation ni accents.
///
/// Les dumps publics écrivent `France.2.fr` là où le panneau annonce
/// `France2.fr` ; sans normalisation, la moitié des chaînes ne trouvent
/// jamais leur guide.
pub fn normalize_key(raw: Option<&str>) -> String {
let Some(raw) = raw else {
return String::new();
};
raw.to_lowercase()
.chars()
.map(fold_accent)
.filter(|c| matches!(c, 'a'..='z' | '0'..='9'))
.collect()
}
/// « 20260811200000 +0200 » → instant UTC.
///
/// Reproduit `DateTime.tryParse` de Dart : 14 chiffres ASCII exigés, les
/// valeurs hors bornes (mois 13, jour 32…) étant reportées sur l'unité
/// supérieure plutôt que rejetées. Longueurs et positions comptées en
/// unités UTF-16, comme en Dart.
pub fn parse_xmltv_date(raw: Option<&str>) -> Option<DateTime<Utc>> {
let raw = raw?;
let units: Vec<u16> = raw.encode_utf16().collect();
if units.len() < 14 {
return None;
}
let digits = &units[..14];
if !digits
.iter()
.all(|u| (b'0' as u16..=b'9' as u16).contains(u))
{
return None;
}
let num = |range: std::ops::Range<usize>| -> i64 {
digits[range]
.iter()
.fold(0i64, |acc, u| acc * 10 + i64::from(*u - b'0' as u16))
};
let base = dart_utc(
num(0..4),
num(4..6),
num(6..8),
num(8..10),
num(10..12),
num(12..14),
)?;
if units.len() < 20 {
return Some(base);
}
let offset = &units[15..20];
let sign: i64 = if offset[0] == b'-' as u16 { -1 } else { 1 };
let (Some(hours), Some(minutes)) = (
dart_int_try_parse(&String::from_utf16_lossy(&offset[1..3])),
dart_int_try_parse(&String::from_utf16_lossy(&offset[3..5])),
) else {
return Some(base);
};
base.checked_sub_signed(TimeDelta::hours(sign * hours) + TimeDelta::minutes(sign * minutes))
}
}
/// `DateTime.utc(y, m, d, h, min, s)` de Dart, avec report des valeurs hors
/// bornes (mois 0 → décembre précédent, jour 0 → veille du 1er, etc.).
fn dart_utc(
year: i64,
month: i64,
day: i64,
hour: i64,
minute: i64,
second: i64,
) -> Option<DateTime<Utc>> {
let months = year * 12 + (month - 1);
let y = i32::try_from(months.div_euclid(12)).ok()?;
let m = u32::try_from(months.rem_euclid(12) + 1).ok()?;
let first = NaiveDate::from_ymd_opt(y, m, 1)?.and_hms_opt(0, 0, 0)?;
let delta = TimeDelta::days(day - 1)
+ TimeDelta::hours(hour)
+ TimeDelta::minutes(minute)
+ TimeDelta::seconds(second);
Some(first.checked_add_signed(delta)?.and_utc())
}
/// `int.tryParse` de Dart (base 10) : espaces autour tolérés, signe
/// facultatif.
fn dart_int_try_parse(raw: &str) -> Option<i64> {
let s = dart_trim(raw);
let (negative, digits) = match s.as_bytes().first() {
Some(b'-') => (true, &s[1..]),
Some(b'+') => (false, &s[1..]),
_ => (false, s),
};
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let value: i64 = digits.parse().ok()?;
Some(if negative { -value } else { value })
}
fn lookup(
index: &XmltvIndex,
channel_id: Option<&str>,
display_name: Option<&str>,
) -> Option<Arc<Vec<XmltvProgramme>>> {
let mut keys = vec![XmltvEpgService::normalize_key(channel_id)];
if let Some(name) = display_name {
keys.extend(XmltvEpgService::name_keys(name));
}
// Clés souples en dernier recours, dans leur propre espace de noms.
keys.push(loose_index_key(&XmltvEpgService::loose_key(channel_id)));
if let Some(name) = display_name {
let (cleaned, _) = XmltvEpgService::clean_channel_name(name);
keys.push(loose_index_key(&XmltvEpgService::loose_key(Some(&cleaned))));
}
keys.iter()
.filter(|k| !k.is_empty())
.find_map(|k| index.get(k).filter(|hit| !hit.is_empty()).cloned())
}
/// Ligne de journal d'une source en échec. L'URL (déjà masquée) peut être le
/// `xmltv.php` du panneau, identifiants de l'abonné en clair ; le texte de
/// l'erreur est masqué aussi, les erreurs HTTP recopiant l'URL demandée.
fn failure_line(safe_url: &str, error: &str) -> String {
format!("[XmltvEpg] {safe_url} : échec ({})", redact_url(error))
}
async fn refresh(inner: &Arc<Inner>) {
let config = &inner.config;
if config.source_urls.is_empty() {
return;
}
let mut merged = XmltvIndex::new();
let mut ok = 0;
for url in &config.source_urls {
// La source peut être le `xmltv.php` du panneau, dont l'URL porte les
// identifiants de l'abonné en clair : ne jamais la journaliser telle
// quelle (elle finirait dans `docker logs`). Le texte de l'erreur est
// masqué aussi, reqwest recopiant l'URL demandée.
let safe_url = redact_url(url);
match download_and_parse(inner, url).await {
Ok(parsed) => {
let count = parsed.len();
// Première source servie gagne : les suivantes ne comblent que
// les chaînes encore absentes.
if merged.is_empty() {
merged = parsed;
} else {
for (key, programmes) in parsed {
merged.entry(key).or_insert(programmes);
}
}
ok += 1;
(inner.log)(&format!("[XmltvEpg] {safe_url} : {count} chaînes indexées"));
}
Err(error) => (inner.log)(&failure_line(&safe_url, &error)),
}
}
if ok == 0 {
// Garder l'index précédent plutôt que de servir un guide vide.
inner.lock().failed_at = Some(Instant::now());
(inner.log)("[XmltvEpg] aucune source disponible, index précédent conservé");
return;
}
let count = merged.len();
let previous = {
let mut state = inner.lock();
state.indexed_at = Some(Instant::now());
state.failed_at = None;
std::mem::replace(&mut state.index, Arc::new(merged))
};
(inner.log)(&format!("[XmltvEpg] index prêt : {count} chaînes"));
// Libérer l'ancien index (des centaines de milliers de chaînes de
// caractères) hors de la boucle asynchrone.
tokio::task::spawn_blocking(move || drop(previous));
}
/// Télécharge `url` puis l'indexe sur un thread bloquant dédié.
///
/// Décompresser et parser un dump de plusieurs milliers de chaînes sur la
/// boucle asynchrone la gelait pendant des secondes. Plus rien d'autre ne
/// répondait — en particulier le relais `turbo.ts`, qui cessait d'envoyer des
/// octets au lecteur : la lecture se coupait systématiquement au démarrage,
/// au moment précis où l'écran des chaînes demandait le guide.
async fn download_and_parse(inner: &Inner, url: &str) -> Result<XmltvIndex, String> {
let timeout = inner.config.download_timeout;
let body = match tokio::time::timeout(timeout, download(&inner.client, url)).await {
Ok(result) => result?,
Err(_) => {
return Err(format!(
"TimeoutException after {}: Future not completed",
dart_duration(timeout)
));
}
};
let retention = inner.config.retention;
let horizon = inner.config.horizon;
tokio::task::spawn_blocking(move || decode_and_parse(&body, retention, horizon))
.await
.map_err(|e| e.to_string())?
}
async fn download(client: &reqwest::Client, url: &str) -> Result<bytes::Bytes, String> {
let response = client.get(url).send().await.map_err(|e| error_text(&e))?;
let status = response.status().as_u16();
if status != 200 {
return Err(format!("HttpException: HTTP {status}"));
}
response.bytes().await.map_err(|e| error_text(&e))
}
/// Texte d'une erreur avec ses causes (reqwest cache la cause réelle —
/// connexion refusée, DNS… — derrière un message générique).
fn error_text(error: &dyn std::error::Error) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
/// Format `Duration.toString()` de Dart : `0:05:00.000000`.
fn dart_duration(d: Duration) -> String {
let micros = d.as_micros();
let hours = micros / 3_600_000_000;
let minutes = (micros / 60_000_000) % 60;
let seconds = (micros / 1_000_000) % 60;
let fraction = micros % 1_000_000;
format!("{hours}:{minutes:02}:{seconds:02}.{fraction:06}")
}
fn decode_and_parse(
raw: &[u8],
retention: Duration,
horizon: Duration,
) -> Result<XmltvIndex, String> {
// Beaucoup de miroirs servent du .gz sans en-tête Content-Encoding : on
// regarde le nombre magique plutôt que de se fier aux en-têtes.
let inflated;
let bytes = if raw.len() > 2 && raw[0] == 0x1f && raw[1] == 0x8b {
let mut out = Vec::new();
flate2::read::MultiGzDecoder::new(raw)
.read_to_end(&mut out)
.map_err(|e| format!("FormatException: {e}"))?;
inflated = out;
&inflated[..]
} else {
raw
};
parse(&String::from_utf8_lossy(bytes), retention, horizon)
}
#[derive(Clone, Copy, PartialEq)]
enum TextTag {
Title,
Desc,
DisplayName,
}
/// Valeur d'un attribut (entités décodées), ou chaîne vide s'il est absent.
fn attr(event: &BytesStart<'_>, name: &[u8]) -> String {
let mut attributes = event.attributes();
attributes.with_checks(false);
for attribute in attributes {
// Attribut mal formé : traité comme absent plutôt que de rejeter le
// dump entier.
let Ok(attribute) = attribute else { break };
if attribute.key.as_ref() == name {
return decode_entities(&String::from_utf8_lossy(&attribute.value)).into_owned();
}
}
String::new()
}
/// Décodage des entités comme le paquet Dart `xml` : les cinq entités XML et
/// les références numériques ; toute entité inconnue ou invalide reste telle
/// quelle (une entité fautive ne doit pas faire échouer tout le dump).
fn decode_entities(input: &str) -> Cow<'_, str> {
let Some(first) = input.find('&') else {
return Cow::Borrowed(input);
};
let mut out = String::with_capacity(input.len());
out.push_str(&input[..first]);
let mut rest = &input[first..];
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
rest = &rest[amp..];
let Some(semicolon) = rest.find(';') else {
// Plus aucun `;` : aucune entité ne peut suivre.
break;
};
match decode_entity(&rest[1..semicolon]) {
Some(c) => {
out.push(c);
rest = &rest[semicolon + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
Cow::Owned(out)
}
fn decode_entity(entity: &str) -> Option<char> {
match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => {
let number = entity.strip_prefix('#')?;
let code = if let Some(hex) = number.strip_prefix(['x', 'X']) {
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(hex, 16).ok()?
} else {
if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
number.parse().ok()?
};
char::from_u32(code)
}
}
}
fn parse(xml: &str, retention: Duration, horizon: Duration) -> Result<XmltvIndex, String> {
let now = Utc::now();
let cutoff = TimeDelta::from_std(retention)
.ok()
.and_then(|d| now.checked_sub_signed(d))
.unwrap_or(DateTime::<Utc>::MIN_UTC);
let limit = TimeDelta::from_std(horizon)
.ok()
.and_then(|d| now.checked_add_signed(d))
.unwrap_or(DateTime::<Utc>::MAX_UTC);
let mut by_channel: HashMap<String, Vec<XmltvProgramme>> = HashMap::new();
// Alias : plusieurs dumps déclarent <channel id="X"> avec un ou plusieurs
// <display-name> différents de l'identifiant (« TF1 HD », « TF1 »). On
// les indexe TOUS : seul le premier l'était, et c'était souvent la
// variante la moins proche du nom annoncé par le panneau.
// Ordre d'insertion conservé (`Map` ordonnée en Dart) : l'application
// des alias en dépend.
let mut aliases: Vec<(String, String)> = Vec::new();
let mut alias_names: HashSet<String> = HashSet::new();
let mut put_alias = |name: String, id: &str| {
if alias_names.insert(name.clone()) {
aliases.push((name, id.to_string()));
}
};
let mut display_names: Vec<String> = Vec::new();
let mut channel_id: Option<String> = None;
let mut programme_channel: Option<String> = None;
let mut start: Option<DateTime<Utc>> = None;
let mut stop: Option<DateTime<Utc>> = None;
let mut current_tag: Option<TextTag> = None;
let mut title = String::new();
let mut desc = String::new();
let mut display_name = String::new();
// Les programmes d'une chaîne se suivent : mémoriser la dernière clé
// évite de renormaliser le même identifiant des milliers de fois.
let mut last_key: Option<(String, String)> = None;
let mut reader = Reader::from_str(xml);
{
// Lecture non validante, comme `parseEvents` côté Dart.
let config = reader.config_mut();
config.trim_text(false);
config.check_end_names = false;
config.allow_unmatched_ends = true;
config.expand_empty_elements = false;
}
loop {
let event = reader
.read_event()
.map_err(|e| format!("XmlParserException: {e} at {}", reader.error_position()))?;
match event {
Event::Eof => break,
Event::Start(ref e) | Event::Empty(ref e) => {
match e.name().as_ref() {
b"channel" => {
channel_id = Some(attr(e, b"id"));
display_name.clear();
display_names.clear();
}
b"programme" => {
programme_channel = Some(attr(e, b"channel"));
start = XmltvEpgService::parse_xmltv_date(Some(&attr(e, b"start")));
stop = XmltvEpgService::parse_xmltv_date(Some(&attr(e, b"stop")));
title.clear();
desc.clear();
}
b"title" => current_tag = Some(TextTag::Title),
b"desc" => current_tag = Some(TextTag::Desc),
b"display-name" => current_tag = Some(TextTag::DisplayName),
_ => {}
}
if matches!(event, Event::Empty(_)) {
current_tag = None;
}
}
Event::Text(ref t) => {
if let Some(tag) = current_tag {
let raw = String::from_utf8_lossy(t.as_ref());
let text = decode_entities(&raw);
match tag {
TextTag::Title => title.push_str(&text),
TextTag::Desc => desc.push_str(&text),
TextTag::DisplayName => display_name.push_str(&text),
}
}
}
Event::CData(ref c) => {
if let Some(tag) = current_tag {
let text = String::from_utf8_lossy(c.as_ref());
match tag {
TextTag::Title => title.push_str(&text),
TextTag::Desc => desc.push_str(&text),
TextTag::DisplayName => display_name.push_str(&text),
}
}
}
Event::End(ref e) => {
match e.name().as_ref() {
b"display-name" => {
display_names.push(std::mem::take(&mut display_name));
}
b"channel" => {
let id = XmltvEpgService::normalize_key(channel_id.as_deref());
for raw in &display_names {
let name = XmltvEpgService::normalize_key(Some(raw));
if !id.is_empty() && !name.is_empty() && id != name {
put_alias(name, &id);
}
}
// Clés souples (espace de noms « ~ ») : premier arrivé
// gagne.
if !id.is_empty() {
let raws = std::iter::once(channel_id.as_deref())
.chain(display_names.iter().map(|s| Some(s.as_str())));
for raw in raws {
let loose = loose_index_key(&XmltvEpgService::loose_key(raw));
if !loose.is_empty() {
put_alias(loose, &id);
}
}
}
channel_id = None;
}
b"programme" => {
if let (Some(channel), Some(s), Some(e)) = (&programme_channel, start, stop)
&& e > cutoff
&& s < limit
{
let key = match &last_key {
Some((raw, key)) if raw == channel => key.clone(),
_ => {
let key = XmltvEpgService::normalize_key(Some(channel));
last_key = Some((channel.clone(), key.clone()));
key
}
};
if !key.is_empty() {
by_channel.entry(key).or_default().push(XmltvProgramme {
title: dart_trim(&title).to_string(),
description: dart_trim(&desc).to_string(),
start: s,
stop: e,
});
}
}
programme_channel = None;
start = None;
stop = None;
}
_ => {}
}
current_tag = None;
}
_ => {}
}
}
let mut index: XmltvIndex = by_channel
.into_iter()
.map(|(key, mut list)| {
list.sort_by_key(|a| a.start);
(key, Arc::new(list))
})
.collect();
// Rendre les chaînes atteignables aussi par leur nom affiché.
for (name, id) in aliases {
if let Some(programmes) = index.get(&id).cloned() {
index.entry(name).or_insert(programmes);
}
}
Ok(index)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::http::{StatusCode, Uri};
use chrono::Timelike;
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
type Svc = XmltvEpgService;
fn stamp(d: DateTime<Utc>) -> String {
d.format("%Y%m%d%H%M%S +0000").to_string()
}
/// Extrait représentatif d'un dump XMLTV public : décalage horaire
/// explicite, identifiant ponctué côté source là où le panneau annonce
/// `France2.fr`, et un `display-name` distinct de l'identifiant.
fn fixture(start: DateTime<Utc>, stop: DateTime<Utc>) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<tv>
<channel id="France.2.fr"><display-name>FR - FRANCE 2</display-name></channel>
<programme start="{}" stop="{}" channel="France.2.fr">
<title lang="fr">Journal de 20h</title>
<desc lang="fr">L'info du soir</desc>
</programme>
</tv>
"#,
stamp(start),
stamp(stop)
)
}
fn offline_service() -> Svc {
Svc::new(XmltvConfig::new(vec![]), reqwest::Client::new())
}
/// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les
/// appels et renvoie `(statut, corps)`.
async fn serve(
respond: impl Fn(&Uri) -> (StatusCode, bytes::Bytes) + Send + Sync + 'static,
) -> (String, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let respond = Arc::new(respond);
let counter = Arc::clone(&calls);
let app = Router::new().fallback(move |uri: Uri| {
let respond = Arc::clone(&respond);
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
respond(&uri)
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), calls)
}
async fn service_with(dump: String) -> (Svc, Arc<AtomicUsize>) {
let body = bytes::Bytes::from(dump.into_bytes());
let (base, calls) = serve(move |_| (StatusCode::OK, body.clone())).await;
let svc = Svc::new(
XmltvConfig::new(vec![format!("{base}/epg.xml")]),
reqwest::Client::new(),
);
(svc, calls)
}
fn now_s() -> DateTime<Utc> {
Utc::now().with_nanosecond(0).unwrap()
}
// --- normalizeKey ---
#[test]
fn normalize_rapproche_les_identifiants_ponctues_differemment() {
// Le cœur du repli : sans cette normalisation, `France2.fr` du panneau
// et `France.2.fr` du dump ne se rencontrent jamais.
assert_eq!(
Svc::normalize_key(Some("France2.fr")),
Svc::normalize_key(Some("France.2.fr"))
);
}
#[test]
fn normalize_replie_la_casse_et_les_accents() {
assert_eq!(Svc::normalize_key(Some("Chérie 25")), "cherie25");
}
#[test]
fn normalize_rend_une_cle_vide_sur_entree_nulle_ou_sans_caractere_utile() {
assert_eq!(Svc::normalize_key(None), "");
assert_eq!(Svc::normalize_key(Some("...")), "");
}
// --- nameKeys ---
fn has(keys: &[String], k: &str) -> bool {
keys.iter().any(|x| x == k)
}
#[test]
fn name_keys_retire_le_prefixe_pays_et_les_marqueurs_de_qualite() {
assert!(has(&Svc::name_keys("FR - TF1 FHD ◉"), "tf1"));
assert!(has(&Svc::name_keys("FR - FRANCE 4 FHD"), "france4"));
assert!(has(&Svc::name_keys("|FR| M6 HD"), "m6"));
assert!(has(&Svc::name_keys("[FR] Arte UHD 4K"), "arte"));
assert!(has(&Svc::name_keys("FR: RMC Story HEVC"), "rmcstory"));
}
#[test]
fn name_keys_propose_aussi_la_forme_nom_pays() {
// Les dumps publics identifient souvent la chaîne par `TF1.fr`.
assert!(has(&Svc::name_keys("FR - TF1 FHD"), "tf1fr"));
}
#[test]
fn name_keys_garde_la_variante_decalee_distincte() {
assert!(!has(&Svc::name_keys("FR - TF1 +1 FHD"), "tf1"));
}
#[test]
fn name_keys_ne_reduit_pas_un_nom_court_a_rien() {
assert!(has(&Svc::name_keys("M6"), "m6"));
assert!(!has(&Svc::name_keys("HD"), ""));
}
#[test]
fn clean_channel_name_rend_le_pays_et_developpe_f3() {
assert_eq!(
Svc::clean_channel_name("FR - F3 ALPES HD"),
("France 3 ALPES".to_string(), Some("fr".to_string()))
);
assert_eq!(Svc::clean_channel_name("TF1"), ("TF1".to_string(), None));
// `\b` ASCII de Dart : « F2é » se développe, « F2x » non.
assert_eq!(Svc::clean_channel_name("F2é").0, "France 2é");
assert_eq!(Svc::clean_channel_name("F2x").0, "F2x");
}
#[test]
fn plus_one_se_comporte_comme_le_lookahead_dart() {
assert_eq!(replace_plus_one("TF1 +1 FHD"), "TF1 FHD");
assert_eq!(replace_plus_one("TF1 + 1"), "TF1 ");
assert!(plus_one_spans("Canal +10").is_empty());
assert!(plus_one_spans("Canal+").is_empty());
assert_eq!(replace_plus_one("A+1+1"), "A ");
}
// --- correspondances élargies (chaînes vides mesurées en prod) ---
fn channel(id: &str, name: &str, title: &str, start: DateTime<Utc>) -> String {
format!(
r#"
<channel id="{id}"><display-name>{name}</display-name></channel>
<programme start="{}" stop="{}" channel="{id}"><title>{title}</title></programme>"#,
stamp(start),
stamp(start + TimeDelta::hours(1))
)
}
#[tokio::test]
async fn f3_alpes_du_panneau_trouve_france_3_alpes() {
let now = now_s();
let (svc, _) = service_with(format!(
"<tv>{}</tv>",
channel("France.3.-.Alpes.fr", "France 3 Alpes", "JT Alpes", now)
))
.await;
let hits = svc.programmes_for(None, Some("FR - F3 ALPES HD")).await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "JT Alpes");
}
#[tokio::test]
async fn ab3_trouve_ab3_channel_fr_par_cle_souple() {
let now = now_s();
let (svc, _) = service_with(format!(
"<tv>{}</tv>",
channel("AB3.Channel.fr", "AB3 Channel", "Série", now)
))
.await;
let hits = svc
.programmes_for(Some("AB3.fr"), Some("FR - AB3 FHD"))
.await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Série");
}
#[tokio::test]
async fn tf1_plus_1_reprend_le_guide_de_tf1_decale_d_une_heure() {
let now = now_s();
let (svc, _) = service_with(format!(
"<tv>{}</tv>",
channel("TF1.fr", "TF1", "Le 13h", now)
))
.await;
let hits = svc.programmes_for(None, Some("FR - TF1 +1 FHD")).await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Le 13h");
assert_eq!(hits[0].start, now + TimeDelta::hours(1));
}
#[tokio::test]
async fn une_chaine_plus_1_qui_a_son_propre_guide_le_garde() {
let now = now_s();
let (svc, _) = service_with(format!(
"<tv>{}{}</tv>",
channel("Boomerang.fr", "Boomerang", "Normal", now),
channel("Boomerang.+1.fr", "Boomerang +1", "Décalé", now)
))
.await;
let hits = svc.programmes_for(None, Some("FR - BOOMERANG +1")).await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Décalé");
}
// --- programmesFor par nom ---
#[tokio::test]
async fn trouve_le_guide_d_une_chaine_sans_epg_channel_id_par_son_nom() {
let now = now_s();
let dump = format!(
r#"<tv>
<channel id="TF1.fr"><display-name>TF1 HD</display-name><display-name>TF1</display-name></channel>
<programme start="{}" stop="{}" channel="TF1.fr">
<title>Le 13h</title>
</programme>
</tv>
"#,
stamp(now),
stamp(now + TimeDelta::hours(1))
);
let (svc, _) = service_with(dump).await;
let hits = svc.programmes_for(None, Some("FR - TF1 FHD ◉")).await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Le 13h");
}
#[test]
fn indexe_chaque_display_name_pas_seulement_le_premier() {
let now = now_s();
let index = offline_service().parse_for_test(&format!(
r#"<tv>
<channel id="x.fr"><display-name>Premier Nom</display-name><display-name>Second Nom</display-name></channel>
<programme start="{}" stop="{}" channel="x.fr"><title>T</title></programme>
</tv>"#,
stamp(now),
stamp(now + TimeDelta::hours(1))
));
assert!(index.contains_key(&Svc::normalize_key(Some("Second Nom"))));
// Les alias partagent la liste de la chaîne, sans copie.
assert!(Arc::ptr_eq(
&index[&Svc::normalize_key(Some("Second Nom"))],
&index["xfr"]
));
}
// --- parseXmltvDate ---
#[test]
fn date_applique_le_decalage_horaire_annonce() {
assert_eq!(
Svc::parse_xmltv_date(Some("20260811200000 +0200")),
Some(Utc.with_ymd_and_hms(2026, 8, 11, 18, 0, 0).unwrap())
);
}
#[test]
fn date_sans_decalage_traitee_comme_utc() {
assert_eq!(
Svc::parse_xmltv_date(Some("20260811200000")),
Some(Utc.with_ymd_and_hms(2026, 8, 11, 20, 0, 0).unwrap())
);
}
#[test]
fn date_rejette_une_valeur_tronquee() {
assert_eq!(Svc::parse_xmltv_date(Some("202608")), None);
assert_eq!(Svc::parse_xmltv_date(None), None);
assert_eq!(Svc::parse_xmltv_date(Some("2026O811200000")), None);
}
#[test]
fn date_decalage_negatif_et_report_hors_bornes() {
assert_eq!(
Svc::parse_xmltv_date(Some("20260811200000 -0530")),
Some(Utc.with_ymd_and_hms(2026, 8, 12, 1, 30, 0).unwrap())
);
// Comme `DateTime.parse` de Dart : le 32 août devient le 1er septembre.
assert_eq!(
Svc::parse_xmltv_date(Some("20260832000000")),
Some(Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap())
);
}
use chrono::TimeZone;
// --- parse ---
#[test]
fn indexe_un_programme_en_cours_et_decode_son_titre() {
let now = Utc::now();
let index = offline_service().parse_for_test(&fixture(
now - TimeDelta::minutes(10),
now + TimeDelta::minutes(20),
));
let programmes = index
.get(&Svc::normalize_key(Some("France2.fr")))
.expect("chaîne indexée");
assert_eq!(programmes.len(), 1);
assert_eq!(programmes[0].title, "Journal de 20h");
assert_eq!(programmes[0].description, "L'info du soir");
}
#[test]
fn rend_la_chaine_atteignable_par_son_nom_affiche() {
let now = Utc::now();
let index = offline_service().parse_for_test(&fixture(now, now + TimeDelta::minutes(20)));
assert!(index.contains_key(&Svc::normalize_key(Some("FR - FRANCE 2"))));
}
#[test]
fn ecarte_les_programmes_termines_hors_fenetre_de_retention() {
let old = Utc::now() - TimeDelta::days(2);
let index = offline_service().parse_for_test(&fixture(old, old + TimeDelta::minutes(30)));
assert!(index.is_empty());
}
#[test]
fn decode_entites_cdata_et_tolere_les_entites_inconnues() {
let now = now_s();
let index = offline_service().parse_for_test(&format!(
r#"<tv><programme start="{}" stop="{}" channel="A&amp;B.fr"><title>Tom &amp; Jerry &nbsp;&#233;&#x41; AT&T</title><desc><![CDATA[<b>brut</b> &amp;]]></desc><sub-title/></programme></tv>"#,
stamp(now),
stamp(now + TimeDelta::hours(1))
));
let programmes = index.get("abfr").expect("chaîne indexée");
assert_eq!(programmes[0].title, "Tom & Jerry &nbsp;éA AT&T");
assert_eq!(programmes[0].description, "<b>brut</b> &amp;");
}
#[test]
fn programmes_tries_par_heure_de_debut() {
let now = now_s();
let index = offline_service().parse_for_test(&format!(
r#"<tv><programme start="{}" stop="{}" channel="c"><title>B</title></programme><programme start="{}" stop="{}" channel="c"><title>A</title></programme></tv>"#,
stamp(now + TimeDelta::hours(1)),
stamp(now + TimeDelta::hours(2)),
stamp(now),
stamp(now + TimeDelta::hours(1)),
));
let titles: Vec<_> = index["c"].iter().map(|p| p.title.as_str()).collect();
assert_eq!(titles, ["A", "B"]);
}
#[test]
fn to_json_au_format_dart() {
let p = XmltvProgramme {
title: "T".into(),
description: "D".into(),
start: Utc.with_ymd_and_hms(2026, 8, 11, 18, 0, 0).unwrap(),
stop: Utc.with_ymd_and_hms(2026, 8, 11, 19, 0, 0).unwrap(),
};
assert_eq!(
p.to_json("42"),
serde_json::json!({
"title": "T",
"description": "D",
"start": "2026-08-11T18:00:00.000Z",
"end": "2026-08-11T19:00:00.000Z",
"channel_id": "42",
})
);
}
// --- ensureFresh ---
#[tokio::test]
async fn ne_retelecharge_pas_tant_que_l_index_est_frais() {
let now = Utc::now();
let (svc, calls) = service_with(fixture(now, now + TimeDelta::minutes(30))).await;
svc.ensure_fresh().await;
svc.ensure_fresh().await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(svc.has_data());
}
#[tokio::test]
async fn les_appels_concurrents_partagent_un_seul_telechargement() {
let now = Utc::now();
let (svc, calls) = service_with(fixture(now, now + TimeDelta::minutes(30))).await;
futures::future::join_all((0..8).map(|_| svc.ensure_fresh())).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(svc.has_data());
}
#[tokio::test]
async fn espace_les_tentatives_apres_un_echec() {
// Sans ce recul, une source morte était retéléchargée à chaque
// consultation du guide : une requête sortante par chaîne affichée.
let (base, calls) = serve(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
bytes::Bytes::from_static(b"nope"),
)
})
.await;
let svc = Svc::new(
XmltvConfig::new(vec![format!("{base}/epg.xml")]),
reqwest::Client::new(),
);
svc.ensure_fresh().await;
svc.ensure_fresh().await;
svc.ensure_fresh().await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(!svc.has_data());
}
#[tokio::test]
async fn conserve_l_index_precedent_apres_un_echec() {
let now = Utc::now();
let fail = Arc::new(std::sync::atomic::AtomicBool::new(false));
let body = bytes::Bytes::from(fixture(now, now + TimeDelta::minutes(30)).into_bytes());
let flag = Arc::clone(&fail);
let (base, _) = serve(move |_| {
if flag.load(Ordering::SeqCst) {
(StatusCode::BAD_GATEWAY, bytes::Bytes::new())
} else {
(StatusCode::OK, body.clone())
}
})
.await;
let mut config = XmltvConfig::new(vec![format!("{base}/epg.xml")]);
config.refresh_interval = Duration::ZERO;
let svc = Svc::new(config, reqwest::Client::new());
svc.ensure_fresh().await;
let before = svc.channel_count();
assert!(before > 0);
fail.store(true, Ordering::SeqCst);
svc.ensure_fresh().await;
assert_eq!(svc.channel_count(), before);
}
#[tokio::test]
async fn decompresse_un_dump_gzip_sans_en_tete() {
let now = Utc::now();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(fixture(now, now + TimeDelta::minutes(30)).as_bytes())
.unwrap();
let body = bytes::Bytes::from(gz.finish().unwrap());
let (base, _) = serve(move |_| (StatusCode::OK, body.clone())).await;
let svc = Svc::new(
XmltvConfig::new(vec![format!("{base}/epg.xml.gz")]),
reqwest::Client::new(),
);
let hits = svc.programmes_for(Some("France2.fr"), None).await;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Journal de 20h");
}
// --- réactivité du serveur ---
#[tokio::test(flavor = "current_thread")]
async fn l_indexation_ne_gele_pas_la_boucle_d_evenements() {
// Décoder et parser un dump de plusieurs milliers de chaînes sur la
// boucle asynchrone bloquait tout le reste pendant des secondes — y
// compris le relais `turbo.ts` des flux en cours, d'où des coupures
// systématiques au démarrage de la lecture. Runtime mono-thread : un
// parsing hors `spawn_blocking` gèlerait le battement ci-dessous.
let now = Utc::now();
// ~7000 chaînes comme un vrai dump de panneau ; 3 programmes par
// chaîne (au lieu de 30 côté Dart) pour garder le test rapide.
let description =
"Résumé du programme, avec assez de texte pour peser comme un vrai guide. ".repeat(4);
let mut xml = String::from(r#"<?xml version="1.0"?><tv>"#);
for c in 0..7000 {
xml.push_str(&format!(
r#"<channel id="Chaine.{c}.fr"><display-name>FR - CHAINE {c}</display-name></channel>"#
));
for p in 0..3 {
let start = now + TimeDelta::minutes(30 * p);
let stop = start + TimeDelta::minutes(30);
xml.push_str(&format!(
r#"<programme start="{}" stop="{}" channel="Chaine.{c}.fr"><title>Programme {p}</title><desc>{description}</desc></programme>"#,
stamp(start),
stamp(stop)
));
}
}
xml.push_str("</tv>");
let (svc, _) = service_with(xml).await;
// Un « battement » toutes les 10 ms : le plus long écart observé est
// le temps pendant lequel la boucle est restée bloquée.
let beat = Arc::new(Mutex::new((Instant::now(), Duration::ZERO)));
let heartbeat = {
let beat = Arc::clone(&beat);
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(10)).await;
let mut b = beat.lock().unwrap();
let gap = b.0.elapsed();
b.1 = b.1.max(gap);
b.0 = Instant::now();
}
})
};
svc.ensure_fresh().await;
heartbeat.abort();
let (last, mut worst) = *beat.lock().unwrap();
// Le dernier blocage n'est suivi d'aucun battement : le compter aussi.
worst = worst.max(last.elapsed());
assert!(
svc.channel_count() >= 7000,
"{} chaînes",
svc.channel_count()
);
assert!(
worst < Duration::from_millis(250),
"boucle d'événements gelée {} ms",
worst.as_millis()
);
}
// --- horizon ---
#[test]
fn ecarte_les_programmes_au_dela_de_l_horizon() {
// Un dump national couvre sept jours : tout garder ferait grossir
// l'index pour un guide qui n'affiche que le programme courant.
let mut config = XmltvConfig::new(vec![]);
config.horizon = Duration::from_secs(48 * 3600);
let svc = Svc::new(config, reqwest::Client::new());
let far = Utc::now() + TimeDelta::days(5);
assert!(
svc.parse_for_test(&fixture(far, far + TimeDelta::hours(1)))
.is_empty()
);
}
// --- journalisation (xmltv_epg_log_redaction_test.dart) ---
/// URL du dump `xmltv.php` d'un panneau Xtream : les identifiants de
/// l'abonné y figurent en clair, comme en production.
const PANEL_QUERY: &str = "/xmltv.php?username=john&password=hunter2";
fn capturing(url: String) -> (Svc, Arc<Mutex<Vec<String>>>) {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&lines);
let svc = Svc::with_logger(
XmltvConfig::new(vec![url]),
reqwest::Client::new(),
Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string())),
);
(svc, lines)
}
#[tokio::test]
async fn masque_les_identifiants_de_l_url_quand_la_source_repond() {
let (base, _) = serve(|_| {
(
StatusCode::OK,
bytes::Bytes::from_static(br#"<?xml version="1.0"?><tv></tv>"#),
)
})
.await;
let (svc, lines) = capturing(format!("{base}{PANEL_QUERY}"));
svc.ensure_fresh().await;
let lines = lines.lock().unwrap().clone();
let xmltv: Vec<_> = lines
.iter()
.filter(|l| l.starts_with("[XmltvEpg]"))
.collect();
assert!(!xmltv.is_empty());
for line in &xmltv {
assert!(!line.contains("john"), "{line}");
assert!(!line.contains("hunter2"), "{line}");
}
assert!(
lines
.iter()
.any(|l| l.contains("username=***&password=***")),
"{lines:?}"
);
}
#[tokio::test]
async fn masque_les_identifiants_de_l_url_et_de_l_erreur_en_cas_d_echec() {
// Les erreurs reqwest recopient l'URL demandée dans leur message :
// c'est par là que les identifiants fuyaient aussi. Port fermé →
// connexion refusée.
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let (svc, lines) = capturing(format!("http://127.0.0.1:{port}{PANEL_QUERY}"));
svc.ensure_fresh().await;
let lines = lines.lock().unwrap().clone();
let failure = lines
.iter()
.find(|l| l.contains("échec"))
.unwrap_or_else(|| panic!("aucune ligne d'échec journalisée : {lines:?}"));
assert!(!failure.contains("john"), "{failure}");
assert!(!failure.contains("hunter2"), "{failure}");
// URL masquée en tête de ligne, et dans le texte de l'erreur.
assert_eq!(
failure.matches("username=***&password=***").count(),
2,
"{failure}"
);
assert!(!svc.has_data());
}
#[test]
fn ligne_d_echec_masque_le_texte_de_l_exception() {
let line = failure_line(
&redact_url("http://panel.example:8080/xmltv.php?username=john&password=hunter2"),
"ClientException: Connection refused, uri=http://panel.example:8080/xmltv.php?username=john&password=hunter2",
);
assert!(!line.contains("john"));
assert!(!line.contains("hunter2"));
assert!(line.contains("username=***&password=***"));
assert!(line.contains("Connection refused"));
assert!(line.starts_with(
"[XmltvEpg] http://panel.example:8080/xmltv.php?username=***&password=*** : échec ("
));
}
#[test]
fn duree_au_format_dart() {
assert_eq!(dart_duration(Duration::from_secs(300)), "0:05:00.000000");
assert_eq!(
dart_duration(Duration::from_millis(3_723_500)),
"1:02:03.500000"
);
}
}