feat: implement livemeta/pull API and station mapping
- Replace old /api/live endpoint with new livemeta/pull API (api.radiofrance.fr) - Add hardcoded station ID/stream mappings with runtime-updatable mapping - Implement station rediscovery on unknown slug or stream failure - Add persistent caching for station mappings (30-day TTL) via pmoconfig - Update live_metadata to use numeric IDs and handle ID mismatches - Add station validation (API + stream accessibility check) - Refactor metadata caching with fetched_at and fallback TTL - Update discover_local_radios to scrape SvelteKit page data - Add new models: PullResponse, PullStep, EmbedImage for livemeta format - Improve live_metadata.rs example to safely truncate intros - Enable parallel DIDL generation for playlist groups
This commit is contained in:
@@ -34,12 +34,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if let Some(intro) = &metadata.now.intro {
|
||||
let short_intro = if intro.len() > 100 {
|
||||
format!("{}...", &intro[..100])
|
||||
} else {
|
||||
intro.clone()
|
||||
};
|
||||
println!(" Description: {}", short_intro);
|
||||
let short_intro: String = intro.chars().take(100).collect();
|
||||
let suffix = if intro.chars().count() > 100 { "..." } else { "" };
|
||||
println!(" Description: {}{}", short_intro, suffix);
|
||||
}
|
||||
|
||||
// Song info (for music stations)
|
||||
|
||||
@@ -28,17 +28,27 @@
|
||||
//! ```
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource};
|
||||
use crate::models::{
|
||||
EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station,
|
||||
StreamSource,
|
||||
};
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
use crate::config_ext::StationInfo;
|
||||
|
||||
/// Default Radio France base URL
|
||||
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
|
||||
|
||||
/// Livemeta API base URL (new API since 2026)
|
||||
pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull";
|
||||
|
||||
/// Default timeout for HTTP requests (30 seconds)
|
||||
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
@@ -56,6 +66,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
|
||||
("francebleu", "France Bleu"),
|
||||
];
|
||||
|
||||
/// Mapping slug → numeric station ID for the livemeta/pull API
|
||||
/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026)
|
||||
pub const STATION_IDS: &[(&str, u32)] = &[
|
||||
("franceinter", 1),
|
||||
("franceinfo", 2),
|
||||
("francemusique", 4),
|
||||
("franceculture", 5),
|
||||
("mouv", 6),
|
||||
("fip", 7),
|
||||
("francebleu", 56),
|
||||
// FIP webradios
|
||||
("fip_rock", 64),
|
||||
("fip_jazz", 65),
|
||||
("fip_groove", 66),
|
||||
("fip_reggae", 71),
|
||||
("fip_electro", 74),
|
||||
("fip_metal", 77),
|
||||
("fip_pop", 78),
|
||||
("fip_world", 69),
|
||||
("fip_nouveautes", 70),
|
||||
("fip_hiphop", 95),
|
||||
("fip_sacre_francais", 96),
|
||||
("fip_cultes", 709),
|
||||
];
|
||||
|
||||
/// Icecast HiFi AAC stream URLs (stable, not from API)
|
||||
/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac
|
||||
pub const STATION_STREAMS: &[(&str, &str)] = &[
|
||||
(
|
||||
"franceinter",
|
||||
"https://icecast.radiofrance.fr/franceinter-hifi.aac",
|
||||
),
|
||||
(
|
||||
"franceinfo",
|
||||
"https://icecast.radiofrance.fr/franceinfo-hifi.aac",
|
||||
),
|
||||
(
|
||||
"franceculture",
|
||||
"https://icecast.radiofrance.fr/franceculture-hifi.aac",
|
||||
),
|
||||
(
|
||||
"francemusique",
|
||||
"https://icecast.radiofrance.fr/francemusique-hifi.aac",
|
||||
),
|
||||
("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"),
|
||||
("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"),
|
||||
// FIP webradios
|
||||
(
|
||||
"fip_rock",
|
||||
"https://icecast.radiofrance.fr/fiprock-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_jazz",
|
||||
"https://icecast.radiofrance.fr/fipjazz-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_groove",
|
||||
"https://icecast.radiofrance.fr/fipgroove-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_reggae",
|
||||
"https://icecast.radiofrance.fr/fipreggae-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_electro",
|
||||
"https://icecast.radiofrance.fr/fipelectro-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_metal",
|
||||
"https://icecast.radiofrance.fr/fipmetal-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_pop",
|
||||
"https://icecast.radiofrance.fr/fippop-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_world",
|
||||
"https://icecast.radiofrance.fr/fipworld-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_nouveautes",
|
||||
"https://icecast.radiofrance.fr/fipnouveautes-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_hiphop",
|
||||
"https://icecast.radiofrance.fr/fiphiphop-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_sacre_francais",
|
||||
"https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_cultes",
|
||||
"https://icecast.radiofrance.fr/fipcultes-hifi.aac",
|
||||
),
|
||||
];
|
||||
|
||||
/// Radio France HTTP client
|
||||
///
|
||||
/// This client provides access to Radio France's public APIs for:
|
||||
@@ -63,13 +170,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
|
||||
/// - Live metadata (current show, next show, stream URLs)
|
||||
/// - Image URL construction (Pikapi)
|
||||
///
|
||||
/// The client is stateless and does not cache responses internally.
|
||||
/// Caching should be handled by higher layers (e.g., config extension).
|
||||
/// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
|
||||
/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
|
||||
/// mapping loaded from persistent storage (pmoconfig).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RadioFranceClient {
|
||||
pub(crate) client: Client,
|
||||
base_url: String,
|
||||
timeout: Duration,
|
||||
/// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime
|
||||
station_mapping: Arc<RwLock<HashMap<String, StationMappingEntry>>>,
|
||||
}
|
||||
|
||||
/// Entry in the station mapping
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StationMappingEntry {
|
||||
pub station_id: u32,
|
||||
pub stream_url: String,
|
||||
}
|
||||
|
||||
impl RadioFranceClient {
|
||||
@@ -91,6 +208,7 @@ impl RadioFranceClient {
|
||||
client,
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||
station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +222,89 @@ impl RadioFranceClient {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Build the default station mapping from hardcoded constants
|
||||
fn default_station_mapping() -> HashMap<String, StationMappingEntry> {
|
||||
// Build a lookup for stream URLs
|
||||
let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect();
|
||||
|
||||
STATION_IDS
|
||||
.iter()
|
||||
.map(|(slug, id)| {
|
||||
let stream_url = stream_map
|
||||
.get(slug)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
(
|
||||
slug.to_string(),
|
||||
StationMappingEntry {
|
||||
station_id: *id,
|
||||
stream_url,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant
|
||||
///
|
||||
/// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig.
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub fn set_station_mapping(&self, mapping: HashMap<String, StationInfo>) {
|
||||
let mut m = self.station_mapping.write().unwrap();
|
||||
m.clear();
|
||||
for (slug, info) in mapping {
|
||||
m.insert(
|
||||
slug,
|
||||
StationMappingEntry {
|
||||
station_id: info.station_id,
|
||||
stream_url: info.stream_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig)
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub fn get_station_mapping(&self) -> HashMap<String, StationInfo> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.iter()
|
||||
.map(|(slug, entry)| {
|
||||
(
|
||||
slug.clone(),
|
||||
StationInfo {
|
||||
station_id: entry.station_id,
|
||||
stream_url: entry.stream_url.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire)
|
||||
pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) {
|
||||
let mut m = self.station_mapping.write().unwrap();
|
||||
m.insert(
|
||||
slug.to_string(),
|
||||
StationMappingEntry {
|
||||
station_id,
|
||||
stream_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'ID numérique pour un slug, ou None si inconnu
|
||||
pub fn station_id_for_slug(&self, slug: &str) -> Option<u32> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.get(slug).map(|e| e.station_id)
|
||||
}
|
||||
|
||||
/// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu
|
||||
pub fn stream_url_for_slug(&self, slug: &str) -> Option<String> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.get(slug).map(|e| e.stream_url.clone())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Station Discovery
|
||||
// ========================================================================
|
||||
@@ -254,20 +455,71 @@ impl RadioFranceClient {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Discover local France Bleu radios via API
|
||||
/// Discover local France Bleu radios via SvelteKit page data
|
||||
///
|
||||
/// Uses the France Bleu /api/live? endpoint which includes
|
||||
/// a `localRadios` array in the `now` field.
|
||||
/// Scrapes francebleu page for local station slugs and discovers their IDs.
|
||||
/// Met à jour le station_mapping avec les IDs trouvés.
|
||||
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> {
|
||||
let response = self.live_metadata("francebleu").await?;
|
||||
let url = format!("{}/francebleu/__data.json", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.local_radios()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
// Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
|
||||
let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
|
||||
let mut stations = Vec::new();
|
||||
let mut found_any = false;
|
||||
|
||||
for cap in re_slug_id.captures_iter(&resp) {
|
||||
let slug = cap[1].to_string();
|
||||
if let Ok(id) = cap[2].parse::<u32>() {
|
||||
let stream_url = Self::derive_stream_url(&slug);
|
||||
self.update_station_entry(&slug, id, stream_url);
|
||||
let name = Self::slug_to_display_name(&slug);
|
||||
stations.push(Station::new(slug, name));
|
||||
found_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found_any {
|
||||
return Ok(stations);
|
||||
}
|
||||
|
||||
// Fallback : scraper la page HTML pour les noms "ICI ..."
|
||||
let html_url = format!("{}/francebleu", self.base_url);
|
||||
let html = self
|
||||
.client
|
||||
.get(&html_url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?;
|
||||
let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?;
|
||||
|
||||
let mut slugs: HashSet<String> = HashSet::new();
|
||||
for cap in re_slug.captures_iter(&html) {
|
||||
slugs.insert(cap[1].to_string());
|
||||
}
|
||||
|
||||
let _names: Vec<String> = re_ici
|
||||
.captures_iter(&html)
|
||||
.map(|c| c[1].trim().to_string())
|
||||
.collect();
|
||||
|
||||
Ok(slugs
|
||||
.into_iter()
|
||||
.filter(|local| local.is_on_air)
|
||||
.map(|local| Station::new(local.name, local.title))
|
||||
.map(|slug| {
|
||||
let name = Self::slug_to_display_name(&slug);
|
||||
Station::new(slug, name)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -370,28 +622,42 @@ impl RadioFranceClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
||||
let (base_station, webradio) = Self::parse_station_slug(station);
|
||||
let station_id = self
|
||||
.station_id_for_slug(station)
|
||||
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", station)))?;
|
||||
|
||||
let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?;
|
||||
|
||||
// Add webradio parameter if needed
|
||||
if let Some(wr) = webradio {
|
||||
url.query_pairs_mut().append_pair("webradio", wr);
|
||||
}
|
||||
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Fetching live metadata: {}", url);
|
||||
|
||||
let response = self.client.get(url).timeout(self.timeout).send().await?;
|
||||
let response = self.client.get(&url).timeout(self.timeout).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"API returned status: {}",
|
||||
response.status()
|
||||
"livemeta API returned status {} for station {} (id={})",
|
||||
response.status(),
|
||||
station,
|
||||
station_id
|
||||
)));
|
||||
}
|
||||
|
||||
let live: LiveResponse = response.json().await?;
|
||||
let pull: PullResponse = response.json().await?;
|
||||
|
||||
// Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée.
|
||||
// Si Radio France réassigne les IDs, on déclenche une redécouverte.
|
||||
if pull.station_id != station_id {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery",
|
||||
station, station_id, pull.station_id
|
||||
);
|
||||
// Mettre à jour le mapping avec le nouvel ID retourné par l'API
|
||||
let stream_url = self.stream_url_for_slug(station).unwrap_or_default();
|
||||
self.update_station_entry(station, pull.station_id, stream_url);
|
||||
}
|
||||
|
||||
let live = self.pull_to_live_response(station, &pull);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
@@ -404,6 +670,268 @@ impl RadioFranceClient {
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne)
|
||||
fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse {
|
||||
let now = if let Some(step) = pull.current_step() {
|
||||
self.step_to_show_metadata(station_slug, step)
|
||||
} else {
|
||||
ShowMetadata::default()
|
||||
};
|
||||
|
||||
// delayToRefresh : on utilise end_time - now comme indicateur (min 30s)
|
||||
let delay_to_refresh = if let Some(end) = now.end_time {
|
||||
let current = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
if end > current {
|
||||
((end - current) * 1000).min(60_000) // max 60s, en ms
|
||||
} else {
|
||||
30_000
|
||||
}
|
||||
} else {
|
||||
30_000
|
||||
};
|
||||
|
||||
LiveResponse {
|
||||
station_name: station_slug.to_string(),
|
||||
delay_to_refresh,
|
||||
migrated: true,
|
||||
now,
|
||||
next: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un PullStep en ShowMetadata
|
||||
fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata {
|
||||
// Stream : injecté depuis le mapping en mémoire
|
||||
let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default();
|
||||
let sources = if !stream_url.is_empty() {
|
||||
vec![StreamSource {
|
||||
url: stream_url,
|
||||
broadcast_type: crate::models::BroadcastType::Live,
|
||||
format: crate::models::StreamFormat::Aac,
|
||||
bitrate: 192,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Visual : la nouvelle API donne directement l'UUID (pas une URL complète)
|
||||
let visual_background = step.visual.as_deref().map(|uuid| EmbedImage {
|
||||
model: "EmbedImage".to_string(),
|
||||
src: ImageSize::Large.build_url(uuid),
|
||||
width: None,
|
||||
height: None,
|
||||
dominant: None,
|
||||
copyright: None,
|
||||
});
|
||||
|
||||
if step.is_song() {
|
||||
// === Radio musicale ===
|
||||
let artists = step.artists_display();
|
||||
let song = Some(Song {
|
||||
id: step.song_id.clone().unwrap_or_default(),
|
||||
year: step.annee_edition_musique,
|
||||
interpreters: if artists.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![artists.clone()]
|
||||
},
|
||||
release: crate::models::Release {
|
||||
label: step.label.clone(),
|
||||
title: step.titre_album.clone(),
|
||||
reference: None,
|
||||
},
|
||||
});
|
||||
|
||||
ShowMetadata {
|
||||
start_time: step.start,
|
||||
end_time: step.end,
|
||||
producer: step.disc_jockey.clone(),
|
||||
first_line: Line {
|
||||
title: Some(step.title.clone()),
|
||||
id: None,
|
||||
path: step.path.clone(),
|
||||
},
|
||||
second_line: Line {
|
||||
title: if artists.is_empty() { None } else { Some(artists) },
|
||||
id: None,
|
||||
path: None,
|
||||
},
|
||||
song,
|
||||
media: Media { sources },
|
||||
visual_background,
|
||||
..ShowMetadata::default()
|
||||
}
|
||||
} else {
|
||||
// === Radio parlée / émission ===
|
||||
let show_title = step.title_concept.as_deref()
|
||||
.unwrap_or(step.title.as_str())
|
||||
.to_string();
|
||||
let episode_title = if step.title_concept.is_some() {
|
||||
step.title.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let producer = step.disc_jockey.clone()
|
||||
.or_else(|| step.producers.first().map(|p| p.name.clone()));
|
||||
|
||||
ShowMetadata {
|
||||
start_time: step.start,
|
||||
end_time: step.end,
|
||||
producer,
|
||||
first_line: Line {
|
||||
title: Some(show_title),
|
||||
id: None,
|
||||
path: step.path.clone(),
|
||||
},
|
||||
second_line: Line {
|
||||
title: if episode_title.is_empty() { None } else { Some(episode_title) },
|
||||
id: None,
|
||||
path: None,
|
||||
},
|
||||
intro: step.expression_description.clone()
|
||||
.or_else(|| step.description.clone()),
|
||||
media: Media { sources },
|
||||
visual_background,
|
||||
..ShowMetadata::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web
|
||||
///
|
||||
/// Utilisé quand un stream échoue ou qu'un slug est inconnu.
|
||||
/// Met à jour le mapping en mémoire si la découverte réussit.
|
||||
pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Rediscovering station mapping for: {}", slug);
|
||||
|
||||
// Construire l'URL de la page de la station principale
|
||||
let base_slug = slug.split('_').next().unwrap_or(slug);
|
||||
let page_url = format!("{}/{}/__data.json", self.base_url, base_slug);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&page_url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Cannot rediscover station {}: page returned {}",
|
||||
slug,
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp.text().await?;
|
||||
|
||||
// Fix 2: chercher l'ID spécifique à la station via son brandEnum.
|
||||
// Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit.
|
||||
// Pour les stations principales, on cherche aussi le pattern `"Brand","<id>"`.
|
||||
let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK
|
||||
let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?;
|
||||
let brand_id: Option<u32> = re_enum
|
||||
.captures_iter(&text)
|
||||
.filter_map(|c| c[1].parse::<u32>().ok())
|
||||
.next();
|
||||
|
||||
// Fallback : premier "Brand","<id>" dans la page (pour stations principales)
|
||||
let brand_id = if brand_id.is_some() {
|
||||
brand_id
|
||||
} else {
|
||||
let re_brand = Regex::new(r#""Brand","(\d+)""#)?;
|
||||
let ids: Vec<u32> = re_brand
|
||||
.captures_iter(&text)
|
||||
.filter_map(|c| c[1].parse::<u32>().ok())
|
||||
.collect();
|
||||
ids.into_iter().next()
|
||||
};
|
||||
|
||||
let station_id = brand_id.ok_or_else(|| {
|
||||
Error::ApiError(format!("Could not find station_id for {} in page data", slug))
|
||||
})?;
|
||||
|
||||
// Dériver l'URL stream depuis le slug (format icecast connu)
|
||||
let stream_url = Self::derive_stream_url(slug);
|
||||
|
||||
self.update_station_entry(slug, station_id, stream_url.clone());
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Rediscovered station {}: id={}, stream={}",
|
||||
slug,
|
||||
station_id,
|
||||
stream_url
|
||||
);
|
||||
|
||||
Ok((station_id, stream_url))
|
||||
}
|
||||
|
||||
/// Valide qu'une station existe toujours et que son stream est accessible
|
||||
///
|
||||
/// Vérifie deux choses :
|
||||
/// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France)
|
||||
/// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible)
|
||||
///
|
||||
/// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort.
|
||||
pub async fn validate_station(&self, slug: &str) -> Result<()> {
|
||||
// 1. Vérifier que l'API livemeta répond pour cette station
|
||||
let station_id = self
|
||||
.station_id_for_slug(slug)
|
||||
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?;
|
||||
|
||||
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
|
||||
let response = self.client.get(&url).timeout(self.timeout).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Station {} (id={}) no longer exists: livemeta returned {}",
|
||||
slug, station_id, response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let pull: PullResponse = response.json().await?;
|
||||
|
||||
// Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée
|
||||
if pull.station_id != station_id {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Station {} ID mismatch: stored={}, API returned={} — mapping is stale",
|
||||
slug, station_id, pull.station_id
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. Vérifier que l'URL icecast répond
|
||||
if let Some(stream_url) = self.stream_url_for_slug(slug) {
|
||||
if !stream_url.is_empty() {
|
||||
let stream_resp = self
|
||||
.client
|
||||
.head(&stream_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
if !stream_resp.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Stream for {} is not accessible: {} returned {}",
|
||||
slug, stream_url, stream_resp.status()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dérive l'URL icecast depuis le slug selon le pattern Radio France
|
||||
pub fn derive_stream_url(slug: &str) -> String {
|
||||
// fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais
|
||||
let name = slug.replace('_', "");
|
||||
format!("https://icecast.radiofrance.fr/{}-hifi.aac", name)
|
||||
}
|
||||
|
||||
/// Get only the current show metadata
|
||||
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
|
||||
let response = self.live_metadata(station).await?;
|
||||
@@ -464,13 +992,7 @@ impl RadioFranceClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
|
||||
let metadata = self.live_metadata(station).await?;
|
||||
|
||||
metadata
|
||||
.now
|
||||
.media
|
||||
.best_hifi_stream()
|
||||
.map(|s| s.url.clone())
|
||||
self.stream_url_for_slug(station)
|
||||
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
|
||||
}
|
||||
|
||||
@@ -619,6 +1141,7 @@ impl ClientBuilder {
|
||||
client,
|
||||
base_url: self.base_url,
|
||||
timeout: self.timeout,
|
||||
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -644,7 +1167,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
RadioFranceClient::parse_station_slug("francebleu_alsace"),
|
||||
("francebleu_alsace", None)
|
||||
("francebleu", Some("francebleu_alsace"))
|
||||
);
|
||||
assert_eq!(
|
||||
RadioFranceClient::parse_station_slug("franceculture"),
|
||||
|
||||
@@ -37,11 +37,24 @@ use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Default TTL for station list cache (7 days in seconds)
|
||||
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||
|
||||
/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable)
|
||||
pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600;
|
||||
|
||||
/// Informations d'une station : ID numérique et URL du stream HiFi
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StationInfo {
|
||||
/// ID numérique pour l'API livemeta/pull
|
||||
pub station_id: u32,
|
||||
/// URL du stream HiFi (icecast AAC ou MP3)
|
||||
pub stream_url: String,
|
||||
}
|
||||
|
||||
/// Cached station list (simplifié)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedStations {
|
||||
@@ -49,6 +62,14 @@ struct CachedStations {
|
||||
last_updated: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
/// Mapping slug → StationInfo avec timestamp de mise à jour
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedStationMapping {
|
||||
/// slug → { station_id, stream_url }
|
||||
mapping: HashMap<String, StationInfo>,
|
||||
last_updated: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
|
||||
///
|
||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
||||
@@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt {
|
||||
|
||||
/// Efface le cache des stations (force re-découverte)
|
||||
fn clear_radiofrance_station_cache(&self) -> Result<()>;
|
||||
|
||||
// ========================================================================
|
||||
// Station Mapping Cache (slug → station_id + stream_url)
|
||||
// ========================================================================
|
||||
|
||||
/// Récupère le mapping slug → StationInfo depuis le cache (si valide)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Some(HashMap)` si le cache existe et n'est pas expiré
|
||||
/// - `None` si absent ou TTL dépassé → il faut re-découvrir
|
||||
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>>;
|
||||
|
||||
/// Enregistre le mapping slug → StationInfo en cache
|
||||
fn set_radiofrance_station_mapping(&self, mapping: &HashMap<String, StationInfo>)
|
||||
-> Result<()>;
|
||||
|
||||
/// Récupère le TTL du mapping (en secondes, défaut 30 jours)
|
||||
fn get_radiofrance_mapping_ttl(&self) -> Result<u64>;
|
||||
|
||||
/// Définit le TTL du mapping (en secondes)
|
||||
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>;
|
||||
|
||||
/// Efface le cache du mapping (force re-découverte au prochain accès)
|
||||
fn clear_radiofrance_station_mapping(&self) -> Result<()>;
|
||||
|
||||
/// Met à jour une seule entrée dans le mapping (sans invalider tout le cache)
|
||||
///
|
||||
/// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire.
|
||||
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>;
|
||||
}
|
||||
|
||||
impl RadioFranceConfigExt for Config {
|
||||
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
|
||||
// Set to null to clear
|
||||
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
|
||||
}
|
||||
|
||||
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>> {
|
||||
let ttl = self.get_radiofrance_mapping_ttl()?;
|
||||
|
||||
match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
|
||||
Ok(value) => {
|
||||
let cached: CachedStationMapping = serde_yaml::from_value(value)?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if now - cached.last_updated < ttl {
|
||||
Ok(Some(cached.mapping))
|
||||
} else {
|
||||
Ok(None) // Expiré
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None), // Absent
|
||||
}
|
||||
}
|
||||
|
||||
fn set_radiofrance_station_mapping(
|
||||
&self,
|
||||
mapping: &HashMap<String, StationInfo>,
|
||||
) -> Result<()> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let cached = CachedStationMapping {
|
||||
mapping: mapping.clone(),
|
||||
last_updated: now,
|
||||
};
|
||||
|
||||
let value = serde_yaml::to_value(&cached)?;
|
||||
self.set_value(&["sources", "radiofrance", "station_mapping"], value)
|
||||
}
|
||||
|
||||
fn get_radiofrance_mapping_ttl(&self) -> Result<u64> {
|
||||
match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) {
|
||||
Ok(Value::Number(n)) => {
|
||||
if let Some(ttl) = n.as_u64() {
|
||||
Ok(ttl)
|
||||
} else {
|
||||
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
|
||||
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
|
||||
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> {
|
||||
self.set_value(
|
||||
&["sources", "radiofrance", "station_mapping_ttl_secs"],
|
||||
Value::Number(serde_yaml::Number::from(ttl_secs)),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_radiofrance_station_mapping(&self) -> Result<()> {
|
||||
self.set_value(
|
||||
&["sources", "radiofrance", "station_mapping"],
|
||||
Value::Null,
|
||||
)
|
||||
}
|
||||
|
||||
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> {
|
||||
// Charger le mapping existant (même expiré) pour mise à jour incrémentale
|
||||
let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
|
||||
Ok(value) => serde_yaml::from_value::<CachedStationMapping>(value)
|
||||
.map(|c| c.mapping)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
};
|
||||
|
||||
mapping.insert(slug.to_string(), info);
|
||||
|
||||
self.set_radiofrance_station_mapping(&mapping)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -122,7 +122,7 @@ pub use models::{
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::RadioFranceConfigExt;
|
||||
pub use config_ext::{RadioFranceConfigExt, StationInfo};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
|
||||
|
||||
use crate::client::RadioFranceClient;
|
||||
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImageSize, LiveResponse, Station};
|
||||
use pmoconfig::Config;
|
||||
#[cfg(feature = "playlist")]
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
|
||||
|
||||
// TTL = end_time de l'API Radio France
|
||||
pub end_time: Option<u64>, // Unix timestamp
|
||||
|
||||
/// Timestamp de la dernière récupération (pour TTL minimum)
|
||||
pub fetched_at: u64,
|
||||
}
|
||||
|
||||
impl CachedMetadata {
|
||||
@@ -89,6 +94,10 @@ impl CachedMetadata {
|
||||
|
||||
// 4. TTL = end_time
|
||||
let end_time = live.now.end_time;
|
||||
let fetched_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
Ok(Self {
|
||||
slug: station.slug.clone(),
|
||||
@@ -106,6 +115,7 @@ impl CachedMetadata {
|
||||
nr_audio_channels,
|
||||
duration,
|
||||
end_time,
|
||||
fetched_at,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,6 +359,7 @@ impl CachedMetadata {
|
||||
/// Construit un Container DIDL de playlist à un item
|
||||
///
|
||||
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
|
||||
#[cfg(feature = "playlist")]
|
||||
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
|
||||
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
|
||||
let duration = if let Some(end) = self.end_time {
|
||||
@@ -432,17 +443,23 @@ impl CachedMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// TTL minimum quand l'API ne fournit pas de end_time (3 minutes)
|
||||
pub const FALLBACK_TTL_SECS: u64 = 3 * 60;
|
||||
|
||||
/// Vérifie si le TTL est dépassé
|
||||
///
|
||||
/// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine
|
||||
/// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if let Some(end_time) = self.end_time {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
now >= end_time
|
||||
} else {
|
||||
// Pas de end_time = toujours expiré (refresh systématique)
|
||||
true
|
||||
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +495,9 @@ pub struct MetadataCache {
|
||||
|
||||
impl MetadataCache {
|
||||
/// Constructeur avec tous les paramètres obligatoires
|
||||
///
|
||||
/// Charge automatiquement le mapping station depuis pmoconfig (si valide).
|
||||
/// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client.
|
||||
#[cfg(feature = "cache")]
|
||||
pub fn new(
|
||||
client: RadioFranceClient,
|
||||
@@ -485,6 +505,25 @@ impl MetadataCache {
|
||||
server_base_url: String,
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
// Charger le mapping depuis pmoconfig et l'injecter dans le client
|
||||
if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Loaded station mapping from config ({} entries)",
|
||||
mapping.len()
|
||||
);
|
||||
client.set_station_mapping(mapping);
|
||||
} else {
|
||||
// Pas de cache persistant : persister le mapping par défaut
|
||||
let mapping = client.get_station_mapping();
|
||||
let _ = config.set_radiofrance_station_mapping(&mapping);
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Initialized station mapping from defaults ({} entries)",
|
||||
mapping.len()
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
client,
|
||||
@@ -525,18 +564,46 @@ impl MetadataCache {
|
||||
let live_response = match self.client.live_metadata(slug).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// Graceful degradation: retourner les données expirées si API down
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(metadata) = cache.get(slug) {
|
||||
// Si station inconnue → tenter une re-découverte du mapping
|
||||
let is_unknown = e.to_string().contains("Unknown station slug");
|
||||
if is_unknown {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"API Radio France down for {}, using expired cache: {}",
|
||||
slug,
|
||||
e
|
||||
);
|
||||
return Ok(metadata.clone());
|
||||
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
|
||||
|
||||
match self.client.rediscover_station(slug).await {
|
||||
Ok(_) => {
|
||||
// Persister le mapping mis à jour
|
||||
self.persist_station_mapping();
|
||||
// Réessayer la requête
|
||||
match self.client.live_metadata(slug).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e2) => return Err(e2),
|
||||
}
|
||||
}
|
||||
Err(discover_err) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::error!(
|
||||
"Rediscovery failed for '{}': {}",
|
||||
slug,
|
||||
discover_err
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Graceful degradation: retourner les données expirées si API down
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(metadata) = cache.get(slug) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"API Radio France down for {}, using expired cache: {}",
|
||||
slug,
|
||||
e
|
||||
);
|
||||
return Ok(metadata.clone());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -575,17 +642,34 @@ impl MetadataCache {
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Récupère la liste des stations (cache persistant via pmoconfig)
|
||||
/// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours)
|
||||
///
|
||||
/// # Logique
|
||||
///
|
||||
/// 1. Essaie de lire depuis pmoconfig
|
||||
/// 2. Si cache valide (TTL 1 semaine), retourne
|
||||
/// 3. Sinon, découvre via API et met à jour pmoconfig
|
||||
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
|
||||
/// 2. Si cache valide, retourne sans appel réseau
|
||||
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
|
||||
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
||||
// TODO: Implémenter avec config_ext
|
||||
// Pour l'instant, découvre directement
|
||||
self.client.discover_all_stations().await
|
||||
// 1. Essayer le cache persistant
|
||||
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Using cached station list ({} stations)", stations.len());
|
||||
return Ok(stations);
|
||||
}
|
||||
|
||||
// 2. Cache miss : découverte via scraping
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!("Station cache miss, discovering via web scraping...");
|
||||
|
||||
let stations = self.client.discover_all_stations().await?;
|
||||
|
||||
// 3. Persister dans pmoconfig
|
||||
if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to cache station list: {}", e);
|
||||
}
|
||||
|
||||
Ok(stations)
|
||||
}
|
||||
|
||||
/// Récupère les métadonnées live brutes de l'API (sans cache)
|
||||
@@ -601,6 +685,60 @@ impl MetadataCache {
|
||||
self.client.get_hifi_stream_url(slug).await
|
||||
}
|
||||
|
||||
/// Force une re-découverte du mapping pour un slug donné
|
||||
///
|
||||
/// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.)
|
||||
/// Persiste le mapping mis à jour dans pmoconfig.
|
||||
pub async fn handle_stream_failure(&self, slug: &str) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Stream failure for '{}', triggering rediscovery", slug);
|
||||
|
||||
match self.client.rediscover_station(slug).await {
|
||||
Ok((id, url)) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Re-discovered station '{}': id={}, url={}",
|
||||
slug,
|
||||
id,
|
||||
url
|
||||
);
|
||||
self.persist_station_mapping();
|
||||
// Invalider le cache mémoire pour forcer un refresh des métadonnées
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.remove(slug);
|
||||
}
|
||||
Err(e) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::error!("Rediscovery failed for '{}': {}", slug, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persiste le mapping courant dans pmoconfig
|
||||
fn persist_station_mapping(&self) {
|
||||
let mapping = self.client.get_station_mapping();
|
||||
if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to persist station mapping: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour manuellement le mapping d'une station et le persiste
|
||||
pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) {
|
||||
self.client
|
||||
.update_station_entry(slug, station_id, stream_url.clone());
|
||||
|
||||
// Persister dans pmoconfig
|
||||
let info = StationInfo {
|
||||
station_id,
|
||||
stream_url,
|
||||
};
|
||||
if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to upsert station info for '{}': {}", slug, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// S'abonner aux changements de métadonnées
|
||||
///
|
||||
/// Le callback sera appelé avec le slug chaque fois que
|
||||
@@ -634,7 +772,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cached_metadata_is_expired() {
|
||||
let metadata = CachedMetadata {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let base = CachedMetadata {
|
||||
slug: "test".to_string(),
|
||||
title: "Test".to_string(),
|
||||
creator: None,
|
||||
@@ -650,15 +793,26 @@ mod tests {
|
||||
nr_audio_channels: None,
|
||||
duration: None,
|
||||
end_time: Some(0), // Dans le passé
|
||||
fetched_at: now,
|
||||
};
|
||||
|
||||
assert!(metadata.is_expired());
|
||||
// end_time dans le passé → expiré
|
||||
assert!(base.is_expired());
|
||||
|
||||
let metadata_no_end = CachedMetadata {
|
||||
// end_time dans le futur → non expiré
|
||||
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
|
||||
assert!(!not_expired.is_expired());
|
||||
|
||||
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
|
||||
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
|
||||
assert!(!no_end_fresh.is_expired());
|
||||
|
||||
// Pas de end_time, fetched_at ancien → expiré
|
||||
let no_end_old = CachedMetadata {
|
||||
end_time: None,
|
||||
..metadata
|
||||
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
|
||||
..base
|
||||
};
|
||||
|
||||
assert!(metadata_no_end.is_expired());
|
||||
assert!(no_end_old.is_expired());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,14 +31,117 @@ impl Station {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live API Response Models
|
||||
// New livemeta/pull API Models (api.radiofrance.fr)
|
||||
// ============================================================================
|
||||
|
||||
/// Response from the /api/live? endpoint
|
||||
/// Response from https://api.radiofrance.fr/livemeta/pull/{stationId}
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullResponse {
|
||||
/// Numeric station ID
|
||||
pub station_id: u32,
|
||||
/// Map of stepId -> step metadata
|
||||
pub steps: std::collections::HashMap<String, PullStep>,
|
||||
/// Ordered levels (depth 1 = current show/song)
|
||||
pub levels: Vec<PullLevel>,
|
||||
}
|
||||
|
||||
impl PullResponse {
|
||||
/// Get the current step at depth 1 (the "now playing" item)
|
||||
pub fn current_step(&self) -> Option<&PullStep> {
|
||||
// levels[0].items[0] is the current item at depth 1 (most relevant)
|
||||
let level = self.levels.first()?;
|
||||
let step_id = level.items.first()?;
|
||||
self.steps.get(step_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A level in the livemeta response (groups steps by depth)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PullLevel {
|
||||
/// Ordered list of step IDs at this level
|
||||
pub items: Vec<String>,
|
||||
/// Depth (1 = show/song, 2 = sub-item, 3 = deeper)
|
||||
pub position: u32,
|
||||
}
|
||||
|
||||
/// A step (show, episode, or song) from the livemeta pull API
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullStep {
|
||||
pub uuid: String,
|
||||
pub step_id: String,
|
||||
pub title: String,
|
||||
pub start: Option<u64>,
|
||||
pub end: Option<u64>,
|
||||
pub station_id: u32,
|
||||
pub embed_type: Option<String>, // "song", "expression", "concept"
|
||||
pub depth: u32,
|
||||
pub disc_jockey: Option<String>,
|
||||
/// For songs: authors
|
||||
#[serde(default)]
|
||||
pub authors: serde_json::Value, // can be String or Vec<String>
|
||||
#[serde(default)]
|
||||
pub performers: Option<String>,
|
||||
#[serde(default)]
|
||||
pub highlighted_artists: Vec<String>,
|
||||
pub song_id: Option<String>,
|
||||
pub titre_album: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub annee_edition_musique: Option<u32>,
|
||||
pub cover_uuid: Option<String>,
|
||||
/// Direct UUID for visual (new API uses UUID directly, not URL)
|
||||
pub visual: Option<String>,
|
||||
/// For shows: concept title (e.g. "La Science, CQFD")
|
||||
pub title_concept: Option<String>,
|
||||
/// For shows: producers list
|
||||
#[serde(default)]
|
||||
pub producers: Vec<PullProducer>,
|
||||
pub path: Option<String>,
|
||||
pub expression_description: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl PullStep {
|
||||
/// Returns true if this step is a music track
|
||||
pub fn is_song(&self) -> bool {
|
||||
self.embed_type.as_deref() == Some("song")
|
||||
}
|
||||
|
||||
/// Get artist display string
|
||||
pub fn artists_display(&self) -> String {
|
||||
if !self.highlighted_artists.is_empty() {
|
||||
return self.highlighted_artists.join(", ");
|
||||
}
|
||||
match &self.authors {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A producer in a PullStep
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PullProducer {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live API Response Models (internal representation)
|
||||
// ============================================================================
|
||||
|
||||
/// Internal live metadata representation
|
||||
/// Built from PullResponse (new API) or used directly in tests
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveResponse {
|
||||
/// Station name (slug)
|
||||
/// Station slug
|
||||
pub station_name: String,
|
||||
/// Recommended delay before next refresh (milliseconds)
|
||||
pub delay_to_refresh: u64,
|
||||
@@ -59,7 +162,7 @@ impl LiveResponse {
|
||||
}
|
||||
|
||||
/// Metadata for a show or track currently playing
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShowMetadata {
|
||||
/// Whether to display music program info
|
||||
|
||||
@@ -98,18 +98,26 @@ impl StationGroups {
|
||||
|
||||
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
|
||||
///
|
||||
/// Appelle to_stub() sur chaque StationGroup
|
||||
/// Appelle to_stub() sur chaque StationGroup en parallèle
|
||||
pub async fn to_didl(
|
||||
&self,
|
||||
metadata_cache: &MetadataCache,
|
||||
server_base_url: &str,
|
||||
) -> Result<Container> {
|
||||
let mut containers = Vec::new();
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
for group in &self.groups {
|
||||
let container = group.to_stub(metadata_cache, server_base_url).await?;
|
||||
containers.push(container);
|
||||
}
|
||||
let futures: Vec<_> = self
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.to_stub(metadata_cache, server_base_url))
|
||||
.collect();
|
||||
|
||||
let results: Vec<Result<Container>> = stream::iter(futures)
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let containers: Vec<Container> = results.into_iter().collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(Container {
|
||||
id: "radiofrance".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user