diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs index f4bace58..041dd642 100644 --- a/pmoradiofrance/src/client.rs +++ b/pmoradiofrance/src/client.rs @@ -54,113 +54,9 @@ pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; /// Default User-Agent pub const DEFAULT_USER_AGENT: &str = "PMOMusic/0.3.10 (pmoradiofrance)"; -/// Known main stations (fallback if scraping fails) -pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ - ("franceinter", "France Inter"), - ("franceinfo", "France Info"), - ("franceculture", "France Culture"), - ("francemusique", "France Musique"), - ("fip", "FIP"), - ("mouv", "Mouv'"), - ("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", - ), -]; +// Données des stations — générées par tools/generate_radiofrance_stations.py +// Pour mettre à jour : python3 tools/generate_radiofrance_stations.py > pmoradiofrance/src/stations_data.rs +include!("stations_data.rs"); /// Radio France HTTP client /// @@ -454,70 +350,20 @@ impl RadioFranceClient { .collect()) } - /// Discover local France Bleu radios via SvelteKit page data + /// Discover local France Bleu radios /// - /// Scrapes francebleu page for local station slugs and discovers their IDs. - /// Met à jour le station_mapping avec les IDs trouvés. + /// Returns the list of France Bleu local radios from the static STATION_IDS mapping. + /// + /// The IDs in STATION_IDS were discovered via the livemeta/pull API in March 2026 and + /// are stable (the API has been consistent since 2019). France Bleu's local radio + /// network is essentially fixed, so a static list is sufficient and robust. pub async fn discover_local_radios(&self) -> Result> { - let url = format!("{}/francebleu/__data.json", self.base_url); - let resp = self - .client - .get(&url) - .timeout(self.timeout) - .send() - .await? - .text() - .await?; - - // 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::() { - 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 = HashSet::new(); - for cap in re_slug.captures_iter(&html) { - slugs.insert(cap[1].to_string()); - } - - let _names: Vec = re_ici - .captures_iter(&html) - .map(|c| c[1].trim().to_string()) - .collect(); - - Ok(slugs - .into_iter() - .map(|slug| { - let name = Self::slug_to_display_name(&slug); - Station::new(slug, name) + Ok(STATION_IDS + .iter() + .filter(|(slug, _)| slug.starts_with("francebleu_")) + .map(|(slug, _)| { + let name = Self::slug_to_display_name(slug); + Station::new(slug.to_string(), name) }) .collect()) } @@ -716,14 +562,23 @@ impl RadioFranceClient { 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, + // Visual : l'API peut retourner soit un UUID Pikapi, soit une URL S3/CDN complète + let visual_background = step.visual.as_deref().map(|visual| { + let src = if visual.starts_with("http") { + // URL directe (S3, CDN) — utiliser telle quelle + visual.to_string() + } else { + // UUID Pikapi — construire l'URL + ImageSize::Large.build_url(visual) + }; + EmbedImage { + model: "EmbedImage".to_string(), + src, + width: None, + height: None, + dominant: None, + copyright: None, + } }); if step.is_song() { diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index 53299e4a..3c1a9ffd 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -210,12 +210,24 @@ impl CachedMetadata { cache: &Arc, server_base_url: &str, ) -> (Option, Option) { - // Extraire l'UUID de la cover (priorité : visual_background > visuals.card > visuals.player) - let uuid = metadata + // Extraire l'URL de la cover — deux stratégies selon la source : + // 1. visual_background.src peut être une URL S3 directe ou une URL Pikapi + // 2. visuals.card/player contiennent des URLs Pikapi avec UUID extractable + let cover_url = metadata .now .visual_background .as_ref() - .and_then(|v| v.extract_uuid()) + .map(|v| { + // Si c'est déjà une URL directe (S3, CDN), l'utiliser telle quelle + // Sinon essayer d'extraire l'UUID Pikapi pour construire une URL haute-résolution + if v.src.starts_with("http") && !v.src.contains("/pikapi/") { + v.src.clone() + } else if let Some(uuid) = v.extract_uuid() { + ImageSize::Large.build_url(&uuid) + } else { + v.src.clone() + } + }) .or_else(|| { metadata.now.visuals.as_ref().and_then(|visuals| { visuals @@ -223,13 +235,13 @@ impl CachedMetadata { .as_ref() .and_then(|c| c.extract_uuid()) .or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid())) + .map(|uuid| ImageSize::Large.build_url(&uuid)) }) }); - let uuid = match uuid { + let cover_url = match cover_url { Some(u) => u, None => { - // Fallback sur le logo par défaut via l'API REST let logo_url = format!( "{}/api/radiofrance/default-logo", server_base_url.trim_end_matches('/') @@ -238,9 +250,6 @@ impl CachedMetadata { } }; - // URL haute résolution - let cover_url = ImageSize::Large.build_url(&uuid); - // Tenter de cacher la cover match cache.add_from_url(&cover_url, Some("radiofrance")).await { Ok(pk) => { @@ -252,8 +261,8 @@ impl CachedMetadata { #[cfg(feature = "logging")] tracing::debug!( - "Cached cover - UUID: {}, PK: {}, public_url: {}", - uuid, + "Cached cover - url: {}, PK: {}, public_url: {}", + cover_url, pk, public_url ); @@ -262,7 +271,7 @@ impl CachedMetadata { } Err(e) => { #[cfg(feature = "logging")] - tracing::warn!("Failed to cache Radio France cover UUID {}: {}", uuid, e); + tracing::warn!("Failed to cache Radio France cover {}: {}", cover_url, e); // Fallback sur le logo par défaut en cas d'erreur let logo_url = format!( "{}/api/radiofrance/default-logo", @@ -623,14 +632,21 @@ impl MetadataCache { ) .await?; - // 4. Met à jour le cache - { + // 4. Met à jour le cache — notifier seulement si le titre a changé (vrai changement de piste) + let should_notify = { let mut cache = self.cache.write().await; + let changed = cache + .get(slug) + .map(|old| old.title != metadata.title) + .unwrap_or(false); // premier fetch → pas de notification cache.insert(slug.to_string(), metadata.clone()); - } + changed + }; - // 5. Notifie les abonnés (async) - self.notify_async(slug).await; + // 5. Notifie les abonnés uniquement si la piste a changé + if should_notify { + self.notify_async(slug).await; + } // 6. Retourne les métadonnées Ok(metadata) diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 5c0a2fd4..19b70e19 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -332,16 +332,63 @@ impl Station { pub async fn to_didl( &self, metadata_cache: &MetadataCache, - _server_base_url: &str, + server_base_url: &str, ) -> Result { - // Récupérer les métadonnées du cache - let cached_metadata = metadata_cache.get(&self.slug).await?; - - // Construire le container de playlist avec l'item via CachedMetadata::to_didl() let playlist_id = format!("radiofrance:{}", self.slug); let parent_id = self.compute_parent_id(); - Ok(cached_metadata.to_didl(&playlist_id, &parent_id)) + // Graceful degradation : si l'API échoue, construire un item minimal avec juste l'URL de stream + match metadata_cache.get(&self.slug).await { + Ok(cached_metadata) => Ok(cached_metadata.to_didl(&playlist_id, &parent_id)), + Err(_) => { + use pmodidl::{Item, Resource}; + let stream_url = format!( + "{}/api/radiofrance/{}/stream", + server_base_url.trim_end_matches('/'), + self.slug + ); + let item = Item { + id: format!("{}:stream", playlist_id), + parent_id: playlist_id.clone(), + restricted: Some("1".to_string()), + title: self.name.clone(), + creator: None, + class: "object.item.audioItem.audioBroadcast".to_string(), + artist: Some("Radio France".to_string()), + album: None, + genre: None, + album_art: Some(format!( + "{}/api/radiofrance/default-logo", + server_base_url.trim_end_matches('/') + )), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![Resource { + protocol_info: "http-get:*:audio/aac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: None, + url: stream_url, + }], + descriptions: vec![], + }; + Ok(Container { + id: playlist_id, + parent_id, + restricted: Some("1".to_string()), + child_count: Some("1".to_string()), + searchable: Some("0".to_string()), + title: self.name.clone(), + class: "object.container.playlistContainer".to_string(), + artist: Some("Radio France".to_string()), + album_art: None, + containers: vec![], + items: vec![item], + }) + } + } } /// Calcule le parent_id selon la position de la station diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index b9e87789..4348f7a2 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -275,10 +275,10 @@ impl MusicSource for RadioFranceSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - // Si c'est une playlist (1 station), retourner le container lui-même - // Sinon retourner ses sous-containers + // Si c'est une playlist (1 station), retourner ses items directement + // Sinon retourner ses sous-containers (stations du groupe) if container.class == "object.container.playlistContainer" { - Ok(BrowseResult::Containers(vec![container])) + Ok(BrowseResult::Items(container.items)) } else { Ok(BrowseResult::Containers(container.containers)) } diff --git a/pmoradiofrance/src/stations_data.rs b/pmoradiofrance/src/stations_data.rs new file mode 100644 index 00000000..0ebb57f0 --- /dev/null +++ b/pmoradiofrance/src/stations_data.rs @@ -0,0 +1,181 @@ +// ============================================================ +// FICHIER GÉNÉRÉ AUTOMATIQUEMENT — NE PAS MODIFIER À LA MAIN +// +// Généré par : tools/generate_radiofrance_stations.py +// Date : 2026-03-25 +// Source : API GraphQL Radio France (openapi.radiofrance.fr) +// Token : token de secours (AlexandrePradeilles/ECI, 2022) +// Stations : 73 +// +// Pour mettre à jour : +// python3 tools/generate_radiofrance_stations.py +// ============================================================ + +pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ + ("franceinter", "France Inter"), + ("franceinfo", "franceinfo"), + ("francemusique", "France Musique"), + ("franceculture", "France Culture"), + ("mouv", "Mouv\'"), + ("fip", "FIP"), +]; + +/// Mapping slug → ID numérique livemeta/pull API +pub const STATION_IDS: &[(&str, u32)] = &[ + // Stations principales + ("franceinter", 1), + ("franceinfo", 2), + ("francemusique", 4), + ("franceculture", 5), + ("mouv", 6), + ("fip", 7), + // Webradios + ("franceinter_la_musique_inter", 1101), // La musique d\'Inter + ("francemusique_classique_easy", 401), // Classique Easy + ("francemusique_classique_plus", 402), // Classique Plus + ("francemusique_concert_rf", 403), // Concerts Radio France + ("francemusique_ocora_monde", 404), // Ocora Musiques du Monde + ("francemusique_la_jazz", 405), // La Jazz + ("francemusique_la_contemporaine", 406), // La Contemporaine + ("francemusique_la_bo", 407), // Musique de Films + ("francemusique_la_baroque", 408), // La Baroque + ("francemusique_opera", 409), // Opéra + ("francemusique_piano_zen", 410), // Piano Zen + ("fip_rock", 64), // FIP Rock + ("fip_jazz", 65), // FIP Jazz + ("fip_groove", 66), // FIP Groove + ("fip_world", 69), // FIP Monde + ("fip_nouveautes", 70), // FIP Nouveautés + ("fip_reggae", 71), // FIP Reggae + ("fip_electro", 74), // FIP Electro + ("fip_metal", 77), // FIP Metal + ("fip_pop", 78), // FIP Pop + ("fip_hiphop", 95), // FIP Hip-Hop + ("francebleu_chanson_francaise", 5601), // 100% chanson française + // Radios ICI (France Bleu locales) + ("francebleu_rcfm", 11), // ICI RCFM + ("francebleu_alsace", 12), // ICI Alsace + ("francebleu_armorique", 13), // ICI Armorique + ("francebleu_auxerre", 14), // ICI Auxerre + ("francebleu_bearn", 15), // ICI Béarn Bigorre + ("francebleu_belfort_montbeliard", 16), // ICI Belfort-Montbéliard + ("francebleu_berry", 17), // ICI Berry + ("francebleu_besancon", 18), // ICI Besançon + ("francebleu_bourgogne", 19), // ICI Bourgogne + ("francebleu_breizh_izel", 20), // ICI Breizh Izel + ("francebleu_champagne_ardenne", 21), // ICI Champagne-Ardenne + ("francebleu_cotentin", 22), // ICI Cotentin + ("francebleu_creuse", 23), // ICI Creuse + ("francebleu_drome_ardeche", 24), // ICI Drôme Ardèche + ("francebleu_gard_lozere", 25), // ICI Gard Lozère + ("francebleu_gascogne", 26), // ICI Gascogne + ("francebleu_gironde", 27), // ICI Gironde + ("francebleu_herault", 28), // ICI Hérault + ("francebleu_isere", 29), // ICI Isère + ("francebleu_la_rochelle", 30), // ICI La Rochelle + ("francebleu_limousin", 31), // ICI Limousin + ("francebleu_loire_ocean", 32), // ICI Loire Océan + ("francebleu_lorraine", 33), // ICI Lorraine (Meurthe-et-Moselle et Vosges) + ("francebleu_mayenne", 34), // ICI Mayenne + ("francebleu_nord", 36), // ICI Nord + ("francebleu_normandie", 37), // ICI Normandie (Calvados - Orne) + ("francebleu_normandie_seine_maritime", 38), // ICI Normandie (Seine-Maritime - Eure) + ("francebleu_orleans", 39), // ICI Orléans + ("francebleu_pays_d_auvergne", 40), // ICI Pays d\'Auvergne + ("francebleu_pays_basque", 41), // ICI Pays Basque + ("francebleu_pays_de_savoie", 42), // ICI Pays de Savoie + ("francebleu_perigord", 43), // ICI Périgord + ("francebleu_picardie", 44), // ICI Picardie + ("francebleu_provence", 45), // ICI Provence + ("francebleu_roussillon", 46), // ICI Roussillon + ("francebleu_touraine", 47), // ICI Touraine + ("francebleu_vaucluse", 48), // ICI Vaucluse + ("francebleu_azur", 49), // ICI Azur + ("francebleu_lorraine_nord", 50), // ICI Lorraine (Moselle et Pays Haut) + ("francebleu_poitou", 54), // ICI Poitou + ("francebleu_paris", 68), // ICI Paris Île-de-France + ("francebleu_elsass", 90), // ICI Elsass + ("francebleu_maine", 91), // ICI Maine + ("francebleu_occitanie", 92), // ICI Occitanie + ("francebleu_saint_etienne_loire", 93), // ICI Saint-Étienne Loire +]; + +/// URLs HiFi AAC Icecast (dérivées des URLs midfi de l'API GraphQL) +pub const STATION_STREAMS: &[(&str, &str)] = &[ + // Stations principales + ("franceinter", "https://icecast.radiofrance.fr/franceinter-hifi.aac"), + ("franceinfo", "https://icecast.radiofrance.fr/franceinfo-hifi.aac"), + ("francemusique", "https://icecast.radiofrance.fr/francemusique-hifi.aac"), + ("franceculture", "https://icecast.radiofrance.fr/franceculture-hifi.aac"), + ("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"), + ("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"), + // Webradios + ("franceinter_la_musique_inter", "https://icecast.radiofrance.fr/franceinterlamusiqueinter-hifi.aac"), // La musique d\'Inter + ("francemusique_classique_easy", "https://icecast.radiofrance.fr/francemusiqueeasyclassique-hifi.aac"), // Classique Easy + ("francemusique_classique_plus", "https://icecast.radiofrance.fr/francemusiqueclassiqueplus-hifi.aac"), // Classique Plus + ("francemusique_concert_rf", "https://icecast.radiofrance.fr/francemusiqueconcertsradiofrance-hifi.aac"), // Concerts Radio France + ("francemusique_ocora_monde", "https://icecast.radiofrance.fr/francemusiqueocoramonde-hifi.aac"), // Ocora Musiques du Monde + ("francemusique_la_jazz", "https://icecast.radiofrance.fr/francemusiquelajazz-hifi.aac"), // La Jazz + ("francemusique_la_contemporaine", "https://icecast.radiofrance.fr/francemusiquelacontemporaine-hifi.aac"), // La Contemporaine + ("francemusique_la_bo", "https://icecast.radiofrance.fr/francemusiquelabo-hifi.aac"), // Musique de Films + ("francemusique_la_baroque", "https://icecast.radiofrance.fr/francemusiquebaroque-hifi.aac"), // La Baroque + ("francemusique_opera", "https://icecast.radiofrance.fr/francemusiqueopera-hifi.aac"), // Opéra + ("francemusique_piano_zen", "https://icecast.radiofrance.fr/francemusiquepianozen-hifi.aac"), // Piano Zen + ("fip_rock", "https://icecast.radiofrance.fr/fiprock-hifi.aac"), // FIP Rock + ("fip_jazz", "https://icecast.radiofrance.fr/fipjazz-hifi.aac"), // FIP Jazz + ("fip_groove", "https://icecast.radiofrance.fr/fipgroove-hifi.aac"), // FIP Groove + ("fip_world", "https://icecast.radiofrance.fr/fipworld-hifi.aac"), // FIP Monde + ("fip_nouveautes", "https://icecast.radiofrance.fr/fipnouveautes-hifi.aac"), // FIP Nouveautés + ("fip_reggae", "https://icecast.radiofrance.fr/fipreggae-hifi.aac"), // FIP Reggae + ("fip_electro", "https://icecast.radiofrance.fr/fipelectro-hifi.aac"), // FIP Electro + ("fip_metal", "https://icecast.radiofrance.fr/fipmetal-hifi.aac"), // FIP Metal + ("fip_pop", "https://icecast.radiofrance.fr/fippop-hifi.aac"), // FIP Pop + ("fip_hiphop", "https://icecast.radiofrance.fr/fiphiphop-hifi.aac"), // FIP Hip-Hop + ("francebleu_chanson_francaise", "https://icecast.radiofrance.fr/fbchansonfrancaise-hifi.aac"), // 100% chanson française + // Radios ICI (France Bleu locales) + ("francebleu_rcfm", "https://icecast.radiofrance.fr/fbfrequenzamora-hifi.aac"), // ICI RCFM + ("francebleu_alsace", "https://icecast.radiofrance.fr/fbalsace-hifi.aac"), // ICI Alsace + ("francebleu_armorique", "https://icecast.radiofrance.fr/fbarmorique-hifi.aac"), // ICI Armorique + ("francebleu_auxerre", "https://icecast.radiofrance.fr/fbauxerre-hifi.aac"), // ICI Auxerre + ("francebleu_bearn", "https://icecast.radiofrance.fr/fbbearn-hifi.aac"), // ICI Béarn Bigorre + ("francebleu_belfort_montbeliard", "https://icecast.radiofrance.fr/fbbelfort-hifi.aac"), // ICI Belfort-Montbéliard + ("francebleu_berry", "https://icecast.radiofrance.fr/fbberry-hifi.aac"), // ICI Berry + ("francebleu_besancon", "https://icecast.radiofrance.fr/fbbesancon-hifi.aac"), // ICI Besançon + ("francebleu_bourgogne", "https://icecast.radiofrance.fr/fbbourgogne-hifi.aac"), // ICI Bourgogne + ("francebleu_breizh_izel", "https://icecast.radiofrance.fr/fbbreizizel-hifi.aac"), // ICI Breizh Izel + ("francebleu_champagne_ardenne", "https://icecast.radiofrance.fr/fbchampagne-hifi.aac"), // ICI Champagne-Ardenne + ("francebleu_cotentin", "https://icecast.radiofrance.fr/fbcotentin-hifi.aac"), // ICI Cotentin + ("francebleu_creuse", "https://icecast.radiofrance.fr/fbcreuse-hifi.aac"), // ICI Creuse + ("francebleu_drome_ardeche", "https://icecast.radiofrance.fr/fbdromeardeche-hifi.aac"), // ICI Drôme Ardèche + ("francebleu_gard_lozere", "https://icecast.radiofrance.fr/fbgardlozere-hifi.aac"), // ICI Gard Lozère + ("francebleu_gascogne", "https://icecast.radiofrance.fr/fbgascogne-hifi.aac"), // ICI Gascogne + ("francebleu_gironde", "https://icecast.radiofrance.fr/fbgironde-hifi.aac"), // ICI Gironde + ("francebleu_herault", "https://icecast.radiofrance.fr/fbherault-hifi.aac"), // ICI Hérault + ("francebleu_isere", "https://icecast.radiofrance.fr/fbisere-hifi.aac"), // ICI Isère + ("francebleu_la_rochelle", "https://icecast.radiofrance.fr/fblarochelle-hifi.aac"), // ICI La Rochelle + ("francebleu_limousin", "https://icecast.radiofrance.fr/fblimousin-hifi.aac"), // ICI Limousin + ("francebleu_loire_ocean", "https://icecast.radiofrance.fr/fbloireocean-hifi.aac"), // ICI Loire Océan + ("francebleu_lorraine", "https://icecast.radiofrance.fr/fbsudlorraine-hifi.aac"), // ICI Lorraine (Meurthe-et-Moselle et Vosges) + ("francebleu_mayenne", "https://icecast.radiofrance.fr/fbmayenne-hifi.aac"), // ICI Mayenne + ("francebleu_nord", "https://icecast.radiofrance.fr/fbnord-hifi.aac"), // ICI Nord + ("francebleu_normandie", "https://icecast.radiofrance.fr/fbbassenormandie-hifi.aac"), // ICI Normandie (Calvados - Orne) + ("francebleu_normandie_seine_maritime", "https://icecast.radiofrance.fr/fbhautenormandie-hifi.aac"), // ICI Normandie (Seine-Maritime - Eure) + ("francebleu_orleans", "https://icecast.radiofrance.fr/fborleans-hifi.aac"), // ICI Orléans + ("francebleu_pays_d_auvergne", "https://icecast.radiofrance.fr/fbpaysdauvergne-hifi.aac"), // ICI Pays d\'Auvergne + ("francebleu_pays_basque", "https://icecast.radiofrance.fr/fbpaysbasque-hifi.aac"), // ICI Pays Basque + ("francebleu_pays_de_savoie", "https://icecast.radiofrance.fr/fbpaysdesavoie-hifi.aac"), // ICI Pays de Savoie + ("francebleu_perigord", "https://icecast.radiofrance.fr/fbperigord-hifi.aac"), // ICI Périgord + ("francebleu_picardie", "https://icecast.radiofrance.fr/fbpicardie-hifi.aac"), // ICI Picardie + ("francebleu_provence", "https://icecast.radiofrance.fr/fbprovence-hifi.aac"), // ICI Provence + ("francebleu_roussillon", "https://icecast.radiofrance.fr/fbroussillon-hifi.aac"), // ICI Roussillon + ("francebleu_touraine", "https://icecast.radiofrance.fr/fbtouraine-hifi.aac"), // ICI Touraine + ("francebleu_vaucluse", "https://icecast.radiofrance.fr/fbvaucluse-hifi.aac"), // ICI Vaucluse + ("francebleu_azur", "https://icecast.radiofrance.fr/fbazur-hifi.aac"), // ICI Azur + ("francebleu_lorraine_nord", "https://icecast.radiofrance.fr/fblorrainenord-hifi.aac"), // ICI Lorraine (Moselle et Pays Haut) + ("francebleu_poitou", "https://icecast.radiofrance.fr/fbpoitou-hifi.aac"), // ICI Poitou + ("francebleu_paris", "https://icecast.radiofrance.fr/fb1071-hifi.aac"), // ICI Paris Île-de-France + ("francebleu_elsass", "https://icecast.radiofrance.fr/fbelsass-hifi.aac"), // ICI Elsass + ("francebleu_maine", "https://icecast.radiofrance.fr/fbmaine-hifi.aac"), // ICI Maine + ("francebleu_occitanie", "https://icecast.radiofrance.fr/fbtoulouse-hifi.aac"), // ICI Occitanie + ("francebleu_saint_etienne_loire", "https://icecast.radiofrance.fr/fbstetienne-hifi.aac"), // ICI Saint-Étienne Loire +]; diff --git a/tools/generate_radiofrance_stations.py b/tools/generate_radiofrance_stations.py new file mode 100644 index 00000000..1421319a --- /dev/null +++ b/tools/generate_radiofrance_stations.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +Génère les constantes Rust pour pmoradiofrance depuis l'API GraphQL Radio France. + +Usage: + python3 generate_radiofrance_stations.py --token + python3 generate_radiofrance_stations.py # utilise RADIOFRANCE_TOKEN env var + +Token de secours (token public trouvé dans AlexandrePradeilles/ECI, actif depuis 2022) : + 36bee04f-68a9-4bf8-8f2c-0662b454192c + +Le script interroge l'API GraphQL officielle et génère le code Rust pour : +- KNOWN_MAIN_STATIONS : stations principales (slug, nom officiel) +- STATION_IDS : slug → ID numérique livemeta/pull +- STATION_STREAMS : slug → URL HiFi AAC Icecast +""" + +import argparse +import os +import sys +import json +import urllib.request +import urllib.parse +import re + +GRAPHQL_URL = "https://openapi.radiofrance.fr/v1/graphql" + +# Token de secours — trouvé dans le dépôt public AlexandrePradeilles/ECI (oct. 2022), +# toujours actif. À remplacer par ton propre token si révoqué. +FALLBACK_TOKEN = "36bee04f-68a9-4bf8-8f2c-0662b454192c" + +QUERY = """ +{ + brands { + id + title + playerUrl + liveStream + webRadios { + id + title + playerUrl + liveStream + } + localRadios { + id + title + playerUrl + liveStream + } + } +} +""" + +# Slugs manuels pour les cas où la dérivation automatique ne correspond pas +# à la convention déjà établie dans le code Rust. +# Format : GraphQL_ID → slug_rust +SLUG_OVERRIDES = { + # LocalRadios dont l'ID GraphQL manque le préfixe francebleu_ + "ELSASS": "francebleu_elsass", + # Stations dont le slug GraphQL diffère du slug Rust historique + "FRANCEBLEU_SUR_LORRAINE": "francebleu_lorraine", + "FRANCEBLEU_NORMANDIE_CAEN": "francebleu_normandie", + "FRANCEBLEU_NORMANDIE_ROUEN": "francebleu_normandie_seine_maritime", + "FRANCEBLEU_TOULOUSE": "francebleu_occitanie", +} + +# Slugs qui ne correspondent pas à la conversion lowercase de l'ID GraphQL +SLUG_OVERRIDES.update({ + "FIP_HIP_HOP": "fip_hiphop", +}) + +# Mouv' webradios : pas de liveStream donc inutilisables, on les exclut +EXCLUDED_WEBRADIO_IDS = { + "MOUV_100MIX", + "MOUV_CLASSICS", + "MOUV_DANCEHALL", + "MOUV_RNB", + "MOUV_RAPUS", + "MOUV_RAPFR", +} + +# Brands à exclure en tant que station principale (pas de stream au niveau brand) +# mais dont on garde les webRadios et localRadios +EXCLUDED_BRAND_STREAM_IDS = {"FRANCEBLEU"} + + +def graphql_id_to_slug(graphql_id: str, is_local_radio: bool = False) -> str: + """Convertit un ID GraphQL en slug Rust.""" + if graphql_id in SLUG_OVERRIDES: + return SLUG_OVERRIDES[graphql_id] + + slug = graphql_id.lower() + + # LocalRadios sous FRANCEBLEU qui n'ont pas le préfixe + if is_local_radio and not slug.startswith("francebleu_"): + slug = "francebleu_" + slug + + return slug + + +def midfi_to_hifi(stream_url: str) -> str | None: + """Convertit une URL midfi MP3 en URL HiFi AAC.""" + if not stream_url: + return None + # https://icecast.radiofrance.fr/fiprock-midfi.mp3?id=openapi + # → https://icecast.radiofrance.fr/fiprock-hifi.aac + url = re.sub(r"-midfi\.mp3\?.*$", "-hifi.aac", stream_url) + if url == stream_url: + return None # pattern non reconnu + return url + + +def extract_livemeta_id(player_url: str | None) -> int | None: + """Extrait l'ID numérique depuis l'URL du player embed.""" + if not player_url: + return None + m = re.search(r"id_station=(\d+)", player_url) + return int(m.group(1)) if m else None + + +def query_api(token: str) -> dict: + url = f"{GRAPHQL_URL}?x-token={token}" + payload = json.dumps({"query": QUERY}).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + + +def collect_stations(data: dict) -> list[dict]: + """ + Retourne une liste plate de stations : + { slug, name, livemeta_id, hifi_url, kind } + kind ∈ { "brand", "webradio", "local" } + """ + stations = [] + + for brand in data["data"]["brands"]: + brand_id = brand["id"] + + # Station principale — seulement si elle a un stream au niveau brand + if brand_id not in EXCLUDED_BRAND_STREAM_IDS: + livemeta_id = extract_livemeta_id(brand.get("playerUrl")) + hifi_url = midfi_to_hifi(brand.get("liveStream") or "") + if not hifi_url: + slug = graphql_id_to_slug(brand_id) + hifi_url = f"https://icecast.radiofrance.fr/{slug}-hifi.aac" + stations.append({ + "slug": graphql_id_to_slug(brand_id), + "name": brand["title"], + "livemeta_id": livemeta_id, + "hifi_url": hifi_url, + "kind": "brand", + }) + + # Webradios + for wr in brand.get("webRadios") or []: + if wr["id"] in EXCLUDED_WEBRADIO_IDS: + continue + live = wr.get("liveStream") + hifi = midfi_to_hifi(live or "") + if not hifi: + continue # pas de stream utilisable + stations.append({ + "slug": graphql_id_to_slug(wr["id"]), + "name": wr["title"], + "livemeta_id": extract_livemeta_id(wr.get("playerUrl")), + "hifi_url": hifi, + "kind": "webradio", + }) + + # Radios locales (ICI / France Bleu) + for lr in brand.get("localRadios") or []: + live = lr.get("liveStream") + hifi = midfi_to_hifi(live or "") + if not hifi: + continue + stations.append({ + "slug": graphql_id_to_slug(lr["id"], is_local_radio=True), + "name": lr["title"], + "livemeta_id": extract_livemeta_id(lr.get("playerUrl")), + "hifi_url": hifi, + "kind": "local", + }) + + return stations + + +def rust_escape(s: str) -> str: + return s.replace('"', '\\"').replace("'", "\\'") + + +def generate_rust(stations: list[dict], token_origin: str = "inconnu") -> str: + brands = [s for s in stations if s["kind"] == "brand"] + webradios = [s for s in stations if s["kind"] == "webradio"] + locals_ = [s for s in stations if s["kind"] == "local"] + + from datetime import date + + lines = [] + lines.append("// ============================================================") + lines.append("// FICHIER GÉNÉRÉ AUTOMATIQUEMENT — NE PAS MODIFIER À LA MAIN") + lines.append("//") + lines.append(f"// Généré par : tools/generate_radiofrance_stations.py") + lines.append(f"// Date : {date.today().isoformat()}") + lines.append(f"// Source : API GraphQL Radio France (openapi.radiofrance.fr)") + lines.append(f"// Token : {token_origin}") + lines.append(f"// Stations : {len(stations)}") + lines.append("//") + lines.append("// Pour mettre à jour :") + lines.append("// python3 tools/generate_radiofrance_stations.py") + lines.append("// ============================================================") + lines.append("") + + # KNOWN_MAIN_STATIONS + lines.append("pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[") + for s in brands: + lines.append(f' ("{s["slug"]}", "{rust_escape(s["name"])}"),') + lines.append("];") + lines.append("") + + # STATION_IDS + lines.append("/// Mapping slug → ID numérique livemeta/pull API") + lines.append("pub const STATION_IDS: &[(&str, u32)] = &[") + + lines.append(" // Stations principales") + for s in brands: + if s["livemeta_id"] is not None: + lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}),') + + lines.append(" // Webradios") + for s in webradios: + if s["livemeta_id"] is not None: + lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}), // {rust_escape(s["name"])}') + + lines.append(" // Radios ICI (France Bleu locales)") + for s in sorted(locals_, key=lambda x: x["livemeta_id"] or 0): + if s["livemeta_id"] is not None: + lines.append(f' ("{s["slug"]}", {s["livemeta_id"]}), // {rust_escape(s["name"])}') + + lines.append("];") + lines.append("") + + # STATION_STREAMS + lines.append("/// URLs HiFi AAC Icecast (dérivées des URLs midfi de l'API GraphQL)") + lines.append("pub const STATION_STREAMS: &[(&str, &str)] = &[") + + lines.append(" // Stations principales") + for s in brands: + lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"),') + + lines.append(" // Webradios") + for s in webradios: + lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"), // {rust_escape(s["name"])}') + + lines.append(" // Radios ICI (France Bleu locales)") + for s in sorted(locals_, key=lambda x: x["livemeta_id"] or 0): + lines.append(f' ("{s["slug"]}", "{s["hifi_url"]}"), // {rust_escape(s["name"])}') + + lines.append("];") + + return "\n".join(lines) + + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_OUTPUT = os.path.join(SCRIPT_DIR, "..", "pmoradiofrance", "src", "stations_data.rs") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--token", help="Token API Radio France (ou variable RADIOFRANCE_TOKEN)") + parser.add_argument( + "--output", "-o", + default=DEFAULT_OUTPUT, + help=f"Fichier Rust de sortie (défaut : {DEFAULT_OUTPUT})", + ) + args = parser.parse_args() + + token = args.token or os.environ.get("RADIOFRANCE_TOKEN") or FALLBACK_TOKEN + if token == FALLBACK_TOKEN: + token_origin = "token de secours (AlexandrePradeilles/ECI, 2022)" + print("Info : utilisation du token de secours (AlexandrePradeilles/ECI).", file=sys.stderr) + elif os.environ.get("RADIOFRANCE_TOKEN"): + token_origin = "variable d'environnement RADIOFRANCE_TOKEN" + else: + token_origin = "argument --token" + + print("Interrogation de l'API GraphQL Radio France...", file=sys.stderr) + try: + data = query_api(token) + except Exception as e: + print(f"Erreur API : {e}", file=sys.stderr) + sys.exit(1) + + if "errors" in data: + print(f"Erreurs GraphQL : {data['errors']}", file=sys.stderr) + sys.exit(1) + + stations = collect_stations(data) + print(f"{len(stations)} stations collectées.", file=sys.stderr) + + rust_code = generate_rust(stations, token_origin=token_origin) + + output_path = os.path.realpath(args.output) + with open(output_path, "w", encoding="utf-8") as f: + f.write(rust_code + "\n") + print(f"Fichier écrit : {output_path}", file=sys.stderr) + + +if __name__ == "__main__": + main()