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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,13 +16,14 @@
|
|||||||
**/*.mp4
|
**/*.mp4
|
||||||
**/*.mp3
|
**/*.mp3
|
||||||
**/*.ogg
|
**/*.ogg
|
||||||
|
|
||||||
xxx
|
xxx
|
||||||
/dcai/
|
/dcai/
|
||||||
**/.pmomusic.yml
|
**/.pmomusic.yml
|
||||||
**/.pmomusic_covers/**
|
**/.pmomusic_covers/**
|
||||||
**/.pmomusic_audio/**
|
**/.pmomusic_audio/**
|
||||||
/.pmomusic
|
/.pmomusic
|
||||||
.DS_Store
|
**/.DS_Store
|
||||||
/target
|
/target
|
||||||
**/target/
|
**/target/
|
||||||
/.pmomusic_covers
|
/.pmomusic_covers
|
||||||
|
|||||||
@@ -34,12 +34,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(intro) = &metadata.now.intro {
|
if let Some(intro) = &metadata.now.intro {
|
||||||
let short_intro = if intro.len() > 100 {
|
let short_intro: String = intro.chars().take(100).collect();
|
||||||
format!("{}...", &intro[..100])
|
let suffix = if intro.chars().count() > 100 { "..." } else { "" };
|
||||||
} else {
|
println!(" Description: {}{}", short_intro, suffix);
|
||||||
intro.clone()
|
|
||||||
};
|
|
||||||
println!(" Description: {}", short_intro);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Song info (for music stations)
|
// Song info (for music stations)
|
||||||
|
|||||||
@@ -28,17 +28,27 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
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 regex::Regex;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoconfig")]
|
||||||
|
use crate::config_ext::StationInfo;
|
||||||
|
|
||||||
/// Default Radio France base URL
|
/// Default Radio France base URL
|
||||||
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
|
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)
|
/// Default timeout for HTTP requests (30 seconds)
|
||||||
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
@@ -56,6 +66,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
|
|||||||
("francebleu", "France Bleu"),
|
("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
|
/// Radio France HTTP client
|
||||||
///
|
///
|
||||||
/// This client provides access to Radio France's public APIs for:
|
/// 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)
|
/// - Live metadata (current show, next show, stream URLs)
|
||||||
/// - Image URL construction (Pikapi)
|
/// - Image URL construction (Pikapi)
|
||||||
///
|
///
|
||||||
/// The client is stateless and does not cache responses internally.
|
/// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
|
||||||
/// Caching should be handled by higher layers (e.g., config extension).
|
/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
|
||||||
|
/// mapping loaded from persistent storage (pmoconfig).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RadioFranceClient {
|
pub struct RadioFranceClient {
|
||||||
pub(crate) client: Client,
|
pub(crate) client: Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
timeout: Duration,
|
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 {
|
impl RadioFranceClient {
|
||||||
@@ -91,6 +208,7 @@ impl RadioFranceClient {
|
|||||||
client,
|
client,
|
||||||
base_url: DEFAULT_BASE_URL.to_string(),
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
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
|
&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
|
// Station Discovery
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -254,20 +455,71 @@ impl RadioFranceClient {
|
|||||||
.collect())
|
.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
|
/// Scrapes francebleu page for local station slugs and discovers their IDs.
|
||||||
/// a `localRadios` array in the `now` field.
|
/// Met à jour le station_mapping avec les IDs trouvés.
|
||||||
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> {
|
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
|
// Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
|
||||||
.local_radios()
|
let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
|
||||||
.cloned()
|
let mut stations = Vec::new();
|
||||||
.unwrap_or_default()
|
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()
|
.into_iter()
|
||||||
.filter(|local| local.is_on_air)
|
.map(|slug| {
|
||||||
.map(|local| Station::new(local.name, local.title))
|
let name = Self::slug_to_display_name(&slug);
|
||||||
|
Station::new(slug, name)
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,28 +622,42 @@ impl RadioFranceClient {
|
|||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
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))?;
|
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
|
||||||
|
|
||||||
// Add webradio parameter if needed
|
|
||||||
if let Some(wr) = webradio {
|
|
||||||
url.query_pairs_mut().append_pair("webradio", wr);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!("Fetching live metadata: {}", url);
|
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() {
|
if !response.status().is_success() {
|
||||||
return Err(Error::ApiError(format!(
|
return Err(Error::ApiError(format!(
|
||||||
"API returned status: {}",
|
"livemeta API returned status {} for station {} (id={})",
|
||||||
response.status()
|
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")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -404,6 +670,268 @@ impl RadioFranceClient {
|
|||||||
Ok(live)
|
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
|
/// Get only the current show metadata
|
||||||
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
|
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
|
||||||
let response = self.live_metadata(station).await?;
|
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> {
|
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
|
||||||
let metadata = self.live_metadata(station).await?;
|
self.stream_url_for_slug(station)
|
||||||
|
|
||||||
metadata
|
|
||||||
.now
|
|
||||||
.media
|
|
||||||
.best_hifi_stream()
|
|
||||||
.map(|s| s.url.clone())
|
|
||||||
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
|
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +1141,7 @@ impl ClientBuilder {
|
|||||||
client,
|
client,
|
||||||
base_url: self.base_url,
|
base_url: self.base_url,
|
||||||
timeout: self.timeout,
|
timeout: self.timeout,
|
||||||
|
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,7 +1167,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RadioFranceClient::parse_station_slug("francebleu_alsace"),
|
RadioFranceClient::parse_station_slug("francebleu_alsace"),
|
||||||
("francebleu_alsace", None)
|
("francebleu", Some("francebleu_alsace"))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RadioFranceClient::parse_station_slug("franceculture"),
|
RadioFranceClient::parse_station_slug("franceculture"),
|
||||||
|
|||||||
@@ -37,11 +37,24 @@ use anyhow::Result;
|
|||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Default TTL for station list cache (7 days in seconds)
|
/// Default TTL for station list cache (7 days in seconds)
|
||||||
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
|
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é)
|
/// Cached station list (simplifié)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct CachedStations {
|
struct CachedStations {
|
||||||
@@ -49,6 +62,14 @@ struct CachedStations {
|
|||||||
last_updated: u64, // Unix timestamp
|
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
|
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
|
||||||
///
|
///
|
||||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
/// 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)
|
/// Efface le cache des stations (force re-découverte)
|
||||||
fn clear_radiofrance_station_cache(&self) -> Result<()>;
|
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 {
|
impl RadioFranceConfigExt for Config {
|
||||||
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
|
|||||||
// Set to null to clear
|
// Set to null to clear
|
||||||
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ pub use models::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub use config_ext::RadioFranceConfigExt;
|
pub use config_ext::{RadioFranceConfigExt, StationInfo};
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};
|
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
|
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
|
||||||
|
|
||||||
use crate::client::RadioFranceClient;
|
use crate::client::RadioFranceClient;
|
||||||
|
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{ImageSize, LiveResponse, Station};
|
use crate::models::{ImageSize, LiveResponse, Station};
|
||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
use pmodidl::{Container, Item, Resource};
|
use pmodidl::{Container, Item, Resource};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
|
|||||||
|
|
||||||
// TTL = end_time de l'API Radio France
|
// TTL = end_time de l'API Radio France
|
||||||
pub end_time: Option<u64>, // Unix timestamp
|
pub end_time: Option<u64>, // Unix timestamp
|
||||||
|
|
||||||
|
/// Timestamp de la dernière récupération (pour TTL minimum)
|
||||||
|
pub fetched_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CachedMetadata {
|
impl CachedMetadata {
|
||||||
@@ -89,6 +94,10 @@ impl CachedMetadata {
|
|||||||
|
|
||||||
// 4. TTL = end_time
|
// 4. TTL = end_time
|
||||||
let end_time = live.now.end_time;
|
let end_time = live.now.end_time;
|
||||||
|
let fetched_at = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
slug: station.slug.clone(),
|
slug: station.slug.clone(),
|
||||||
@@ -106,6 +115,7 @@ impl CachedMetadata {
|
|||||||
nr_audio_channels,
|
nr_audio_channels,
|
||||||
duration,
|
duration,
|
||||||
end_time,
|
end_time,
|
||||||
|
fetched_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +359,7 @@ impl CachedMetadata {
|
|||||||
/// Construit un Container DIDL de playlist à un item
|
/// Construit un Container DIDL de playlist à un item
|
||||||
///
|
///
|
||||||
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
|
/// 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 {
|
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
|
||||||
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
|
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
|
||||||
let duration = if let Some(end) = self.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é
|
/// 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 {
|
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 {
|
if let Some(end_time) = self.end_time {
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
now >= end_time
|
now >= end_time
|
||||||
} else {
|
} else {
|
||||||
// Pas de end_time = toujours expiré (refresh systématique)
|
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,6 +495,9 @@ pub struct MetadataCache {
|
|||||||
|
|
||||||
impl MetadataCache {
|
impl MetadataCache {
|
||||||
/// Constructeur avec tous les paramètres obligatoires
|
/// 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")]
|
#[cfg(feature = "cache")]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
client: RadioFranceClient,
|
client: RadioFranceClient,
|
||||||
@@ -485,6 +505,25 @@ impl MetadataCache {
|
|||||||
server_base_url: String,
|
server_base_url: String,
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
client,
|
client,
|
||||||
@@ -525,18 +564,46 @@ impl MetadataCache {
|
|||||||
let live_response = match self.client.live_metadata(slug).await {
|
let live_response = match self.client.live_metadata(slug).await {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Graceful degradation: retourner les données expirées si API down
|
// Si station inconnue → tenter une re-découverte du mapping
|
||||||
let cache = self.cache.read().await;
|
let is_unknown = e.to_string().contains("Unknown station slug");
|
||||||
if let Some(metadata) = cache.get(slug) {
|
if is_unknown {
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::warn!(
|
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
|
||||||
"API Radio France down for {}, using expired cache: {}",
|
|
||||||
slug,
|
match self.client.rediscover_station(slug).await {
|
||||||
e
|
Ok(_) => {
|
||||||
);
|
// Persister le mapping mis à jour
|
||||||
return Ok(metadata.clone());
|
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)
|
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
|
/// # Logique
|
||||||
///
|
///
|
||||||
/// 1. Essaie de lire depuis pmoconfig
|
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
|
||||||
/// 2. Si cache valide (TTL 1 semaine), retourne
|
/// 2. Si cache valide, retourne sans appel réseau
|
||||||
/// 3. Sinon, découvre via API et met à jour pmoconfig
|
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
|
||||||
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
||||||
// TODO: Implémenter avec config_ext
|
// 1. Essayer le cache persistant
|
||||||
// Pour l'instant, découvre directement
|
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
|
||||||
self.client.discover_all_stations().await
|
#[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)
|
/// 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
|
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
|
/// S'abonner aux changements de métadonnées
|
||||||
///
|
///
|
||||||
/// Le callback sera appelé avec le slug chaque fois que
|
/// Le callback sera appelé avec le slug chaque fois que
|
||||||
@@ -634,7 +772,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cached_metadata_is_expired() {
|
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(),
|
slug: "test".to_string(),
|
||||||
title: "Test".to_string(),
|
title: "Test".to_string(),
|
||||||
creator: None,
|
creator: None,
|
||||||
@@ -650,15 +793,26 @@ mod tests {
|
|||||||
nr_audio_channels: None,
|
nr_audio_channels: None,
|
||||||
duration: None,
|
duration: None,
|
||||||
end_time: Some(0), // Dans le passé
|
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,
|
end_time: None,
|
||||||
..metadata
|
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
|
||||||
|
..base
|
||||||
};
|
};
|
||||||
|
assert!(no_end_old.is_expired());
|
||||||
assert!(metadata_no_end.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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LiveResponse {
|
pub struct LiveResponse {
|
||||||
/// Station name (slug)
|
/// Station slug
|
||||||
pub station_name: String,
|
pub station_name: String,
|
||||||
/// Recommended delay before next refresh (milliseconds)
|
/// Recommended delay before next refresh (milliseconds)
|
||||||
pub delay_to_refresh: u64,
|
pub delay_to_refresh: u64,
|
||||||
@@ -59,7 +162,7 @@ impl LiveResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata for a show or track currently playing
|
/// Metadata for a show or track currently playing
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ShowMetadata {
|
pub struct ShowMetadata {
|
||||||
/// Whether to display music program info
|
/// Whether to display music program info
|
||||||
|
|||||||
@@ -98,18 +98,26 @@ impl StationGroups {
|
|||||||
|
|
||||||
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
|
/// 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(
|
pub async fn to_didl(
|
||||||
&self,
|
&self,
|
||||||
metadata_cache: &MetadataCache,
|
metadata_cache: &MetadataCache,
|
||||||
server_base_url: &str,
|
server_base_url: &str,
|
||||||
) -> Result<Container> {
|
) -> Result<Container> {
|
||||||
let mut containers = Vec::new();
|
use futures::stream::{self, StreamExt};
|
||||||
|
|
||||||
for group in &self.groups {
|
let futures: Vec<_> = self
|
||||||
let container = group.to_stub(metadata_cache, server_base_url).await?;
|
.groups
|
||||||
containers.push(container);
|
.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 {
|
Ok(Container {
|
||||||
id: "radiofrance".to_string(),
|
id: "radiofrance".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user