mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 21:48:06 +02:00
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>
646 lines
22 KiB
Rust
646 lines
22 KiB
Rust
//! Logos de chaînes de repli, tirés du dépôt public `tv-logo/tv-logos`.
|
|
//!
|
|
//! POURQUOI : les URL `stream_icon` du panneau pointent chez l'hébergeur du
|
|
//! revendeur. Quand cette machine tombe — cas observé : serveur de picons en
|
|
//! maintenance, 503 sur toutes les URL —, toute la grille reste sans logo.
|
|
//! Le dépôt `tv-logos` est maintenu, nommé de façon régulière
|
|
//! (`countries/france/rmc-sport-2-fr.png`) et s'apparie au nom de chaîne.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use bytes::Bytes;
|
|
use futures::future::{BoxFuture, FutureExt, Shared};
|
|
use regex::Regex;
|
|
use reqwest::header::{ACCEPT, USER_AGENT};
|
|
|
|
use crate::util::log_redactor::redact_url;
|
|
|
|
/// GitHub refuse les requêtes sans `User-Agent`.
|
|
const USER_AGENT_VALUE: &str = "XtremFlow";
|
|
|
|
/// Plafond d'une requête (liste ou image).
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
|
|
|
type SharedListing = Shared<BoxFuture<'static, Option<Arc<Listing>>>>;
|
|
|
|
/// Catalogue de logos, partagé via `Arc<LogoCatalog>`.
|
|
pub struct LogoCatalog {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
struct Inner {
|
|
client: reqwest::Client,
|
|
api_base: String,
|
|
|
|
/// Durée de vie d'une liste de logos par pays. L'API GitHub non
|
|
/// authentifiée plafonne à 60 requêtes par heure : une par jour suffit.
|
|
listing_ttl: Duration,
|
|
|
|
/// Recul après un échec de téléchargement de liste (limite de débit,
|
|
/// réseau), pour ne pas marteler l'API à chaque tuile affichée.
|
|
retry_backoff: Duration,
|
|
|
|
state: Mutex<State>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct State {
|
|
listings: HashMap<&'static str, Arc<Listing>>,
|
|
listings_in_flight: HashMap<&'static str, SharedListing>,
|
|
listing_failed_at: HashMap<&'static str, Instant>,
|
|
/// URL de téléchargement → octets PNG.
|
|
bytes: HashMap<String, Bytes>,
|
|
}
|
|
|
|
struct Listing {
|
|
/// Clé normalisée → URL de téléchargement brute.
|
|
urls: HashMap<String, String>,
|
|
fetched_at: Instant,
|
|
}
|
|
|
|
impl Inner {
|
|
fn lock(&self) -> MutexGuard<'_, State> {
|
|
self.state.lock().unwrap_or_else(PoisonError::into_inner)
|
|
}
|
|
}
|
|
|
|
/// Libère la place de la requête de liste en cours quand elle se termine,
|
|
/// même en cas de panique.
|
|
struct InFlightGuard(Arc<Inner>, &'static str);
|
|
|
|
impl Drop for InFlightGuard {
|
|
fn drop(&mut self) {
|
|
self.0.lock().listings_in_flight.remove(self.1);
|
|
}
|
|
}
|
|
|
|
impl LogoCatalog {
|
|
pub fn new(client: reqwest::Client) -> Self {
|
|
Self::with_api_base(client, "https://api.github.com".to_string())
|
|
}
|
|
|
|
/// `api_base` remplace `https://api.github.com` (tests).
|
|
pub fn with_api_base(client: reqwest::Client, api_base: String) -> Self {
|
|
Self {
|
|
inner: Arc::new(Inner {
|
|
client,
|
|
api_base: api_base.trim_end_matches('/').to_string(),
|
|
listing_ttl: Duration::from_secs(24 * 3600),
|
|
retry_backoff: Duration::from_secs(15 * 60),
|
|
state: Mutex::new(State::default()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Octets PNG du logo correspondant à `channel_name`, ou `None`.
|
|
pub async fn logo_for(&self, channel_name: &str) -> Option<Bytes> {
|
|
let country = country_folder_for(channel_name);
|
|
let listing = self.listing_for(country).await?;
|
|
|
|
let key = match_logo_key(channel_name, listing.urls.keys())?;
|
|
let url = listing.urls.get(&key)?.clone();
|
|
if let Some(cached) = self.inner.lock().bytes.get(&url) {
|
|
return Some(cached.clone());
|
|
}
|
|
|
|
let download = async {
|
|
let response = self
|
|
.inner
|
|
.client
|
|
.get(&url)
|
|
.header(USER_AGENT, USER_AGENT_VALUE)
|
|
.send()
|
|
.await
|
|
.ok()?;
|
|
if response.status().as_u16() != 200 {
|
|
return None;
|
|
}
|
|
response.bytes().await.ok()
|
|
};
|
|
let bytes = tokio::time::timeout(REQUEST_TIMEOUT, download)
|
|
.await
|
|
.ok()
|
|
.flatten()?;
|
|
self.inner.lock().bytes.insert(url, bytes.clone());
|
|
Some(bytes)
|
|
}
|
|
|
|
async fn listing_for(&self, country: &'static str) -> Option<Arc<Listing>> {
|
|
let pending = {
|
|
let mut state = self.inner.lock();
|
|
let cached = state.listings.get(country).cloned();
|
|
if let Some(listing) = &cached
|
|
&& listing.fetched_at.elapsed() < self.inner.listing_ttl
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
if state
|
|
.listing_failed_at
|
|
.get(country)
|
|
.is_some_and(|at| at.elapsed() < self.inner.retry_backoff)
|
|
{
|
|
// Liste périmée plutôt que rien : les logos ne changent pas
|
|
// d'un jour à l'autre.
|
|
return cached;
|
|
}
|
|
|
|
// Les tuiles d'une grille arrivent en rafale : une seule requête
|
|
// de liste. Elle tourne dans sa propre tâche pour aller au bout
|
|
// même si le premier demandeur abandonne.
|
|
match state.listings_in_flight.get(country) {
|
|
Some(pending) => pending.clone(),
|
|
None => {
|
|
let inner = Arc::clone(&self.inner);
|
|
let task = tokio::spawn(async move {
|
|
let _guard = InFlightGuard(Arc::clone(&inner), country);
|
|
fetch_listing(&inner, country).await
|
|
});
|
|
let pending = async move { task.await.ok().flatten() }.boxed().shared();
|
|
state.listings_in_flight.insert(country, pending.clone());
|
|
pending
|
|
}
|
|
}
|
|
};
|
|
pending.await
|
|
}
|
|
}
|
|
|
|
async fn fetch_listing(inner: &Inner, country: &'static str) -> Option<Arc<Listing>> {
|
|
let url = format!(
|
|
"{}/repos/tv-logo/tv-logos/contents/countries/{country}",
|
|
inner.api_base
|
|
);
|
|
match request_listing(inner, &url).await {
|
|
Ok(urls) => {
|
|
let count = urls.len();
|
|
let listing = Arc::new(Listing {
|
|
urls,
|
|
fetched_at: Instant::now(),
|
|
});
|
|
{
|
|
let mut state = inner.lock();
|
|
state.listings.insert(country, Arc::clone(&listing));
|
|
state.listing_failed_at.remove(country);
|
|
}
|
|
println!("[LogoCatalog] {country} : {count} logos");
|
|
Some(listing)
|
|
}
|
|
Err(error) => {
|
|
let stale = {
|
|
let mut state = inner.lock();
|
|
state.listing_failed_at.insert(country, Instant::now());
|
|
state.listings.get(country).cloned()
|
|
};
|
|
println!(
|
|
"[LogoCatalog] liste {country} indisponible : {}",
|
|
redact_url(&error)
|
|
);
|
|
stale
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Liste des PNG d'un dossier pays : clé normalisée → URL brute.
|
|
async fn request_listing(inner: &Inner, url: &str) -> Result<HashMap<String, String>, String> {
|
|
let request = async {
|
|
let response = inner
|
|
.client
|
|
.get(url)
|
|
.header(ACCEPT, "application/vnd.github+json")
|
|
.header(USER_AGENT, USER_AGENT_VALUE)
|
|
.send()
|
|
.await
|
|
.map_err(|e| error_text(&e))?;
|
|
let status = response.status().as_u16();
|
|
if status != 200 {
|
|
return Err(format!("Bad state: HTTP {status}"));
|
|
}
|
|
response.bytes().await.map_err(|e| error_text(&e))
|
|
};
|
|
let body = tokio::time::timeout(REQUEST_TIMEOUT, request)
|
|
.await
|
|
.map_err(|_| "TimeoutException after 0:00:20.000000: Future not completed".to_string())??;
|
|
|
|
let decoded: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&body))
|
|
.map_err(|e| format!("FormatException: {e}"))?;
|
|
let mut urls = HashMap::new();
|
|
if let serde_json::Value::Array(entries) = decoded {
|
|
for entry in entries {
|
|
let serde_json::Value::Object(entry) = entry else {
|
|
continue;
|
|
};
|
|
let name = entry
|
|
.get("name")
|
|
.and_then(dart_to_string)
|
|
.unwrap_or_default();
|
|
let Some(download) = entry.get("download_url").and_then(dart_to_string) else {
|
|
continue;
|
|
};
|
|
if !name.ends_with(".png") {
|
|
continue;
|
|
}
|
|
urls.insert(key_from_file_name(&name), download);
|
|
}
|
|
}
|
|
Ok(urls)
|
|
}
|
|
|
|
/// `value?.toString()` de Dart : `None` pour `null`.
|
|
fn dart_to_string(value: &serde_json::Value) -> Option<String> {
|
|
match value {
|
|
serde_json::Value::Null => None,
|
|
serde_json::Value::String(s) => Some(s.clone()),
|
|
other => Some(other.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Texte d'une erreur avec ses causes.
|
|
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
|
|
}
|
|
|
|
/// `rmc-sport-2-fr.png` → `rmc-sport-2`.
|
|
fn key_from_file_name(name: &str) -> String {
|
|
let base = &name[..name.len() - ".png".len()];
|
|
let bytes = base.as_bytes();
|
|
let n = bytes.len();
|
|
if n >= 3
|
|
&& bytes[n - 3] == b'-'
|
|
&& bytes[n - 2].is_ascii_lowercase()
|
|
&& bytes[n - 1].is_ascii_lowercase()
|
|
{
|
|
base[..n - 3].to_string()
|
|
} else {
|
|
base.to_string()
|
|
}
|
|
}
|
|
|
|
/// Code pays du préfixe panneau → dossier du dépôt `tv-logos`.
|
|
fn country_folder(code: &str) -> Option<&'static str> {
|
|
Some(match code {
|
|
"fr" => "france",
|
|
"be" => "belgium",
|
|
"ch" => "switzerland",
|
|
"uk" | "gb" => "united-kingdom",
|
|
"us" => "united-states",
|
|
"ca" => "canada",
|
|
"de" => "germany",
|
|
"es" => "spain",
|
|
"it" => "italy",
|
|
"pt" => "portugal",
|
|
"nl" => "netherlands",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// Préfixe pays des noms panneau : `FR - `, `FR: `, `|FR| `.
|
|
///
|
|
/// `\W` et `\s` reproduits avec leur sens Dart (ASCII pour `\W`, espaces
|
|
/// JavaScript pour `\s`), les classes de `regex` étant Unicode.
|
|
static COUNTRY_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(
|
|
r"^[^A-Za-z0-9_]*([A-Za-z]{2})[\t\n\x0B\x0C\r \xA0\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}\-:|]+",
|
|
)
|
|
.unwrap()
|
|
});
|
|
|
|
/// Dossier `tv-logos` à consulter pour `channel_name`. France par défaut :
|
|
/// c'est l'audience de l'application.
|
|
pub fn country_folder_for(channel_name: &str) -> &'static str {
|
|
COUNTRY_PREFIX
|
|
.captures(channel_name)
|
|
.and_then(|caps| country_folder(&caps[1].to_lowercase()))
|
|
.unwrap_or("france")
|
|
}
|
|
|
|
/// Mots sans rapport avec l'identité de la chaîne : qualité, codec, variante
|
|
/// de flux. Ils sont propres au panneau et absents des noms de logos.
|
|
const NOISE: &[&str] = &[
|
|
"fhd", "uhd", "hd", "sd", "4k", "8k", "hdr", "hevc", "h264", "h265", "1080p", "720p", "50fps",
|
|
"fps", "raw", "backup", "vip", "multi", "vf", "vo", "vostfr",
|
|
];
|
|
|
|
fn fold_accent(c: char) -> char {
|
|
match c {
|
|
'à' | 'á' | 'â' | 'ä' => 'a',
|
|
'è' | 'é' | 'ê' | 'ë' => 'e',
|
|
'ì' | 'í' | 'î' | 'ï' => 'i',
|
|
'ò' | 'ó' | 'ô' | 'ö' => 'o',
|
|
'ù' | 'ú' | 'û' | 'ü' => 'u',
|
|
'ç' => 'c',
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn tokens(channel_name: &str) -> Vec<String> {
|
|
let mut name = channel_name;
|
|
if let Some(caps) = COUNTRY_PREFIX.captures(name)
|
|
&& country_folder(&caps[1].to_lowercase()).is_some()
|
|
{
|
|
name = &name[caps.get(0).map_or(0, |m| m.end())..];
|
|
}
|
|
|
|
let folded: String = name.to_lowercase().chars().map(fold_accent).collect();
|
|
folded
|
|
.replace('+', " plus ")
|
|
.replace('&', " and ")
|
|
.split(|c: char| !matches!(c, 'a'..='z' | '0'..='9'))
|
|
.filter(|t| !t.is_empty() && !NOISE.contains(t))
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|
|
|
|
/// Clé de logo la plus proche de `channel_name` parmi `available`, ou `None`.
|
|
///
|
|
/// Par ordre de confiance : nom exact, nom sans « live », variante plus
|
|
/// longue du même identifiant (`bein-sports-1` → `bein-sports-1-french`),
|
|
/// puis la marque sans numéro de canal (`rmc-sport-live-5` → `rmc-sport-1`).
|
|
/// Aucune approximation sur un mot seul : « France » ne doit pas devenir
|
|
/// France 24.
|
|
pub fn match_logo_key<'a>(
|
|
channel_name: &str,
|
|
available: impl IntoIterator<Item = &'a String>,
|
|
) -> Option<String> {
|
|
let keys: HashSet<&str> = available.into_iter().map(String::as_str).collect();
|
|
let tokens = tokens(channel_name);
|
|
if tokens.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let without_live = |parts: &[String]| -> Vec<String> {
|
|
parts.iter().filter(|t| *t != "live").cloned().collect()
|
|
};
|
|
|
|
let exact = |parts: &[String]| -> Option<String> {
|
|
let slug = parts.join("-");
|
|
(!parts.is_empty() && keys.contains(slug.as_str())).then_some(slug)
|
|
};
|
|
|
|
let longer_variant = |parts: &[String]| -> Option<String> {
|
|
if parts.len() < 2 {
|
|
return None;
|
|
}
|
|
let prefix = format!("{}-", parts.join("-"));
|
|
// Plus courte d'abord (longueur en unités UTF-16 comme en Dart), puis
|
|
// ordre alphabétique.
|
|
keys.iter()
|
|
.filter(|k| k.starts_with(&prefix))
|
|
.min_by(|a, b| {
|
|
a.encode_utf16()
|
|
.count()
|
|
.cmp(&b.encode_utf16().count())
|
|
.then_with(|| a.cmp(b))
|
|
})
|
|
.map(|k| k.to_string())
|
|
};
|
|
|
|
let no_live = without_live(&tokens);
|
|
let direct = exact(&tokens)
|
|
.or_else(|| exact(&no_live))
|
|
.or_else(|| longer_variant(&tokens))
|
|
.or_else(|| longer_variant(&no_live));
|
|
if direct.is_some() {
|
|
return direct;
|
|
}
|
|
|
|
// Marque sans numéro de canal, seulement s'il reste au moins deux mots.
|
|
let last = tokens.last()?;
|
|
if !last.is_empty() && last.bytes().all(|b| b.is_ascii_digit()) {
|
|
let brand = without_live(&tokens[..tokens.len() - 1]);
|
|
if brand.len() >= 2 {
|
|
return exact(&brand).or_else(|| longer_variant(&brand));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::Router;
|
|
use axum::http::{StatusCode, Uri};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// Extrait réel de `tv-logo/tv-logos/countries/france` (noms sans
|
|
/// `-fr.png`).
|
|
const FRANCE: &[&str] = &[
|
|
"bein-sports-1-french",
|
|
"bein-sports-2-french",
|
|
"bein-sports",
|
|
"canal-plus",
|
|
"canal-plus-sport",
|
|
"canal-plus-sport-360",
|
|
"eurosport-1",
|
|
"france-2",
|
|
"france-24",
|
|
"lequipe",
|
|
"rmc-sport-1",
|
|
"rmc-sport-2",
|
|
"rmc-sport-access-1",
|
|
];
|
|
|
|
fn matched(name: &str) -> Option<String> {
|
|
let keys: Vec<String> = FRANCE.iter().map(|s| s.to_string()).collect();
|
|
match_logo_key(name, &keys)
|
|
}
|
|
|
|
#[test]
|
|
fn correspondance_exacte_apres_nettoyage_du_nom_panneau() {
|
|
// Préfixe pays et suffixe de qualité sont du bruit propre au panneau.
|
|
assert_eq!(
|
|
matched("FR - RMC SPORT 2 FHD").as_deref(),
|
|
Some("rmc-sport-2")
|
|
);
|
|
assert_eq!(matched("FR: France 2 HD").as_deref(), Some("france-2"));
|
|
assert_eq!(
|
|
matched("|FR| EUROSPORT 1 UHD").as_deref(),
|
|
Some("eurosport-1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn traduit_le_plus_de_canal_plus() {
|
|
assert_eq!(
|
|
matched("FR - CANAL+ SPORT FHD").as_deref(),
|
|
Some("canal-plus-sport")
|
|
);
|
|
assert_eq!(matched("FR - CANAL+").as_deref(), Some("canal-plus"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepte_une_variante_plus_longue_du_meme_identifiant() {
|
|
// Le dépôt nomme `bein-sports-1-french`, le panneau `BEIN SPORTS 1`.
|
|
assert_eq!(
|
|
matched("FR - BEIN SPORTS 1 FHD").as_deref(),
|
|
Some("bein-sports-1-french")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retombe_sur_la_marque_quand_le_numero_de_canal_est_inconnu() {
|
|
// Les canaux événementiels (« Live 5 ») n'ont pas de logo propre :
|
|
// celui de la marque vaut mieux qu'une tuile vide.
|
|
assert_eq!(
|
|
matched("FR - RMC SPORT LIVE 5 FHD").as_deref(),
|
|
Some("rmc-sport-1")
|
|
);
|
|
assert_eq!(
|
|
matched("FR - CANAL+ SPORT 2").as_deref(),
|
|
Some("canal-plus-sport")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ne_devine_pas_sur_un_seul_mot() {
|
|
// « France » seul ne doit pas donner le logo de France 24.
|
|
assert_eq!(matched("FR - FRANCE"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn rend_none_quand_rien_ne_correspond() {
|
|
assert_eq!(matched("FR - DAZN FHD"), None);
|
|
assert_eq!(matched(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn country_folder_lit_le_prefixe_pays_du_nom() {
|
|
assert_eq!(country_folder_for("FR - TF1"), "france");
|
|
assert_eq!(country_folder_for("BE: RTL TVI"), "belgium");
|
|
assert_eq!(country_folder_for("|UK| BBC ONE"), "united-kingdom");
|
|
}
|
|
|
|
#[test]
|
|
fn country_folder_france_par_defaut_sans_prefixe_reconnu() {
|
|
assert_eq!(country_folder_for("TF1 HD"), "france");
|
|
}
|
|
|
|
#[test]
|
|
fn cle_depuis_le_nom_de_fichier() {
|
|
assert_eq!(key_from_file_name("rmc-sport-2-fr.png"), "rmc-sport-2");
|
|
assert_eq!(key_from_file_name("lequipe.png"), "lequipe");
|
|
assert_eq!(key_from_file_name("bbc-one-uk.png"), "bbc-one");
|
|
}
|
|
|
|
/// Mini serveur HTTP local (remplace le `MockClient` Dart) : compte les
|
|
/// requêtes et répond selon le chemin. `{base}` dans le corps est
|
|
/// remplacé par l'adresse du serveur.
|
|
async fn serve(
|
|
respond: impl Fn(&Uri) -> (StatusCode, String) + Send + Sync + 'static,
|
|
) -> (String, Arc<Mutex<Vec<String>>>) {
|
|
let requested = Arc::new(Mutex::new(Vec::new()));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
let respond = Arc::new(respond);
|
|
let log = Arc::clone(&requested);
|
|
let base_for_body = base.clone();
|
|
let app = Router::new().fallback(move |uri: Uri| {
|
|
let respond = Arc::clone(&respond);
|
|
let log = Arc::clone(&log);
|
|
let base = base_for_body.clone();
|
|
async move {
|
|
log.lock().unwrap().push(uri.to_string());
|
|
let (status, body) = respond(&uri);
|
|
(status, body.replace("{base}", &base))
|
|
}
|
|
});
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
(base, requested)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn telecharge_le_logo_apparie_et_le_garde_en_cache() {
|
|
let (base, requested) = serve(|uri| {
|
|
if uri.path().starts_with("/repos/") {
|
|
(
|
|
StatusCode::OK,
|
|
serde_json::json!([
|
|
{"name": "rmc-sport-2-fr.png", "download_url": "{base}/raw/rmc-sport-2-fr.png"},
|
|
{"name": "README.md", "download_url": "{base}/r"},
|
|
])
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
(StatusCode::OK, "\u{1}\u{2}\u{3}".to_string())
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
|
|
let first = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
|
|
let second = catalog.logo_for("FR - RMC SPORT 2 FHD").await;
|
|
|
|
assert_eq!(first.as_deref(), Some(&[1u8, 2, 3][..]));
|
|
assert_eq!(second.as_deref(), Some(&[1u8, 2, 3][..]));
|
|
// Une liste et un logo, pas un aller-retour par affichage.
|
|
let requested = requested.lock().unwrap().clone();
|
|
assert_eq!(requested.len(), 2, "{requested:?}");
|
|
assert_eq!(
|
|
requested[0],
|
|
"/repos/tv-logo/tv-logos/contents/countries/france"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rend_none_sans_correspondance_sans_telecharger_d_image() {
|
|
let downloads = Arc::new(AtomicUsize::new(0));
|
|
let counter = Arc::clone(&downloads);
|
|
let (base, _) = serve(move |uri| {
|
|
if uri.path().starts_with("/repos/") {
|
|
(StatusCode::OK, "[]".to_string())
|
|
} else {
|
|
counter.fetch_add(1, Ordering::SeqCst);
|
|
(StatusCode::OK, "\u{1}".to_string())
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
|
|
assert_eq!(catalog.logo_for("FR - CHAINE INCONNUE").await, None);
|
|
assert_eq!(downloads.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn espace_les_tentatives_quand_la_liste_est_injoignable() {
|
|
let (base, requested) =
|
|
serve(|_| (StatusCode::FORBIDDEN, "rate limited".to_string())).await;
|
|
|
|
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
|
|
catalog.logo_for("FR - RMC SPORT 2").await;
|
|
catalog.logo_for("FR - RMC SPORT 1").await;
|
|
|
|
assert_eq!(requested.lock().unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn une_seule_requete_de_liste_pour_une_rafale_de_tuiles() {
|
|
let (base, requested) = serve(|uri| {
|
|
if uri.path().starts_with("/repos/") {
|
|
(StatusCode::OK, "[]".to_string())
|
|
} else {
|
|
(StatusCode::OK, String::new())
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let catalog = LogoCatalog::with_api_base(reqwest::Client::new(), base);
|
|
futures::future::join_all((0..8).map(|i| {
|
|
let name = format!("FR - CHAINE {i}");
|
|
let catalog = &catalog;
|
|
async move { catalog.logo_for(&name).await }
|
|
}))
|
|
.await;
|
|
|
|
assert_eq!(requested.lock().unwrap().len(), 1);
|
|
}
|
|
}
|