Merge pull request 'push-pruvwtuysnnn' (#66) from push-pruvwtuysnnn into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 11m37s

Reviewed-on: #66
This commit was merged in pull request #66.
This commit is contained in:
2026-01-23 23:15:27 +01:00
62 changed files with 13703 additions and 211 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.gitignore vendored
View File

@@ -43,3 +43,5 @@ cache
gupnp-tools
pmo*_[0_9]*.txt
webapp_[0_9]*.txt
RF.json
RF_old.json

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
** Tu dois suivre scrupuleusement les règles définies dans le fichier [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) **
** Cette tâche est une tâche de recherche et développement. Elle doit conduire à un prototype fonctionnel et/ou un rapport technique sur la faisabilité. **
# Support du streaming AAC dans pmoflac
## Contexte
Actuellement, `pmoflac` supporte le décodage streaming pour :
- ✅ MP3 (via `minimp3`)
- ✅ FLAC (via `claxon`)
- ✅ Ogg Vorbis (via `lewton`)
- ✅ Ogg Opus (via `opus`)
- ✅ WAV (parsing manuel)
- ✅ AIFF (parsing manuel)
**Manque critique** : Pas de support AAC, pourtant très utilisé pour :
- Streams radio live (Radio France, etc.)
- Podcasts
- Services de streaming musicaux
- Fichiers M4A/MP4
## Problématique
Le décodage AAC en **streaming infini** (radio live) est actuellement impossible dans `pmoflac`, ce qui force à :
- Soit faire un proxy passthrough (pas de transcodage FLAC)
- Soit utiliser une redirection 302 (pas de tracking)
Cela empêche d'avoir une expérience uniforme où toutes les sources servent du FLAC.
## Objectif
**Investiguer et prototyper** le support du décodage AAC streaming dans `pmoflac`, en s'inspirant de l'architecture existante (MP3, Ogg, etc.).
## Recherches préliminaires
### 1. Symphonia avec ReadOnlySource
[Symphonia](https://github.com/pdeljanov/Symphonia) est la bibliothèque Rust la plus complète pour le décodage audio. Elle fournit :
- **`ReadOnlySource`** : Wrapper pour sources non-seekable (streams infinis)
- **`AdtsReader`** : Format reader spécifique pour ADTS (AAC streaming)
- **`symphonia-codec-aac`** : Décodeur AAC-LC (Low Complexity)
**Points d'attention** :
- [Issue connue](https://github.com/RustAudio/rodio/issues/580) : Certains formats peuvent quand même réclamer le seek
- Nécessite de tester avec un vrai stream ADTS
### 2. Format ADTS
[ADTS](https://wiki.multimedia.cx/index.php/ADTS) (Audio Data Transport Stream) est le format AAC conçu pour le streaming :
- Auto-synchronisant : chaque frame a un header (12 bits `0xFFF`)
- Pas de container nécessaire (MP4, M4A)
- Utilisé par les radios en streaming
- Chaque frame contient ses métadonnées (sample rate, channels, etc.)
**Structure** :
```
Frame 1: [ADTS Header 7-9 bytes][AAC Data]
Frame 2: [ADTS Header 7-9 bytes][AAC Data]
...
```
### 3. Alternative : fdk-aac
[Bindings Rust pour fdk-aac](https://github.com/haileys/fdk-aac-rs) (bibliothèque Fraunhofer) :
**Avantages** :
- ✅ Décodeur de référence (qualité maximale)
- ✅ Support explicite du streaming chunk-by-chunk
- ✅ Buffer interne géré automatiquement
- ✅ Pas besoin de seek
**Inconvénients** :
- ❌ Dépendance C (libfdk-aac)
- ❌ Licence restrictive (non-commerciale pour certaines versions)
- ❌ Compilation plus complexe
## Plan d'investigation
### Round 1 : Prototype Symphonia ADTS
**Objectif** : Tester si Symphonia peut décoder un stream AAC infini avec `ReadOnlySource` + `AdtsReader`.
#### Étapes
1. **Créer un module de test** : `pmoflac/tests/aac_streaming_test.rs`
2. **Implémenter un décodeur basique** :
```rust
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
use symphonia::default::get_probe;
use symphonia_codec_aac::AdtsReader;
async fn decode_aac_stream_test<R: AsyncRead + Unpin>(
reader: R
) -> Result<Vec<u8>> {
// Wrapper AsyncRead → Read synchrone (pattern pmoflac)
let sync_reader = blocking_reader_from_async(reader);
// ReadOnlySource pour stream infini
let source = ReadOnlySource::new(sync_reader);
let mss = MediaSourceStream::new(Box::new(source), Default::default());
// Probe avec hint AAC/ADTS
let mut hint = Hint::new();
hint.with_extension("aac");
let mut format = get_probe()
.format(&hint, mss, &Default::default(), &Default::default())?;
// Récupérer le track audio
let track = format.default_track().unwrap();
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &Default::default())?;
let mut pcm_output = Vec::new();
// Décoder frame par frame (boucle infinie jusqu'à disconnect)
loop {
match format.next_packet() {
Ok(packet) => {
let decoded = decoder.decode(&packet)?;
// Convertir en PCM et accumuler
let samples = convert_to_pcm_bytes(decoded);
pcm_output.extend_from_slice(&samples);
}
Err(symphonia::core::errors::Error::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break; // Stream fermé
}
Err(e) => return Err(e.into()),
}
}
Ok(pcm_output)
}
```
3. **Tester avec un fichier AAC ADTS statique** :
- Télécharger un échantillon AAC ADTS
- Vérifier que le décodage fonctionne
- Comparer PCM output avec ffmpeg
4. **Tester avec un stream Radio France live** :
```rust
#[tokio::test]
#[ignore = "Requires network"]
async fn test_decode_radiofrance_stream() {
let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac";
let response = reqwest::get(stream_url).await.unwrap();
let reader = response.bytes_stream();
// Lire 10 secondes de stream
let pcm = decode_aac_stream_test(reader).await.unwrap();
assert!(!pcm.is_empty());
// Vérifier format PCM (44.1kHz ou 48kHz, stéréo, 16-bit)
}
```
#### Critères de succès Round 1
- ✅ Le décodeur accepte un `ReadOnlySource` sans erreur de seek
- ✅ Les frames ADTS sont correctement parsées
- ✅ Le décodage AAC → PCM fonctionne
- ✅ Un stream live (infini) peut être décodé sans plantage
- ✅ Le PCM output est valide (vérifiable avec `ffplay`)
#### Livrables Round 1
1. **Module de test** : `pmoflac/tests/aac_streaming_test.rs`
2. **Rapport technique** : `Blackboard/Report/Support_AAC_streaming_pmoflac.md`
- Résultats des tests
- Problèmes rencontrés (seek, parsing, etc.)
- Métriques de performance (CPU, latence)
- Comparaison qualité avec ffmpeg
---
### Round 2 : Intégration dans pmoflac (si Round 1 réussit)
**Objectif** : Intégrer le décodeur AAC dans l'architecture streaming de `pmoflac`.
#### Fichiers à créer/modifier
**1. `pmoflac/src/aac.rs`** (nouveau)
```rust
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
use tokio::sync::mpsc;
use crate::{
common::ChannelReader,
decoder_common::{spawn_ingest_task, spawn_writer_task, DecodedStream},
pcm::StreamInfo,
};
pub type AacDecodedStream = DecodedStream<AacError>;
#[derive(thiserror::Error, Debug)]
pub enum AacError {
#[error("AAC decode error: {0}")]
Decode(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Channel closed")]
ChannelClosed,
}
/// Décoder un stream AAC/ADTS en PCM
pub async fn decode_aac_stream<R>(reader: R) -> Result<AacDecodedStream, AacError>
where
R: AsyncRead + Unpin + Send + 'static,
{
// Suivre le pattern existant (MP3, FLAC, etc.)
let (ingest_tx, ingest_rx) = mpsc::channel(CHANNEL_CAPACITY);
spawn_ingest_task(reader, ingest_tx);
let (pcm_tx, pcm_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (pcm_reader, pcm_writer) = tokio::io::duplex(DUPLEX_BUFFER_SIZE);
let (info_tx, info_rx) = oneshot::channel::<Result<StreamInfo, AacError>>();
let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), AacError> {
let mut channel_reader = ChannelReader::<AacError>::new(ingest_rx);
// ReadOnlySource pour stream infini
let source = ReadOnlySource::new(&mut channel_reader);
let mss = MediaSourceStream::new(Box::new(source), Default::default());
// Probe AAC/ADTS
let mut hint = Hint::new();
hint.with_extension("aac");
let mut format = get_probe()
.format(&hint, mss, &Default::default(), &Default::default())
.map_err(|e| AacError::Decode(e.to_string()))?;
let track = format.default_track()
.ok_or_else(|| AacError::Decode("No audio track found".into()))?;
let mut decoder = get_codecs()
.make(&track.codec_params, &Default::default())
.map_err(|e| AacError::Decode(e.to_string()))?;
// Extraire StreamInfo
let codec_params = &track.codec_params;
let info = StreamInfo {
sample_rate: codec_params.sample_rate.unwrap_or(48000),
channels: codec_params.channels.unwrap().count() as u8,
bits_per_sample: 16, // AAC decode to 16-bit PCM
total_samples: None, // Stream infini
max_block_size: 0,
min_block_size: 0,
};
if info_tx.send(Ok(info.clone())).is_err() {
return Ok(());
}
// Boucle de décodage
loop {
match format.next_packet() {
Ok(packet) => {
let decoded = decoder.decode(&packet)
.map_err(|e| AacError::Decode(e.to_string()))?;
// Convertir AudioBufferRef → bytes PCM
let pcm_bytes = convert_audio_buffer_to_bytes(decoded, &info);
if pcm_tx.blocking_send(Ok(pcm_bytes)).is_err() {
break; // Reader fermé
}
}
Err(symphonia::core::errors::Error::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break; // Stream terminé normalement
}
Err(e) => {
let msg = e.to_string();
let _ = pcm_tx.blocking_send(Err(AacError::Decode(msg.clone())));
return Err(AacError::Decode(msg));
}
}
}
Ok(())
});
let writer_handle = spawn_writer_task(pcm_rx, pcm_writer, blocking_handle, "aac-decode");
let info = info_rx.await.map_err(|_| AacError::ChannelClosed)??;
let reader = ManagedAsyncReader::new("aac-decode-writer", pcm_reader, writer_handle);
Ok(DecodedStream::new(info, reader))
}
/// Convertir AudioBufferRef Symphonia → bytes PCM little-endian
fn convert_audio_buffer_to_bytes(
audio_buffer: AudioBufferRef,
info: &StreamInfo,
) -> Vec<u8> {
// Implémenter conversion selon le type de buffer
// (S16, S24, S32, F32, etc.) → i16 little-endian interleaved
// ...
}
```
**2. `pmoflac/src/lib.rs`** (modifier)
```rust
pub mod aac;
pub use aac::{decode_aac_stream, AacDecodedStream, AacError};
```
**3. `pmoflac/src/autodetect.rs`** (modifier)
Ajouter la détection AAC/ADTS :
```rust
fn detect_format(bytes: &[u8]) -> Option<DetectedFormat> {
// ... détections existantes ...
// Détecter ADTS AAC (syncword 0xFFF)
if is_adts(bytes) {
return Some(DetectedFormat::Aac);
}
None
}
fn is_adts(bytes: &[u8]) -> bool {
if bytes.len() < 2 {
return false;
}
// ADTS syncword: 12 bits à 1 (0xFFF)
bytes[0] == 0xFF && (bytes[1] & 0xF0) == 0xF0
}
pub enum DecodedAudioStream {
// ... variants existants ...
Aac(AacDecodedStream),
}
```
**4. `pmoflac/src/transcode.rs`** (modifier)
Ajouter AAC au transcodeur :
```rust
pub enum AudioCodec {
// ... codecs existants ...
Aac,
}
pub async fn transcode_to_flac_stream<R>(
reader: R,
options: TranscodeOptions,
) -> Result<TranscodeToFlac, TranscodeError>
where
R: AsyncRead + Unpin + Send + 'static,
{
// ... détection auto ...
match decoded {
// ... cas existants ...
DecodedAudioStream::Aac(stream) => {
transcode_from_decoded(AudioCodec::Aac, stream, options.encoder_options).await
}
}
}
```
**5. `pmoflac/Cargo.toml`** (modifier)
```toml
[dependencies]
# ... dépendances existantes ...
# AAC support
symphonia = { version = "0.5", features = ["aac", "isomp4"], optional = true }
symphonia-core = { version = "0.5", optional = true }
symphonia-codec-aac = { version = "0.5", optional = true }
[features]
default = ["mp3", "ogg", "opus", "wav", "aiff"]
aac = ["dep:symphonia", "dep:symphonia-core", "dep:symphonia-codec-aac"]
all = ["mp3", "ogg", "opus", "wav", "aiff", "aac"]
```
#### Tests Round 2
**Tests unitaires** :
```rust
#[tokio::test]
async fn test_decode_aac_to_pcm() {
let aac_data = include_bytes!("../test-data/sample.aac");
let stream = decode_aac_stream(&aac_data[..]).await.unwrap();
let info = stream.info();
assert_eq!(info.sample_rate, 48000);
assert_eq!(info.channels, 2);
// Lire quelques samples
let mut buffer = vec![0u8; 4096];
let mut reader = stream;
let n = reader.read(&mut buffer).await.unwrap();
assert!(n > 0);
}
```
**Tests intégration** :
```rust
#[tokio::test]
#[ignore = "Integration test - network required"]
async fn test_transcode_radiofrance_to_flac() {
let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac";
let response = reqwest::get(stream_url).await.unwrap();
let reader = response.bytes_stream();
let transcoded = transcode_to_flac_stream(
reader,
TranscodeOptions::default()
).await.unwrap();
assert_eq!(transcoded.input_codec(), AudioCodec::Aac);
assert_eq!(transcoded.input_stream_info().sample_rate, 48000);
// Lire 5 secondes de FLAC
let mut output = Vec::new();
let mut stream = transcoded.into_stream();
for _ in 0..50 {
let mut chunk = vec![0u8; 8192];
stream.read(&mut chunk).await.unwrap();
output.extend_from_slice(&chunk);
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(output.len() > 100_000); // Au moins 100 KB de FLAC
}
```
#### Critères de succès Round 2
- ✅ `decode_aac_stream()` suit le pattern existant (MP3, Ogg, etc.)
- ✅ Auto-détection AAC/ADTS fonctionne
- ✅ Transcodage AAC → FLAC streaming opérationnel
- ✅ Tests unitaires et intégration passent
- ✅ Documentation complète (doctests, exemples)
- ✅ Feature flag `aac` pour compilation optionnelle
---
### Round 3 : Intégration dans pmoradiofrance (si Round 2 réussit)
**Objectif** : Remplacer le proxy AAC passthrough par un transcodage FLAC.
#### Modifications
**1. `pmoradiofrance/src/server_ext.rs`**
Remplacer le proxy passthrough par un transcodage :
```rust
async fn proxy_stream(
Path(slug): Path<String>,
State(state): State<Arc<RadioFranceServerState>>
) -> Result<Response, (StatusCode, String)> {
let stream_url = state.client.get_stream_url(&slug).await
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
let response = reqwest::get(&stream_url).await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
// Transcoder AAC → FLAC avec pmoflac
let transcoded = pmoflac::transcode_to_flac_stream(
response.bytes_stream(),
pmoflac::TranscodeOptions::default()
).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Enregistrer connexion active et démarrer metadata refresh
// ...
// Stream FLAC au lieu d'AAC
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "audio/flac".parse().unwrap());
headers.insert("Cache-Control", "no-cache".parse().unwrap());
Ok((headers, Body::from_stream(transcoded.into_stream())).into_response())
}
```
**2. `pmoradiofrance/src/playlist.rs`**
Changer le protocol_info pour FLAC :
```rust
// Avant (AAC)
protocol_info: "http-get:*:audio/aac:*"
// Après (FLAC)
protocol_info: "http-get:*:audio/flac:*"
sample_frequency: Some(info.sample_rate.to_string())
bits_per_sample: Some("16".to_string())
```
#### Critères de succès Round 3
- ✅ Radio France sert du FLAC au lieu d'AAC
- ✅ Uniformité : toutes les sources PMOMusic servent du FLAC
- ✅ Latence acceptable (<2s) pour le streaming live
- ✅ CPU raisonnable pour 2-3 streams simultanés sur LAN
- ✅ Métadonnées volatiles toujours mises à jour
---
## Alternative : fdk-aac (si Symphonia échoue)
Si Symphonia ne fonctionne pas en streaming infini, explorer `fdk-aac` :
### Avantages
- ✅ Décodeur de référence (meilleure qualité)
- ✅ Conçu pour le streaming
- ✅ Utilisé en production (Android, etc.)
### Inconvénients
- ❌ Dépendance C (compilation complexe)
- ❌ Licence restrictive (vérifier compatibilité projet)
### Prototype minimal
```rust
use fdk_aac::dec::{Decoder, DecoderParams};
pub async fn decode_aac_with_fdk<R>(reader: R) -> Result<AacDecodedStream>
where
R: AsyncRead + Unpin + Send + 'static,
{
// Similar pattern to pmoflac MP3 decoder
// spawn_blocking pour le décodeur C
// ...
}
```
---
## Résultats attendus
### Minimum viable (Round 1)
- ✅ Rapport technique sur la faisabilité du streaming AAC avec Symphonia
- ✅ Prototype fonctionnel (même basique)
- ✅ Identification des limitations et solutions de contournement
### Objectif complet (Round 1-3)
- ✅ Support AAC/ADTS dans `pmoflac` (feature flag optionnelle)
- ✅ Transcodage AAC → FLAC streaming opérationnel
- ✅ Radio France servant du FLAC uniforme
- ✅ Documentation et tests complets
### En cas d'échec
- ✅ Rapport détaillé des blocages techniques
- ✅ Recommandations alternatives (fdk-aac, attendre évolution Symphonia, etc.)
- ✅ Garder le proxy AAC passthrough actuel
---
## Références
### Documentation
- [Symphonia Getting Started](https://github.com/pdeljanov/Symphonia/blob/master/GETTING_STARTED.md)
- [AdtsReader API](https://docs.rs/symphonia-codec-aac/latest/symphonia_codec_aac/struct.AdtsReader.html)
- [ADTS Format Specification](https://wiki.multimedia.cx/index.php/ADTS)
- [fdk-aac Rust Bindings](https://github.com/haileys/fdk-aac-rs)
### Issues et discussions
- [Symphonia ReadOnlySource Issue #580](https://github.com/RustAudio/rodio/issues/580)
- [Symphonia MediaSource Trait](https://docs.rs/symphonia-core/latest/symphonia_core/io/index.html)
### Contexte PMOMusic
- [Task Radio France](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToDiscuss/Construire_pmoradiofrance.md)
- [Architecture pmoflac](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoflac/src/lib.rs)

View File

@@ -0,0 +1,474 @@
# Analyse : Récupération des métadonnées France Culture
## Objectif
Comprendre comment le site web de France Culture (https://www.radiofrance.fr/franceculture) obtient et affiche les informations sur l'émission en cours.
## Architecture du site
### Framework utilisé
**SvelteKit** avec Server-Side Rendering (SSR)
Le site utilise SvelteKit, comme en témoignent :
- L'attribut `data-sveltekit-preload-data="hover"` sur le `<body>`
- Les classes CSS préfixées par `svelte-` (ex: `svelte-1thibul`, `svelte-qz676b`)
- Les chemins vers les assets : `/client/immutable/assets/`
### Rendu des données
**SSR (Server-Side Rendering)** - Les données sont déjà présentes dans le HTML initial
## Méthode de récupération des informations
### ✅ API publique JSON découverte !
**Après analyse du trafic réseau (fichier HAR), l'API officielle existe et est OUVERTE :**
#### API LiveMeta (métadonnées en temps réel)
```
https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player
```
**Caractéristiques :**
-**Aucune authentification requise** (pas de token)
-**Endpoint officiel** utilisé par le site web
-**JSON structuré** avec émission en cours, précédente et suivante
-**Timestamps précis** de début et fin d'émission
-**UUIDs des émissions** pour récupérer plus de détails
-**Indicateur de rafraîchissement** (`delayToRefresh` en millisecondes)
**Exemple de réponse :**
```json
{
"prev": [{
"firstLine": "Le direct",
"firstLineUuid": null,
"firstLinePath": null,
"secondLine": "France Culture, l'esprit d'ouverture",
"cover": "4e9fba8d-7675-409d-86a0-fce40f0cd4a6",
"startTime": null,
"endTime": null
}],
"now": {
"firstLine": "La Série fiction",
"firstLineUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
"firstLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
"firstLinePath": "franceculture/podcasts/fictions-le-feuilleton",
"firstLinePathUuid": "3c1c2e55-41a0-11e5-9fe0-005056a87c89",
"secondLine": "\"Ségou\" de Maryse Condé 9/10 : Deuil et pénitence",
"secondLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
"cover": "436430f7-5b2b-43f2-9f3c-28f2ad6cae39",
"startTime": 1769108400,
"endTime": 1769110122
},
"next": [{
"firstLine": "L'Instant poésie",
"firstLinePath": "franceculture/podcasts/l-instant-poesie",
"firstLineUuid": "06fe22c7-144c-41b8-983d-ec956595b694",
"secondLine": "L'Instant poésie d'Abd al Malik 14/20 : \"Roman inachevé\" de Louis Aragon, une main tendue",
"cover": "a18a392b-f7d5-41bd-972a-e64451f35213",
"startTime": 1769110200,
"endTime": 1769110555
}],
"delayToRefresh": 742000
}
```
**Paramètres optionnels :**
- `?date=<timestamp>` : Récupérer les métadonnées à un moment donné (historique)
#### API Pikapi (images de couverture)
```
https://www.radiofrance.fr/pikapi/images/{uuid}/{taille}
```
**Exemples :**
- `https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200`
- Autres tailles disponibles (à tester)
### Anciennes tentatives (pour référence historique)
Les tentatives d'accès aux endpoints suivants ont échoué :
- `https://www.radiofrance.fr/api/v2.1/stations/franceculture` → retourne du HTML
- `https://www.radiofrance.fr/api/v2.1/stations/franceculture/live` → retourne du HTML
- `https://openapi.radiofrance.fr/v1/graphql` → nécessite un header `x-token`
### Données embarquées dans le HTML (SSR)
Les informations sont également directement rendues dans le HTML par le serveur SvelteKit (méthode de fallback).
## Structure HTML des métadonnées
### Zone principale : CoverRadio
Les informations de l'émission en cours se trouvent dans la section `class="CoverRadio"` :
```html
<div class="CoverRadio-infoContainer">
<!-- Titre de l'émission/segment -->
<div class="CoverRadio-title qg-tt3 svelte-1thibul" role="heading" aria-level="1">
<span class="truncate qg-focus-container svelte-1t7i9vq">
<a href="/franceculture/podcasts/le-journal-de-l-eco/le-jouet-profite-de-la-morosite-ambiante-4949584"
aria-label="Le Journal de l'éco • Le jouet profite de la morosité ambiante">
Le Journal de l'éco • Le jouet profite de la morosité ambiante
</a>
</span>
</div>
<!-- Nom de l'émission parente + producteur -->
<p class="CoverRadio-subtitle qg-tt5 qg-focus-container svelte-1thibul">
<a href="/franceculture/podcasts/les-matins">Les Matins</a>
<span class="CoverRadio-producer qg-tx1 svelte-qz676b">par Guillaume Erner</span>
</p>
<!-- Indicateur de direct -->
<div class="CoverRadio-ctaTop">
<p class="direct qg-st6 CoverRadio-labelDirect dark default svelte-12tsplm">
En direct
</p>
</div>
</div>
```
### Classes CSS identifiées
| Classe CSS | Contenu | Utilité |
|------------|---------|---------|
| `CoverRadio-title` | Titre du segment/chronique en cours | Titre principal |
| `CoverRadio-subtitle` | Nom de l'émission parente | Contexte de diffusion |
| `CoverRadio-producer` | Nom du producteur/animateur | Crédit |
| `CoverRadio-labelDirect` | Badge "En direct" | Statut de diffusion |
## Stratégies d'extraction
### Option 1 : Scraping HTML simple
Récupérer la page HTML et extraire les données via :
- Parsing HTML (BeautifulSoup en Python, scraper en Rust)
- Regex ciblées sur les classes CSS
**Avantages :**
- Pas de token nécessaire
- Données toujours présentes dans le HTML
- Méthode robuste
**Inconvénients :**
- Dépendant de la structure HTML
- Risque de cassure si le site change
- Parsing HTML plus lourd
### Option 2 : API GraphQL avec token
L'API GraphQL existe (`https://openapi.radiofrance.fr/v1/graphql`) mais nécessite un `x-token`.
**Étapes :**
1. Analyser le code JavaScript du site pour trouver comment le token est généré
2. Extraire ou reproduire la logique de génération de token
3. Utiliser l'API GraphQL
**Avantages :**
- API structurée et officielle
- Données JSON propres
- Moins de risque de changement
**Inconvénients :**
- Nécessite un token (non documenté publiquement)
- Potentiellement bloqué/limité en débit
- Reverse engineering requis
### Option 3 : API interne SvelteKit
SvelteKit utilise des endpoints `/__data.json` pour l'hydratation client.
**À explorer :**
- `https://www.radiofrance.fr/franceculture/__data.json`
- Endpoints de données internes
## Recommandation
### Pour un projet comme PMOMusic (pmoradiofrance)
**Approche hybride recommandée :**
1. **Court terme : Scraping HTML**
- Implémenter un parser HTML en Rust
- Cibler les classes CSS `CoverRadio-*`
- Parser avec `scraper` ou `select` en Rust
2. **Moyen terme : Investigation API**
- Analyser le code JavaScript pour trouver le token
- Tenter d'utiliser l'API GraphQL si possible
3. **Mise en cache et rafraîchissement**
- Rafraîchir les métadonnées toutes les 1-5 minutes
- Mettre en cache pour éviter les requêtes excessives
## Exemple de code conceptuel (Rust)
```rust
use scraper::{Html, Selector};
async fn fetch_current_show() -> Result<ShowInfo, Error> {
let html = reqwest::get("https://www.radiofrance.fr/franceculture")
.await?
.text()
.await?;
let document = Html::parse_document(&html);
// Sélecteurs CSS
let title_selector = Selector::parse(".CoverRadio-title a").unwrap();
let subtitle_selector = Selector::parse(".CoverRadio-subtitle a").unwrap();
let producer_selector = Selector::parse(".CoverRadio-producer").unwrap();
let title = document
.select(&title_selector)
.next()
.map(|e| e.inner_html())
.unwrap_or_default();
let show_name = document
.select(&subtitle_selector)
.next()
.map(|e| e.inner_html())
.unwrap_or_default();
let producer = document
.select(&producer_selector)
.next()
.map(|e| e.inner_html().replace("par ", ""))
.unwrap_or_default();
Ok(ShowInfo {
title,
show_name,
producer,
})
}
```
## Points d'attention
1. **Rate limiting** : Ne pas surcharger le site avec des requêtes trop fréquentes
2. **User-Agent** : Utiliser un User-Agent identifiable pour un projet open-source
3. **Gestion d'erreurs** : Le site peut être temporairement indisponible
4. **Structure HTML** : Peut changer sans préavis
5. **Respect des CGU** : Vérifier les conditions d'utilisation de Radio France
## Mise à jour de la page côté client
### Comment la page se rafraîchit-elle ?
**Réponse : La page ne se met PAS à jour automatiquement côté client.**
Après analyse :
1. **Pas de polling/WebSocket** : Aucun mécanisme de `setInterval`, `setTimeout`, WebSocket ou Server-Sent Events (SSE) détecté dans le HTML
2. **Pas de JavaScript de mise à jour** : Le DOM n'est pas modifié dynamiquement pour les métadonnées `CoverRadio-*`
3. **Navigation SvelteKit** : Les mises à jour se font via la navigation SPA de SvelteKit
### Mécanisme de navigation SvelteKit
SvelteKit utilise le **preloading** et les **endpoints `__data.json`** :
```
https://www.radiofrance.fr/franceculture/__data.json
```
Cet endpoint retourne un **JSON structuré** contenant toutes les données de la page, incluant :
- Métadonnées de l'émission en cours
- Configuration du site
- Contenu de la page
**Format de données** :
```json
{
"type": "data",
"nodes": [
{
"metadata": { ... },
"context": { ... },
"mainStationLive": { ... }
}
]
}
```
### Stratégie de rafraîchissement
Pour un utilisateur sur le site :
1. **Chargement initial** : SSR complet avec HTML
2. **Navigation ultérieure** : SvelteKit charge `__data.json` en AJAX
3. **Rechargement manuel** : L'utilisateur doit recharger la page (F5) pour voir les nouvelles métadonnées
**Il n'y a pas de mise à jour automatique en temps réel.**
## Recommandation mise à jour
### 🏆 Option privilégiée : API LiveMeta officielle (DÉCOUVERTE !)
**URL :** `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
**Avantages :**
-**API officielle Radio France** : Endpoint public et documenté
-**Aucune authentification** : Pas de token, pas de restriction
-**JSON léger et structuré** : Format simple et prévisible
-**Données optimales** : Juste ce qu'il faut (prev/now/next)
-**Polling intelligent** : `delayToRefresh` indique quand rafraîchir
-**Stable** : API de production utilisée par le site officiel
-**Support historique** : Paramètre `?date=` pour l'historique
-**UUIDs** : Références pour récupérer plus de détails si besoin
**Inconvénients :**
- Aucun majeur identifié
**Code Rust recommandé :**
```rust
use serde::{Deserialize, Serialize};
use reqwest;
#[derive(Debug, Deserialize, Serialize)]
struct LiveMetadata {
prev: Vec<ShowInfo>,
now: ShowInfo,
next: Vec<ShowInfo>,
#[serde(rename = "delayToRefresh")]
delay_to_refresh: u64,
}
#[derive(Debug, Deserialize, Serialize)]
struct ShowInfo {
#[serde(rename = "firstLine")]
first_line: String,
#[serde(rename = "firstLineUuid")]
first_line_uuid: Option<String>,
#[serde(rename = "firstLinePath")]
first_line_path: Option<String>,
#[serde(rename = "secondLine")]
second_line: String,
cover: String,
#[serde(rename = "startTime")]
start_time: Option<u64>,
#[serde(rename = "endTime")]
end_time: Option<u64>,
}
async fn fetch_franceculture_live() -> Result<LiveMetadata, reqwest::Error> {
let url = "https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player";
reqwest::get(url)
.await?
.json::<LiveMetadata>()
.await
}
// Utilisation avec polling intelligent
async fn monitor_live() {
loop {
match fetch_franceculture_live().await {
Ok(metadata) => {
println!("En cours : {} - {}",
metadata.now.first_line,
metadata.now.second_line
);
// Attendre le temps recommandé avant de rafraîchir
tokio::time::sleep(
tokio::time::Duration::from_millis(metadata.delay_to_refresh)
).await;
}
Err(e) => {
eprintln!("Erreur : {}", e);
// Fallback : attendre 60 secondes
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
}
}
}
}
```
### Hiérarchie des options (mise à jour)
1. **🥇 Premier choix : API LiveMeta** - API officielle Radio France
2. **🥈 Fallback niveau 1 : `__data.json`** - Endpoint SvelteKit si LiveMeta indisponible
3. **🥉 Fallback niveau 2 : Scraping HTML** - Si les API JSON sont toutes indisponibles
4. **💭 Exploration future : API GraphQL** - Si un token public devient disponible
## Conclusion
**Pour la mise à jour côté serveur (PMOMusic) :**
-**Utiliser l'API LiveMeta officielle** : `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
-**Polling intelligent** : Utiliser `delayToRefresh` pour optimiser les appels
-**Récupération des images** : Via Pikapi avec l'UUID de `cover`
-**Gestion d'erreur** : Fallback sur `__data.json` puis HTML si nécessaire
**Pour la page web elle-même :**
- **Aucune mise à jour automatique** : L'utilisateur doit recharger la page manuellement
- Navigation SPA via SvelteKit charge `__data.json` en AJAX
- Le SSR initial contient déjà toutes les données dans le HTML
## URLs de flux audio découvertes
### Flux HLS (recommandé)
**Master playlist :**
```
https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance
```
**Qualités disponibles :**
- **lofi** : 105 kbps (BANDWIDTH=107000) - `franceculture_lofi.m3u8?id=radiofrance`
- **midfi** : 178 kbps (BANDWIDTH=185000) - `franceculture_midfi.m3u8?id=radiofrance`
- **hifi** : 268 kbps (BANDWIDTH=280000) - `franceculture_hifi.m3u8?id=radiofrance`
Codec : `mp4a.40.2` (AAC-LC)
### Flux Icecast (à confirmer)
D'après RF_old.json, ces URLs devraient exister (non observées dans le HAR car le player web utilise HLS) :
**MP3 :**
```
https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance
https://icecast.radiofrance.fr/franceculture-midfi.mp3?id=radiofrance
https://icecast.radiofrance.fr/franceculture-hifi.mp3?id=radiofrance
```
**AAC :**
```
https://icecast.radiofrance.fr/franceculture-lofi.aac?id=radiofrance
https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance
https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
```
## Mapping des stations Radio France
D'après l'analyse du fichier HAR et RF_old.json, voici le mapping des IDs de stations :
| Station | ID Station | Endpoint LiveMeta |
|---------|-----------|-------------------|
| France Culture | 5 | `/livemeta/live/5/transistor_culture_player` |
| France Inter | ? | À découvrir |
| France Musique | ? | À découvrir |
| FIP | ? | À découvrir |
| Mouv' | ? | À découvrir |
| France Bleu (national) | ? | À découvrir |
**Note :** Les IDs des autres stations peuvent être découverts en analysant le HAR de leurs pages respectives ou en testant des valeurs séquentielles (1, 2, 3, 4, 6, 7...).
## Prochaines étapes recommandées
1.**Implémenter le client LiveMeta** en Rust avec les structures proposées
2. 🔍 **Découvrir les IDs des autres stations** Radio France
3. 🔍 **Tester les URLs Icecast** pour confirmer leur disponibilité
4. 📋 **Documenter l'API complète** dans le code PMOMusic
5. 🧪 **Tester le paramètre `?date=`** pour l'accès historique
6. 🎨 **Tester les tailles d'images Pikapi** disponibles (200x200, 400x400, etc.)
## Annexe : Analyse du fichier HAR
**Source :** `www.radiofrance.fr.har`
**Date de capture :** 2026-01-22
**Page analysée :** https://www.radiofrance.fr/franceculture
**Découvertes principales :**
- API LiveMeta accessible et ouverte
- Aucune authentification requise
- Polling intelligent via `delayToRefresh`
- Support HLS multi-bitrate
- API Pikapi pour les images
Cette analyse confirme que Radio France expose des APIs publiques utilisables pour des projets comme PMOMusic.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,831 @@
# Architecture du client Radio France (client.rs)
**Date** : 2026-01-22
**Objectif** : Conception d'une API Rust pour interroger les métadonnées live et flux audio de Radio France
**Référence** : Architecture inspirée de `pmoparadise/src/client.rs`
---
## Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Découverte dynamique des stations](#découverte-dynamique-des-stations)
3. [Architecture du client](#architecture-du-client)
4. [Structures de données](#structures-de-données)
5. [Méthodes principales](#méthodes-principales)
6. [Exemple d'utilisation](#exemple-dutilisation)
7. [Points d'attention](#points-dattention)
---
## Vue d'ensemble
Le client Radio France doit permettre :
- **Découverte dynamique** de ~73 stations/webradios (scraping HTML)
- **Métadonnées live** via `/api/live?` avec polling intelligent
- **Flux audio** en qualité maximale uniquement (AAC 192 kbps + HLS)
- **Un seul client** pour toutes les stations (pas un client par station)
### Philosophie
- **Pas de hardcoding** : Toutes les stations sont découvertes dynamiquement
- **Qualité maximale uniquement** : AAC 192 kbps (hifi) + HLS, pas de choix lofi/midfi
- **Architecture simple** : Un client unique, les stations sont des paramètres
---
## Découverte dynamique des stations
### Stratégie complète
Radio France n'expose **pas d'API centralisée** listant toutes les stations. La découverte se fait par **scraping HTML** des pages principales :
#### 1. Stations principales (8)
**Source** : `https://www.radiofrance.fr/`
**Méthode** : Scraper le HTML et extraire tous les slugs via regex `(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)`
**Résultat attendu** :
```
franceinter
franceinfo
franceculture
francemusique
fip
mouv
francebleu
monpetitfranceinter
```
#### 2. Webradios de chaque station (nombre variable)
**Principe** : **TOUTES les stations** peuvent avoir des webradios, pas seulement FIP et France Musique.
**Méthode** : Pour chaque station principale découverte, scraper sa page `https://www.radiofrance.fr/{station}` et extraire les identifiants via regex `{station}_[a-z_]+`
**Exemples découverts** :
**FIP** (`https://www.radiofrance.fr/fip`) :
```
fip_cultes
fip_electro
fip_groove
fip_hiphop
fip_jazz
fip_metal
fip_nouveautes
fip_pop
fip_reggae
fip_rock
fip_sacre_francais
fip_world
```
**France Musique** (`https://www.radiofrance.fr/francemusique`) :
```
francemusique_baroque
francemusique_classique_easy
francemusique_classique_love
francemusique_classique_plus
francemusique_concert_rf
francemusique_evenementielle
francemusique_la_contemporaine
francemusique_la_jazz
francemusique_ocora_monde
francemusique_opera
francemusique_piano_zen
```
**Autres stations** : À découvrir dynamiquement (France Inter, Mouv, etc. pourraient avoir des webradios futures)
#### 3. Radios locales France Bleu (~40)
**Source** : API `/francebleu/api/live?` → champ `localRadios[]`
**Méthode** : Appel API et extraction du tableau JSON
**Exemple de structure** :
```json
{
"localRadios": [
{"id": 12, "title": "ICI Alsace", "name": "francebleu_alsace", "isOnAir": true},
{"id": 13, "title": "ICI Armorique", "name": "francebleu_armorique", "isOnAir": true},
...
]
}
```
### Total découvert
- **8** stations principales
- **~23** webradios (12 FIP + 11 France Musique + possibles autres)
- **~40** radios locales France Bleu
- **= ~71+ stations au total** (extensible automatiquement si nouvelles webradios)
---
## Architecture du client
### Client unique
Contrairement à une approche "un client par station", nous utilisons **un seul client** avec les stations comme **paramètres de méthode**.
```rust
pub struct RadioFranceClient {
client: reqwest::Client,
timeout: Duration,
}
```
### Pas de cache interne
Le client est **stateless** et ne cache rien. La gestion du cache (métadonnées, images) sera faite par les couches supérieures (`SourceCacheManager`).
### Builder pattern
Pour permettre la configuration :
```rust
pub struct ClientBuilder {
client: Option<reqwest::Client>,
timeout: Duration,
user_agent: String,
}
```
---
## Structures de données
### 1. Station découverte
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Station {
pub slug: String, // "fip_rock", "franceinter"
pub name: String, // "FIP Rock", "France Inter"
pub station_type: StationType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StationType {
Main, // Station principale
Webradio { // Webradio de n'importe quelle station
parent_station: String, // "fip", "francemusique", "mouv", etc.
},
LocalRadio { region: String }, // Radio locale France Bleu
}
```
### 2. Réponse API Live
```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveResponse {
pub station_name: String,
pub delay_to_refresh: u64, // millisecondes
pub migrated: bool,
pub now: ShowMetadata,
pub next: Option<ShowMetadata>,
pub local_radios: Option<Vec<LocalRadio>>, // France Bleu uniquement
}
```
### 3. Métadonnées d'émission
```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowMetadata {
pub start_time: Option<u64>,
pub end_time: Option<u64>,
pub producer: Option<String>,
pub first_line: Line, // Titre émission
pub second_line: Line, // Titre épisode/chronique
pub third_line: Option<Line>, // Sous-titre
pub intro: Option<String>, // Description
pub song: Option<Song>, // Pour radios musicales (FIP, France Musique)
pub media: Media, // Flux audio disponibles
pub visual_background: Option<EmbedImage>,
pub visuals: Option<Visuals>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Line {
pub title: Option<String>,
pub id: Option<String>,
pub path: Option<String>,
}
```
### 4. Morceau musical (FIP, France Musique)
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct Song {
pub id: String,
pub year: Option<u32>,
pub interpreters: Vec<String>,
pub release: Release,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Release {
pub label: Option<String>,
pub title: Option<String>,
pub reference: Option<String>,
}
```
### 5. Flux audio
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct Media {
pub sources: Vec<StreamSource>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamSource {
pub url: String,
pub broadcast_type: BroadcastType,
pub format: StreamFormat,
pub bitrate: u32,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BroadcastType {
Live,
Timeshift,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum StreamFormat {
Mp3,
Aac,
Hls,
}
```
### 6. Images
```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedImage {
pub model: String,
pub src: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub dominant: Option<String>,
pub copyright: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Visuals {
pub card: Option<EmbedImage>,
pub player: Option<EmbedImage>,
}
pub enum ImageSize {
Tiny, // 88x88
Small, // 200x200
Medium, // 420x720
Large, // 560x960
XLarge, // 1200x680
Raw, // Taille originale
}
```
### 7. Radios locales
```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalRadio {
pub id: u32,
pub title: String,
pub name: String,
pub is_on_air: bool,
}
```
---
## Méthodes principales
### 1. Création du client
```rust
impl RadioFranceClient {
/// Créer un nouveau client avec settings par défaut
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Créer un builder pour configuration avancée
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
/// Créer avec un reqwest::Client existant
pub fn with_client(client: reqwest::Client) -> Self {
Self {
client,
timeout: Duration::from_secs(30),
}
}
}
```
### 2. Découverte des stations
```rust
impl RadioFranceClient {
/// Découvrir toutes les stations disponibles (scraping + API)
pub async fn discover_all_stations(&self) -> Result<Vec<Station>> {
let mut stations = Vec::new();
// 1. Découvrir les stations principales
let main_stations = self.scrape_main_stations().await?;
// 2. Pour CHAQUE station principale, découvrir ses webradios éventuelles
for main_station in main_stations {
// Ajouter la station principale
stations.push(main_station.clone());
// Découvrir ses webradios (peut retourner 0 si aucune)
if let Ok(webradios) = self.scrape_station_webradios(&main_station.slug).await {
stations.extend(webradios);
}
}
// 3. Cas spécial : radios locales France Bleu (via API)
if let Ok(locals) = self.discover_local_radios().await {
stations.extend(locals);
}
Ok(stations)
}
/// Scraper les stations principales depuis homepage
async fn scrape_main_stations(&self) -> Result<Vec<Station>> {
let html = self.client
.get("https://www.radiofrance.fr/")
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
let re = regex::Regex::new(
r"(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)"
)?;
let mut slugs = std::collections::HashSet::new();
for cap in re.captures_iter(&html) {
slugs.insert(cap[0].to_string());
}
Ok(slugs.into_iter().map(|slug| Station {
slug: slug.clone(),
name: Self::slug_to_name(&slug),
station_type: StationType::Main,
}).collect())
}
/// Scraper les webradios d'une station donnée
///
/// Fonctionne pour n'importe quelle station (fip, francemusique, mouv, etc.)
/// Retourne un Vec vide si aucune webradio n'est trouvée.
async fn scrape_station_webradios(&self, station: &str) -> Result<Vec<Station>> {
let url = format!("https://www.radiofrance.fr/{}", station);
let html = self.client
.get(&url)
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
// Pattern générique : {station}_[a-z_]+
let pattern = format!(r"{}_[a-z_]+", station);
let re = regex::Regex::new(&pattern)?;
let mut slugs = std::collections::HashSet::new();
for cap in re.captures_iter(&html) {
slugs.insert(cap[0].to_string());
}
Ok(slugs.into_iter().map(|slug| Station {
slug: slug.clone(),
name: Self::slug_to_name(&slug),
station_type: StationType::Webradio {
parent_station: station.to_string(),
},
}).collect())
}
/// Découvrir les radios locales France Bleu via API
async fn discover_local_radios(&self) -> Result<Vec<Station>> {
let response = self.live_metadata("francebleu").await?;
Ok(response.local_radios
.unwrap_or_default()
.into_iter()
.map(|local| Station {
slug: local.name,
name: local.title,
station_type: StationType::LocalRadio {
region: local.title.replace("ICI ", ""),
},
})
.collect())
}
/// Convertir slug en nom lisible (heuristique simple)
fn slug_to_name(slug: &str) -> String {
// Transformations basiques, à améliorer
slug.replace('_', " ")
.split_whitespace()
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
```
### 3. Métadonnées live
```rust
impl RadioFranceClient {
/// Récupérer les métadonnées live d'une station
///
/// # Arguments
/// * `station` - Slug de la station (ex: "franceculture", "fip_rock")
///
/// # Webradios
/// Pour les webradios FIP/France Musique, utiliser le format :
/// - Principales : "fip", "francemusique"
/// - Webradios : "fip_rock", "francemusique_jazz"
///
/// L'API utilise le paramètre `?webradio=` automatiquement si nécessaire.
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
let (base_station, webradio) = Self::parse_station_slug(station);
let mut url = url::Url::parse(&format!(
"https://www.radiofrance.fr/{}/api/live?",
base_station
))?;
// Ajouter le paramètre webradio si nécessaire
if let Some(wr) = webradio {
url.query_pairs_mut().append_pair("webradio", wr);
}
let response = self.client
.get(url)
.timeout(self.timeout)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"API returned status: {}",
response.status()
)));
}
Ok(response.json().await?)
}
/// Parser le slug pour extraire station de base et webradio
///
/// Exemples :
/// - "fip" → ("fip", None)
/// - "fip_rock" → ("fip", Some("fip_rock"))
/// - "francemusique_jazz" → ("francemusique", Some("francemusique_jazz"))
/// - "franceinter" → ("franceinter", None)
fn parse_station_slug(slug: &str) -> (&str, Option<&str>) {
if slug.starts_with("fip_") {
("fip", Some(slug))
} else if slug.starts_with("francemusique_") {
("francemusique", Some(slug))
} else if slug.starts_with("francebleu_") {
// Radios locales : pas de paramètre webradio, slug direct
(slug, None)
} else {
// Stations principales
(slug, None)
}
}
/// Récupérer uniquement les métadonnées de l'émission actuelle
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
let response = self.live_metadata(station).await?;
Ok(response.now)
}
}
```
### 4. Flux audio (qualité maximale uniquement)
```rust
impl RadioFranceClient {
/// Récupérer l'URL du flux audio en qualité maximale
///
/// Priorité : AAC 192 kbps (hifi) > HLS
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
let metadata = self.live_metadata(station).await?;
// Chercher AAC hifi (192 kbps)
if let Some(source) = metadata.now.media.sources.iter().find(|s| {
s.format == StreamFormat::Aac
&& s.broadcast_type == BroadcastType::Live
&& s.bitrate == 192
}) {
return Ok(source.url.clone());
}
// Fallback HLS
if let Some(source) = metadata.now.media.sources.iter().find(|s| {
s.format == StreamFormat::Hls
&& s.broadcast_type == BroadcastType::Live
}) {
return Ok(source.url.clone());
}
Err(Error::NoHifiStream(format!(
"No HiFi stream found for station: {}",
station
)))
}
/// Lister tous les flux disponibles pour une station
pub async fn get_available_streams(&self, station: &str) -> Result<Vec<StreamSource>> {
let metadata = self.live_metadata(station).await?;
Ok(metadata.now.media.sources)
}
}
```
### 5. Images (Pikapi)
```rust
impl RadioFranceClient {
/// Construire l'URL d'une image Pikapi
///
/// # Arguments
/// * `uuid` - UUID de l'image (extrait des métadonnées)
/// * `size` - Taille souhaitée
pub fn get_image_url(uuid: &str, size: ImageSize) -> String {
let size_str = match size {
ImageSize::Tiny => "88x88",
ImageSize::Small => "200x200",
ImageSize::Medium => "420x720",
ImageSize::Large => "560x960",
ImageSize::XLarge => "1200x680",
ImageSize::Raw => "raw",
};
format!("https://www.radiofrance.fr/pikapi/images/{}/{}", uuid, size_str)
}
/// Extraire l'UUID d'une URL Pikapi existante
pub fn extract_image_uuid(url: &str) -> Option<String> {
let re = regex::Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
re.captures(url)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
}
}
```
### 6. Polling intelligent
```rust
impl RadioFranceClient {
/// Calculer le délai avant le prochain refresh recommandé
pub fn next_refresh_delay(metadata: &LiveResponse) -> Duration {
Duration::from_millis(metadata.delay_to_refresh)
}
/// Calculer le délai en tenant compte du temps écoulé
pub fn adjusted_refresh_delay(
metadata: &LiveResponse,
fetched_at: std::time::SystemTime,
) -> Duration {
let base_delay = Duration::from_millis(metadata.delay_to_refresh);
let elapsed = fetched_at.elapsed().unwrap_or(Duration::ZERO);
base_delay.saturating_sub(elapsed)
}
}
```
---
## Exemple d'utilisation
### Découverte et affichage de toutes les stations
```rust
use pmoradiofrance::RadioFranceClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = RadioFranceClient::new().await?;
println!("Découverte des stations...");
let stations = client.discover_all_stations().await?;
println!("Trouvé {} stations :", stations.len());
for station in &stations {
println!(" - {} ({})", station.name, station.slug);
}
Ok(())
}
```
### Récupération des métadonnées live
```rust
use pmoradiofrance::RadioFranceClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = RadioFranceClient::new().await?;
// Station principale
let fc_live = client.live_metadata("franceculture").await?;
println!("France Culture : {} - {}",
fc_live.now.first_line.title.unwrap_or_default(),
fc_live.now.second_line.title.unwrap_or_default()
);
// Webradio FIP
let fip_rock_live = client.live_metadata("fip_rock").await?;
if let Some(song) = &fip_rock_live.now.song {
println!("FIP Rock : {} - {}",
song.interpreters.join(", "),
fip_rock_live.now.first_line.title.unwrap_or_default()
);
}
Ok(())
}
```
### Polling avec délai intelligent
```rust
use pmoradiofrance::RadioFranceClient;
use std::time::{Duration, SystemTime};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = RadioFranceClient::new().await?;
loop {
let fetched_at = SystemTime::now();
let metadata = client.live_metadata("fip").await?;
println!("Now: {} - {}",
metadata.now.second_line.title.unwrap_or_default(),
metadata.now.first_line.title.unwrap_or_default()
);
// Attendre le délai recommandé
let delay = RadioFranceClient::adjusted_refresh_delay(&metadata, fetched_at);
tokio::time::sleep(delay).await;
}
}
```
### Récupération du flux HiFi
```rust
use pmoradiofrance::RadioFranceClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = RadioFranceClient::new().await?;
let stream_url = client.get_hifi_stream_url("franceculture").await?;
println!("Stream HiFi : {}", stream_url);
// Exemple : https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
Ok(())
}
```
---
## Points d'attention
### 1. Rate limiting
- Pas de limite documentée observée
- **Toujours** respecter `delayToRefresh` pour éviter les requêtes inutiles
- Mettre en cache les résultats de `discover_all_stations()` (TTL : 24h recommandé)
### 2. User-Agent
Pour un projet open-source, utiliser un User-Agent identifiable :
```rust
impl Default for ClientBuilder {
fn default() -> Self {
Self {
user_agent: "PMOMusic/0.3.10 (https://github.com/votre-repo)".to_string(),
// ...
}
}
}
```
### 3. Gestion d'erreurs
Les APIs peuvent retourner :
- **Données vides** (`null`) pour certains champs
- **`song`** absent pour radios non-musicales (France Inter, France Info, France Culture)
- **`localRadios`** uniquement pour France Bleu
- **`visual_background`** parfois absent
Toujours utiliser `Option<>` et gérer les cas manquants.
### 4. Webradios et paramètre `?webradio=`
- **Stations principales** : `/franceinter/api/live?`
- **Webradios FIP** : `/fip/api/live?webradio=fip_rock`
- **Webradios France Musique** : `/francemusique/api/live?webradio=francemusique_jazz`
- **Radios locales** : `/francebleu_alsace/api/live?` (slug direct, pas de paramètre)
### 5. Images Pikapi
Les URLs dans les réponses API utilisent parfois des chemins complets, parfois juste l'UUID :
```json
"src": "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39"
```
Toujours normaliser en extrayant l'UUID et en reconstruisant l'URL avec la taille souhaitée.
### 6. Scraping HTML
Le scraping HTML est **fragile** par nature. Recommandations :
- **Cache agressif** : Stocker les résultats de découverte (TTL 24h minimum)
- **Fallback** : Avoir une liste de base hardcodée si le scraping échoue
- **Validation optionnelle** : Tester chaque station découverte avec `/api/live?` avant de l'ajouter (peut être lent)
- **Monitoring** : Logger les échecs de découverte
### 7. Performance
Pour découvrir ~70 stations :
- **Scraping** : 1 homepage + 8 pages stations (une par station principale)
- **Validation France Bleu** : 1 requête API
- **Total** : ~10 requêtes HTTP
Temps estimé : 3-5 secondes avec timeout 30s (parallélisable pour réduire à ~1-2s).
### 8. Respect des CGU
- APIs publiques utilisées par le site officiel
- Usage acceptable pour un projet open-source personnel/non-commercial
- **Ne pas redistribuer** les flux audio commercialement
- **Ne pas surcharger** les serveurs (respecter `delayToRefresh`)
---
## Prochaines étapes
1. **Implémenter `client.rs`** avec l'architecture décrite
2. **Ajouter les tests** :
- Tests unitaires pour parsing de slugs
- Tests d'intégration pour découverte
- Tests d'API live (avec captures VCR)
3. **Intégrer avec `pmosource`** :
- Implémenter le trait `MusicSource`
- Gérer le cache via `SourceCacheManager`
- Support FIFO pour radios musicales (FIP)
4. **Documenter les limitations** :
- Stations non accessibles
- Cas d'erreur connus
- Métriques de fiabilité
---
**Fin du rapport d'architecture client.rs**

317
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.10"
version = "0.3.12"
dependencies = [
"axum 0.8.7",
"console-subscriber",
@@ -693,7 +693,7 @@ dependencies = [
"bevy_ptr",
"bevy_reflect_derive",
"bevy_utils",
"derive_more",
"derive_more 2.0.1",
"disqualified",
"downcast-rs",
"erased-serde",
@@ -1276,6 +1276,29 @@ dependencies = [
"typenum",
]
[[package]]
name = "cssparser"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.110",
]
[[package]]
name = "ctr"
version = "0.9.2"
@@ -1335,6 +1358,17 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "derive_more"
version = "2.0.1"
@@ -1438,12 +1472,33 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.15.0"
@@ -1708,6 +1763,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -1810,6 +1875,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "gcc"
version = "0.3.55"
@@ -1848,6 +1922,15 @@ dependencies = [
"libc",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -2044,6 +2127,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "html5ever"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -2713,6 +2808,12 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
@@ -2722,6 +2823,31 @@ dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3528,6 +3654,58 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -3729,6 +3907,7 @@ dependencies = [
"pmodidl",
"pmoserver",
"pmoupnp",
"pmoutils",
"quick-xml",
"rand 0.9.2",
"ratatui",
@@ -3832,6 +4011,7 @@ dependencies = [
"pmoparadise",
"pmoplaylist",
"pmoqobuz",
"pmoradiofrance",
"pmoserver",
"pmosource",
"pmoupnp",
@@ -3963,6 +4143,38 @@ dependencies = [
"utoipa",
]
[[package]]
name = "pmoradiofrance"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.7",
"chrono",
"futures",
"pmoaudiocache",
"pmocache",
"pmoconfig",
"pmocovers",
"pmodidl",
"pmoplaylist",
"pmoserver",
"pmosource",
"pmoupnp",
"regex",
"reqwest",
"scraper",
"serde",
"serde_json",
"serde_yaml",
"thiserror 2.0.17",
"tokio",
"tokio-test",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
name = "pmoserver"
version = "0.1.0"
@@ -4146,6 +4358,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -4447,7 +4665,7 @@ dependencies = [
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@@ -4836,6 +5054,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -4859,6 +5092,25 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags 2.10.0",
"cssparser",
"derive_more 0.99.20",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -4959,6 +5211,15 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -5047,6 +5308,12 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.11"
@@ -5158,6 +5425,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strum"
version = "0.26.3"
@@ -5509,6 +5801,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6002,7 +6305,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.1.14",
]
[[package]]
@@ -6011,6 +6314,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View File

@@ -16,10 +16,12 @@ members = [
"pmoaudio",
"pmoqobuz",
"pmoparadise",
"pmoradiofrance",
"pmosource",
"pmoplaylist",
"pmoflac",
"pmometadata", "pmocontrol",
"pmometadata",
"pmocontrol",
]
[workspace.dependencies]
@@ -45,6 +47,8 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
reqwest = { version = "0.12", default-features = false }
ureq = "3.1"
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
axum = "0.8.4"
futures = "0.3"
# Utilities
chrono = { version = "0.4", features = ["serde"] }

View File

@@ -1,13 +1,13 @@
[package]
name = "PMOMusic"
version = "0.3.11"
version = "0.3.12"
edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmoupnp = { path = "../pmoupnp"}
pmomediarenderer = { path = "../pmomediarenderer" }
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] }
pmosource = { path = "../pmosource", features = ["server"] }
pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }

View File

@@ -54,6 +54,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Enregistrer la source Radio France (inclut l'initialisation des routes API)
info!("📻 Registering Radio France source...");
if let Err(e) = server.write().await.register_radiofrance().await {
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
}
// Lister toutes les sources enregistrées
let sources = server.read().await.list_music_sources().await;
info!("✅ {} music source(s) registered", sources.len());

2559
metadata.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
pmoupnp = { path = "../pmoupnp" }
pmodidl = { path = "../pmodidl" }
pmoutils = { path = "../pmoutils" }
quick-xml = { workspace = true }
thiserror = { workspace = true }
ureq = "3.1.4"

View File

@@ -19,7 +19,7 @@ fn ensure_crypto_provider_initialized() {
INIT.call_once(|| {
let _ = rustls::crypto::CryptoProvider::install_default(
rustls::crypto::aws_lc_rs::default_provider()
rustls::crypto::aws_lc_rs::default_provider(),
);
});
}
@@ -47,21 +47,25 @@ fn main() {
// Connect to the device
println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT);
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
Ok(device) => {
println!(" ✓ Connected");
device
}
Err(e) => {
eprintln!(" ✗ Failed: {}", e);
std::process::exit(1);
}
};
let cast_device =
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
Ok(device) => {
println!(" ✓ Connected");
device
}
Err(e) => {
eprintln!(" ✗ Failed: {}", e);
std::process::exit(1);
}
};
// Connect to receiver channel
println!();
println!("→ Connecting to receiver channel...");
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
if let Err(e) = cast_device
.connection
.connect(DEFAULT_DESTINATION_ID.to_string())
{
eprintln!(" ✗ Failed: {}", e);
std::process::exit(1);
}

View File

@@ -36,7 +36,7 @@ fn ensure_crypto_provider_initialized() {
INIT.call_once(|| {
let _ = rustls::crypto::CryptoProvider::install_default(
rustls::crypto::aws_lc_rs::default_provider()
rustls::crypto::aws_lc_rs::default_provider(),
);
println!("✓ Rustls CryptoProvider initialized");
});
@@ -88,7 +88,10 @@ fn main() {
eprintln!("Usage: {} <chromecast_ip> [media_url]", args[0]);
eprintln!("\nExample:");
eprintln!(" {} 192.168.1.100", args[0]);
eprintln!("\nIf no media URL is provided, will use: {}", TEST_MEDIA_URL);
eprintln!(
"\nIf no media URL is provided, will use: {}",
TEST_MEDIA_URL
);
std::process::exit(1);
}
@@ -116,21 +119,25 @@ fn main() {
// Step 1: Connect to the device
println!("──────────────────────────────────────────────────────────");
println!("STEP 1: Connecting to Chromecast...");
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
Ok(device) => {
println!("✓ Connected to Chromecast");
device
}
Err(e) => {
eprintln!("✗ Failed to connect: {}", e);
std::process::exit(1);
}
};
let cast_device =
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
Ok(device) => {
println!("✓ Connected to Chromecast");
device
}
Err(e) => {
eprintln!("✗ Failed to connect: {}", e);
std::process::exit(1);
}
};
// Step 2: Connect to the default receiver channel
println!();
println!("STEP 2: Connecting to receiver channel...");
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
if let Err(e) = cast_device
.connection
.connect(DEFAULT_DESTINATION_ID.to_string())
{
eprintln!("✗ Failed to connect channel: {}", e);
std::process::exit(1);
}
@@ -151,7 +158,10 @@ fn main() {
let status = match cast_device.receiver.get_status() {
Ok(status) => {
println!("✓ Receiver status obtained");
println!(" - Volume: {:.0}%", status.volume.level.unwrap_or(0.5) * 100.0);
println!(
" - Volume: {:.0}%",
status.volume.level.unwrap_or(0.5) * 100.0
);
println!(" - Muted: {}", status.volume.muted.unwrap_or(false));
println!(" - Running apps: {}", status.applications.len());
status
@@ -165,7 +175,10 @@ fn main() {
// Step 5: Launch DefaultMediaReceiver
println!();
println!("STEP 5: Launching DefaultMediaReceiver app...");
let app = match cast_device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
let app = match cast_device
.receiver
.launch_app(&CastDeviceApp::DefaultMediaReceiver)
{
Ok(app) => {
println!("✓ App launched successfully");
println!(" - App ID: {}", app.app_id);
@@ -201,11 +214,10 @@ fn main() {
metadata: None,
};
match cast_device.media.load(
app.transport_id.as_str(),
app.session_id.as_str(),
&media,
) {
match cast_device
.media
.load(app.transport_id.as_str(), app.session_id.as_str(), &media)
{
Ok(status) => {
println!("✓ Media loaded successfully!");
println!(" - Media status entries: {}", status.entries.len());
@@ -241,7 +253,10 @@ fn main() {
Ok(ChannelMessage::Heartbeat(response)) => {
if let HeartbeatResponse::Ping = response {
heartbeat_count += 1;
println!("[Heartbeat #{:3}] Received Ping, sending Pong...", heartbeat_count);
println!(
"[Heartbeat #{:3}] Received Ping, sending Pong...",
heartbeat_count
);
if let Err(e) = cast_device.heartbeat.pong() {
eprintln!("✗ Failed to send pong: {}", e);

View File

@@ -3,7 +3,7 @@ use std::thread;
use std::time::Duration;
use pmocontrol::RendererProtocol;
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo, UpnpMediaServer};
fn main() -> std::io::Result<()> {
// Un tout petit logging optionnel

View File

@@ -19,9 +19,9 @@ use crossterm::terminal::{
};
use pmocontrol::model::TrackMetadata;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
RendererEvent, RendererInfo, TransportControl, VolumeControl,
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, PlaybackItem,
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, RendererEvent, RendererInfo,
TransportControl, UpnpMediaServer, UpnpMediaServer, VolumeControl,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;

View File

@@ -10,8 +10,9 @@ use std::time::Duration;
use anyhow::{Context, Result};
use pmocontrol::model::TrackMetadata;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent,
MusicRendererBackend, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
UpnpMediaServer, UpnpMediaServer,
};
const DEFAULT_TIMEOUT_SECS: u64 = 5;

View File

@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use crossbeam_channel::RecvTimeoutError;
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
use pmocontrol::{ControlPoint, MediaServerEvent, ServerId, UpnpMediaServer};
const DISCOVERY_WAIT_SECS: u64 = 5;
const MONITOR_DURATION_SECS: u64 = 90;

View File

@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use pmocontrol::{
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
RendererInfo,
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider,
MusicRendererBackend, RendererInfo,
control_point::ControlPoint,
openhome_client::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,

View File

@@ -344,7 +344,12 @@ fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<(
Ok(())
}
fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) {
fn progress_monitor(
renderer: &MusicRendererBackend,
label: &str,
iterations: usize,
interval_secs: u64,
) {
println!(
"\n[{label}] polling playback state/position {} times (every {} s)...",
iterations, interval_secs

View File

@@ -1647,7 +1647,7 @@ fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: None,
duration: metadata.and_then(|m| m.duration.clone()),
url: item.uri.clone(),
}],
descriptions: Vec::new(),
@@ -1686,6 +1686,7 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata {
date: None,
track_number: None,
creator: None,
duration: None,
})
}

View File

@@ -7,7 +7,9 @@ use std::{
use tracing::debug;
use crate::{
arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host
arylic_client::{ARYLIC_TCP_PORT, send_command_required},
errors::ControlPointError,
linkplay_client::extract_linkplay_host,
};
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();

View File

@@ -65,7 +65,10 @@ impl ChromecastDiscoveryManager {
.collect();
if addresses.is_empty() {
warn!("No IP address found for Chromecast device: {}", service_name);
warn!(
"No IP address found for Chromecast device: {}",
service_name
);
return;
}
@@ -127,22 +130,19 @@ impl ChromecastDiscoveryManager {
// Extract friendly name from TXT record "fn" if available
// Otherwise, extract from service instance name (PTR record)
let friendly_name = txt_records
.get("fn")
.cloned()
.unwrap_or_else(|| {
// Fallback: extract from service name, removing the UUID suffix if present
service_name
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.split('-')
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
.collect::<Vec<_>>()
.join("-")
.trim()
.to_string()
});
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
// Fallback: extract from service name, removing the UUID suffix if present
service_name
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.split('-')
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
.collect::<Vec<_>>()
.join("-")
.trim()
.to_string()
});
debug!(
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",

View File

@@ -3,14 +3,14 @@ use std::time::Duration;
use quick_xml::{Error as XmlError, Reader, events::Event};
use thiserror::Error;
use tracing::{debug};
use tracing::debug;
use crate::DeviceId;
use crate::discovery::arylic::detect_arylic_tcp;
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
use crate::media_server::UpnpMediaServer;
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
use crate::upnp_clients::{AvTransportClient,resolve_control_url};
use crate::upnp_clients::{AvTransportClient, resolve_control_url};
use ureq::Agent;
@@ -77,7 +77,6 @@ pub struct ParsedDeviceDescription {
}
impl ParsedDeviceDescription {
/// Fetch and parse the device description.xml at endpoint.location.
pub fn new(
udn: &str,
@@ -108,7 +107,7 @@ impl ParsedDeviceDescription {
let mut buf = Vec::new();
let mut parsed = ParsedDeviceDescription::default();
parsed.timeout_secs=timeout_secs;
parsed.timeout_secs = timeout_secs;
parsed.location = location.to_string();
parsed.udn = udn.to_string();
parsed.server_header = server_header.to_string();
@@ -354,9 +353,7 @@ impl ParsedDeviceDescription {
parsed.require_fields()
}
pub fn build_renderer(
&self,
) -> Option<RendererInfo> {
pub fn build_renderer(&self) -> Option<RendererInfo> {
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
@@ -371,10 +368,16 @@ impl ParsedDeviceDescription {
let udn = self.udn.to_ascii_lowercase();
let mut caps = detect_renderer_capabilities(&self.service_types);
if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
if detect_linkplay_http(
&self.location,
Duration::from_secs(self.timeout_secs.max(1)),
) {
caps.has_linkplay_http = true;
}
if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
if detect_arylic_tcp(
&self.location,
Duration::from_secs(self.timeout_secs.max(1)),
) {
caps.has_arylic_tcp = true;
}
let protocol = detect_renderer_protocol(&caps);
@@ -390,68 +393,54 @@ impl ParsedDeviceDescription {
self.location.clone(),
self.server_header.clone(),
self.avtransport_service_type.clone(),
self
.avtransport_control_url
self.avtransport_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.rendering_control_service_type.clone(),
self
.rendering_control_control_url
self.rendering_control_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.connection_manager_service_type.clone(),
self
.connection_manager_control_url
self.connection_manager_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_playlist_service_type.clone(),
self
.oh_playlist_control_url
self.oh_playlist_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_playlist_event_sub_url
self.oh_playlist_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&self.location, url)),
self.oh_info_service_type.clone(),
self
.oh_info_control_url
self.oh_info_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_info_event_sub_url
self.oh_info_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&self.location, url)),
self.oh_time_service_type.clone(),
self
.oh_time_control_url
self.oh_time_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_time_event_sub_url
self.oh_time_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&self.location, url)),
self.oh_volume_service_type.clone(),
self
.oh_volume_control_url
self.oh_volume_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_radio_service_type.clone(),
self
.oh_radio_control_url
self.oh_radio_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_product_service_type.clone(),
self
.oh_product_control_url
self.oh_product_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
))
}
pub fn build_server(
&self,
) -> Option<UpnpMediaServer> {
pub fn build_server(&self) -> Option<UpnpMediaServer> {
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
return None;
@@ -480,14 +469,11 @@ impl ParsedDeviceDescription {
self.content_directory_service_type.clone(),
content_directory_control_url,
))
}
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
/// Ok(None) if no AVTransport service was found.
pub fn build_avtransport_client(
&self,
) -> Result<Option<AvTransportClient>, DescriptionError> {
pub fn build_avtransport_client(&self) -> Result<Option<AvTransportClient>, DescriptionError> {
let service_type = match &self.avtransport_service_type {
Some(st) => st.clone(),
None => return Ok(None),
@@ -678,9 +664,6 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
}
}
/// Detect whether a renderer exposes the LinkPlay HTTP API.
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
let Some(host) = extract_linkplay_host(location) else {

View File

@@ -0,0 +1 @@

View File

@@ -565,6 +565,7 @@ pub fn playback_item_from_entry(
date: entry.date.clone(),
track_number: entry.track_number.clone(),
creator: entry.creator.clone(),
duration: resource.duration.clone(),
};
debug!(

View File

@@ -93,6 +93,7 @@ pub struct TrackMetadata {
pub date: Option<String>,
pub track_number: Option<String>,
pub creator: Option<String>,
pub duration: Option<String>,
}
#[derive(Clone, Debug, Copy)]

View File

@@ -1245,33 +1245,46 @@ pub(crate) fn build_didl_lite_metadata(
uri: &str,
protocol_info: &str,
) -> String {
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
<dc:title>{}</dc:title>
<dc:creator>{}</dc:creator>
<upnp:artist>{}</upnp:artist>
<upnp:album>{}</upnp:album>
{}
<res protocolInfo="{}">{}</res>
</item>
</DIDL-Lite>"#,
metadata.title.as_deref().unwrap_or("Unknown Title"),
metadata
.creator
.as_deref()
.or(metadata.artist.as_deref())
.unwrap_or("Unknown Artist"),
metadata.artist.as_deref().unwrap_or("Unknown Artist"),
metadata.album.as_deref().unwrap_or("Unknown Album"),
metadata
.album_art_uri
.as_ref()
.map(|art_uri| format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", art_uri))
.unwrap_or_default(),
protocol_info,
uri
)
use pmodidl::{DIDLLite, Item, Resource};
use pmoutils::ToXmlElement;
// Construire l'Item DIDL avec toutes les métadonnées
let item = Item {
id: "0".to_string(),
parent_id: "-1".to_string(),
restricted: Some("1".to_string()),
title: metadata
.title
.clone()
.unwrap_or_else(|| "Unknown Title".to_string()),
creator: metadata.creator.clone().or_else(|| metadata.artist.clone()),
class: "object.item.audioItem.musicTrack".to_string(),
artist: metadata.artist.clone(),
album: metadata.album.clone(),
genre: metadata.genre.clone(),
album_art: metadata.album_art_uri.clone(),
album_art_pk: None,
date: metadata.date.clone(),
original_track_number: metadata.track_number.clone(),
resources: vec![Resource {
protocol_info: protocol_info.to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: metadata.duration.clone(),
url: uri.to_string(),
}],
descriptions: vec![],
};
// Construire le DIDL-Lite complet
let didl = DIDLLite {
items: vec![item],
..Default::default()
};
// Sérialiser en XML via pmodidl
didl.to_xml()
}
impl DeviceIdentity for MusicRenderer {

View File

@@ -327,17 +327,57 @@ impl PlaybackPosition for OpenHomeRenderer {
}
}
// Get duration from Time service, but fall back to DIDL metadata if duration is 0
let track_duration = if time_info.duration_secs == 0 {
// Try to extract duration from DIDL metadata
track_metadata_xml
.as_ref()
.and_then(|xml| parse_didl_duration_openhome(xml))
} else {
Some(format_hhmmss_u32(time_info.duration_secs))
};
tracing::trace!(
"OpenHome playback_position: duration_secs={}, track_duration={:?}",
time_info.duration_secs,
track_duration
);
Ok(PlaybackPositionInfo {
track: track_id,
rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)),
abs_time: None,
track_duration: Some(format_hhmmss_u32(time_info.duration_secs)),
track_duration,
track_metadata: track_metadata_xml,
track_uri,
})
}
}
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
// Search for duration attribute in <res> element
let res_start = didl.find("<res ")?;
let after_res = &didl[res_start..];
let tag_close = after_res.find('>')?;
let tag_attrs = &after_res[..tag_close];
if let Some(duration_start) = tag_attrs.find("duration=\"") {
let duration_offset = duration_start + "duration=\"".len();
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
tracing::info!(
"OpenHome: Extracted duration from DIDL metadata: {}",
duration
);
return Some(duration.to_string());
}
}
tracing::debug!("OpenHome: No duration found in DIDL metadata");
None
}
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
match raw.trim().to_ascii_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,

View File

@@ -63,17 +63,19 @@ pub fn parse_time_flexible(input: &str) -> Result<u32, ControlPointError> {
let parts: Vec<&str> = input.split(':').collect();
if parts.is_empty() || parts.len() > 3 {
return Err(ControlPointError::InvalidTimeFormat(
format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input)
));
return Err(ControlPointError::InvalidTimeFormat(format!(
"Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS",
input
)));
}
let mut total = 0u32;
for part in parts {
let value = part.parse::<u32>().map_err(|_| {
ControlPointError::InvalidTimeFormat(
format!("Invalid numeric value '{}' in time string '{}'", part, input)
)
ControlPointError::InvalidTimeFormat(format!(
"Invalid numeric value '{}' in time string '{}'",
part, input
))
})?;
total = total * 60 + value;
}
@@ -103,33 +105,29 @@ pub fn parse_hhmmss_strict(input: &str) -> Result<u64, ControlPointError> {
let parts: Vec<&str> = input.split(':').collect();
if parts.len() != 3 {
return Err(ControlPointError::InvalidTimeFormat(
format!("Invalid time format '{}': expected exactly HH:MM:SS", input)
));
return Err(ControlPointError::InvalidTimeFormat(format!(
"Invalid time format '{}': expected exactly HH:MM:SS",
input
)));
}
let hours: u64 = parts[0].parse().map_err(|_| {
ControlPointError::InvalidTimeFormat(
format!("Invalid hour component in '{}'", input)
)
ControlPointError::InvalidTimeFormat(format!("Invalid hour component in '{}'", input))
})?;
let minutes: u64 = parts[1].parse().map_err(|_| {
ControlPointError::InvalidTimeFormat(
format!("Invalid minute component in '{}'", input)
)
ControlPointError::InvalidTimeFormat(format!("Invalid minute component in '{}'", input))
})?;
let seconds: u64 = parts[2].parse().map_err(|_| {
ControlPointError::InvalidTimeFormat(
format!("Invalid second component in '{}'", input)
)
ControlPointError::InvalidTimeFormat(format!("Invalid second component in '{}'", input))
})?;
if minutes >= 60 || seconds >= 60 {
return Err(ControlPointError::InvalidTimeFormat(
format!("Invalid time '{}': minutes and seconds must be < 60", input)
));
return Err(ControlPointError::InvalidTimeFormat(format!(
"Invalid time '{}': minutes and seconds must be < 60",
input
)));
}
Ok(hours * 3600 + minutes * 60 + seconds)
@@ -209,7 +207,7 @@ mod tests {
#[test]
fn test_ms_conversions() {
assert_eq!(ms_to_seconds(1000), 1);
assert_eq!(ms_to_seconds(1500), 1); // rounds down
assert_eq!(ms_to_seconds(1500), 1); // rounds down
assert_eq!(ms_to_seconds(999), 0);
assert_eq!(seconds_to_ms(1), 1000);

View File

@@ -23,6 +23,8 @@ pub struct UpnpRenderer {
connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
/// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas)
cached_duration: Arc<Mutex<Option<String>>>,
}
impl UpnpRenderer {
@@ -103,6 +105,7 @@ impl UpnpRenderer {
connection_manager,
has_avtransport_set_next,
queue,
cached_duration: Arc::new(Mutex::new(None)),
}
}
}
@@ -153,6 +156,7 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
connection_manager,
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
queue,
cached_duration: Arc::new(Mutex::new(None)),
})
}
@@ -202,6 +206,21 @@ impl QueueTransportControl for UpnpRenderer {
)
};
tracing::info!(
"play_from_queue DIDL metadata (first 800 chars):\n{}",
&metadata[..metadata.len().min(800)]
);
// Parse et cache la durée du DIDL
let duration = parse_didl_duration(&metadata);
if let Some(ref dur) = duration {
tracing::info!("Caching duration from queue DIDL: {}", dur);
*self.cached_duration.lock().unwrap() = Some(dur.clone());
} else {
tracing::debug!("No duration to cache from queue DIDL");
*self.cached_duration.lock().unwrap() = None;
}
// UPNP: SetAVTransportURI + Play
let avt = self.avtransport()?;
avt.set_av_transport_uri(&item.uri, &metadata)?;
@@ -307,11 +326,61 @@ impl QueueBackend for UpnpRenderer {
}
}
/// Parse le DIDL-Lite pour extraire la durée du premier élément <res>
fn parse_didl_duration(didl: &str) -> Option<String> {
// Recherche de l'élément <res> (avec ou sans espace après)
let res_start = didl
.find("<res ")
.or_else(|| didl.find("<res>"))
.or_else(|| didl.find("<res\n"))
.or_else(|| didl.find("<res\t"))?;
let after_res = &didl[res_start..];
// Recherche de l'attribut duration dans cet élément <res>
// Il doit être avant la fermeture du tag (avant '>')
if let Some(tag_close) = after_res.find('>') {
let tag_attrs = &after_res[..tag_close];
if let Some(duration_start) = tag_attrs.find("duration=\"") {
let duration_offset = duration_start + "duration=\"".len();
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
tracing::info!("Extracted duration from DIDL: {}", duration);
return Some(duration.to_string());
}
}
}
tracing::warn!("No duration attribute found in DIDL <res> element");
None
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur AVTransport (InstanceID = 0).
impl TransportControl for UpnpRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
// Log du DIDL complet pour déboguer
if !meta.is_empty() {
tracing::debug!(
"play_uri DIDL-Lite metadata: {}",
&meta[..meta.len().min(500)]
);
}
// Parse le DIDL pour extraire la durée
let duration = parse_didl_duration(meta);
if let Some(ref dur) = duration {
tracing::info!("Caching duration from DIDL: {}", dur);
*self.cached_duration.lock().unwrap() = Some(dur.clone());
} else {
tracing::warn!(
"No duration to cache from DIDL (this may be expected for streams without duration)"
);
*self.cached_duration.lock().unwrap() = None;
}
let avt = self.avtransport()?;
avt.set_av_transport_uri(uri, meta)?;
avt.play(0, "1")
@@ -381,11 +450,50 @@ impl PlaybackPosition for UpnpRenderer {
let avt = self.avtransport()?;
let raw: PositionInfo = avt.get_position_info(0)?;
tracing::trace!(
"GetPositionInfo returned: track_duration={:?}, rel_time={:?}",
raw.track_duration,
raw.rel_time
);
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
let normalized_duration = raw.track_duration.as_ref().and_then(|d| {
if d == "00:00:00" || d == "0:00:00" {
None
} else {
Some(d.clone())
}
});
// Si l'ampli ne retourne pas de durée, utilise la durée cachée du DIDL
let track_duration = if normalized_duration.is_none() {
let cached = self.cached_duration.lock().unwrap();
if let Some(ref duration) = *cached {
tracing::debug!("Using cached duration from DIDL as fallback: {}", duration);
Some(duration.clone())
} else {
tracing::warn!("No track_duration from renderer and no cached duration available!");
None
}
} else {
tracing::debug!(
"Using track_duration from renderer: {:?}",
normalized_duration
);
normalized_duration
};
tracing::trace!(
"Final PlaybackPositionInfo: track_duration={:?}, rel_time={:?}",
track_duration,
raw.rel_time
);
Ok(PlaybackPositionInfo {
track: Some(raw.track),
rel_time: raw.rel_time,
abs_time: raw.abs_time,
track_duration: raw.track_duration,
track_duration,
track_metadata: raw.track_metadata,
track_uri: raw.track_uri,
})

View File

@@ -217,6 +217,7 @@ pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMe
date: item.date.clone(),
track_number: item.original_track_number.clone(),
creator: item.creator.clone(),
duration: item.resources.first().and_then(|r| r.duration.clone()),
})
}

View File

@@ -436,10 +436,16 @@ fn build_metadata_xml(item: &PlaybackItem) -> String {
}
let escaped_protocol_info = escape(item.protocol_info.as_str());
xml.push_str(&format!(
r#"<res protocolInfo="{}">{}</res>"#,
escaped_protocol_info, escaped_uri
));
// Build <res> element with optional duration attribute
xml.push_str(&format!(r#"<res protocolInfo="{}""#, escaped_protocol_info));
if let Some(meta) = &item.metadata {
if let Some(duration) = meta.duration.as_deref() {
let escaped_duration = escape(duration);
xml.push_str(&format!(r#" duration="{}""#, escaped_duration));
}
}
xml.push_str(&format!(r#">{}</res>"#, escaped_uri));
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
xml
}

View File

@@ -274,19 +274,19 @@ pub fn ensure_success_with_envelope<'a>(
if let Some(env) = &call_result.envelope {
if let Some(err) = parse_upnp_error(env) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
}
return Err(ControlPointError::SoapActionWrongBody(
action.to_string(),
call_result.status.as_u16() as u32,
call_result.raw_body.clone(),
));
action.to_string(),
call_result.status.as_u16() as u32,
call_result.raw_body.clone(),
));
}
let envelope = call_result
@@ -295,12 +295,12 @@ pub fn ensure_success_with_envelope<'a>(
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
return Err(ControlPointError::SoapUpnpParseError(
action.to_string(),
err.error_code,
err.error_description,
call_result.status.as_u16() as u32,
));
}
Ok(envelope)
@@ -314,7 +314,10 @@ pub fn handle_action_response(
ensure_success(action, call_result)
}
pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
pub fn extract_child_text(
parent: &xmltree::Element,
suffix: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
@@ -327,7 +330,10 @@ pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<Str
Ok(text)
}
pub fn extract_child_text_allow_empty(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
pub fn extract_child_text_allow_empty(
parent: &xmltree::Element,
suffix: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_suffix(parent, suffix)
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
@@ -368,13 +374,19 @@ pub fn extract_child_text_any(
))
}
pub fn extract_child_text_local(parent: &xmltree::Element, local: &str) -> Result<String, ControlPointError> {
let child = find_child_with_local_name(parent, local)
.ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?;
pub fn extract_child_text_local(
parent: &xmltree::Element,
local: &str,
) -> Result<String, ControlPointError> {
let child = find_child_with_local_name(parent, local).ok_or_else(|| {
ControlPointError::SoapAction(format!("Missing {local} element in response"))
})?;
let text = child
.get_text()
.map(|t| t.trim().to_string())
.ok_or_else(|| ControlPointError::SoapAction(format!("{local} element missing text in response")))?;
.ok_or_else(|| {
ControlPointError::SoapAction(format!("{local} element missing text in response"))
})?;
Ok(text)
}
@@ -408,7 +420,6 @@ pub fn parse_bool(value: &str) -> bool {
value.trim() == "1"
}
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
fn value(byte: u8) -> Option<u8> {
match byte {
@@ -432,8 +443,9 @@ pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
continue;
}
let val =
value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?;
let val = value(byte).ok_or_else(|| {
ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char))
})?;
buffer = (buffer << 6) | (val as u32);
bits_collected += 6;
if bits_collected >= 8 {
@@ -446,7 +458,6 @@ pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
Ok(output)
}
#[cfg(test)]
mod tests {
use super::build_soap_body;

View File

@@ -71,6 +71,15 @@ impl AvTransportClient {
/// - `uri` : CurrentURI
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
// Log le DIDL-Lite envoyé (limité pour éviter de polluer les logs)
if !meta.is_empty() {
tracing::debug!(
"SetAVTransportURI - URI: {}, MetaData: {}",
&uri[..uri.len().min(80)],
&meta[..meta.len().min(500)]
);
}
let args = [
("InstanceID", "0"),
("CurrentURI", uri),

View File

@@ -1,17 +1,17 @@
mod openhome_client;
mod avtransport_client;
mod rendering_control_client;
mod connection_manager_client;
mod openhome_client;
mod rendering_control_client;
pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo};
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
pub use crate::upnp_clients::openhome_client::{
OPENHOME_PLAYLIST_HEAD_ID,
OhTrackEntry,OhTrack,
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
pub use crate::upnp_clients::connection_manager_client::{
ConnectionInfo, ConnectionManagerClient, ProtocolInfo,
};
pub use crate::upnp_clients::openhome_client::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
OhTimeClient, OhTrack, OhTrackEntry, OhVolumeClient,
};
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
/// Resolve a possibly relative controlURL against the description URL.
///

View File

@@ -895,6 +895,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
date: item.date.clone(),
track_number: item.original_track_number.clone(),
creator: item.creator.clone(),
duration: item.resources.first().and_then(|r| r.duration.clone()),
})
}

View File

@@ -25,6 +25,7 @@ axum = { version = "0.8", optional = true }
utoipa = { version = "5.3", optional = true }
pmoqobuz = { path = "../pmoqobuz", optional = true }
pmoparadise = { path = "../pmoparadise", optional = true }
pmoradiofrance = { path = "../pmoradiofrance", optional = true }
pmoconfig = { path = "../pmoconfig", optional = true }
anyhow = { version = "1.0", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
@@ -52,3 +53,11 @@ paradise = [
]
# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP)
paradise-api = ["paradise", "pmoparadise/pmoserver"]
# Feature pour activer le support Radio France
radiofrance = [
"api",
"dep:pmoradiofrance",
"pmoradiofrance/server",
"pmoradiofrance/logging",
"dep:pmoconfig"
]

View File

@@ -32,6 +32,16 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<Str
// Retourne uniquement le corps DIDL, sans préfixer une seconde déclaration XML.
let body = didl.to_xml();
// Log le DIDL généré pour déboguer (limité aux 500 premiers caractères)
if !items.is_empty() {
tracing::debug!(
"Generated DIDL-Lite with {} items: {}",
items.len(),
&body[..body.len().min(800)]
);
}
Ok(body)
}

View File

@@ -93,3 +93,6 @@ pub use paradise_streaming::ParadiseStreamingExt;
// Re-export sources when features are enabled
#[cfg(feature = "qobuz")]
pub use pmoqobuz;
#[cfg(feature = "radiofrance")]
pub use pmoradiofrance;

View File

@@ -3,6 +3,7 @@
//! Ce module fournit des helpers pour créer et enregistrer facilement des sources
//! musicales préconfigurées à partir de la configuration système.
use crate::contentdirectory::state;
use pmoserver::Server;
use pmosource::MusicSourceExt;
use std::sync::Arc;
@@ -18,6 +19,10 @@ pub enum SourceInitError {
#[error("Failed to initialize Radio Paradise: {0}")]
ParadiseError(String),
#[cfg(feature = "radiofrance")]
#[error("Failed to initialize Radio France: {0}")]
RadioFranceError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
@@ -117,6 +122,25 @@ pub trait SourcesExt {
/// ```
#[cfg(feature = "paradise")]
async fn register_paradise(&mut self) -> Result<()>;
/// Enregistre la source Radio France
///
/// Cette méthode crée automatiquement un `RadioFranceSource` avec cache activé.
/// Radio France ne nécessite pas d'authentification.
///
/// # Erreurs
///
/// Retourne une erreur si :
/// - La connexion au client Radio France échoue
/// - La feature "radiofrance" n'est pas activée
///
/// # Examples
///
/// ```ignore
/// server.register_radiofrance().await?;
/// ```
#[cfg(feature = "radiofrance")]
async fn register_radiofrance(&mut self) -> Result<()>;
}
#[async_trait::async_trait]
@@ -217,6 +241,50 @@ impl SourcesExt for Server {
Ok(())
}
#[cfg(feature = "radiofrance")]
async fn register_radiofrance(&mut self) -> Result<()> {
use pmoradiofrance::{RadioFranceExt, RadioFranceSource, RadioFranceStatefulClient};
tracing::info!("Initializing Radio France source...");
// Obtenir l'URL de base du serveur
let base_url = self.base_url();
// Créer le client stateful depuis la config
let client = RadioFranceStatefulClient::from_config()
.await
.map_err(|e| {
SourceInitError::RadioFranceError(format!("Failed to create client: {}", e))
})?;
// Créer la source depuis le registry (avec cache)
let source = RadioFranceSource::from_registry(client, base_url).map_err(|e| {
SourceInitError::RadioFranceError(format!("Failed to create source: {}", e))
})?;
// Configurer le notifier pour les événements UPnP GENA
let notifier = Arc::new(|containers: &[String]| {
let refs: Vec<&str> = containers.iter().map(|s| s.as_str()).collect();
state::notify_containers_updated(&refs);
});
let source = source.with_container_notifier(notifier);
// Enregistrer la source (Arc pour partage avec l'API)
let source_arc = Arc::new(source);
self.register_music_source(source_arc.clone()).await;
// Initialiser les routes API Radio France avec la source
self.init_radiofrance_with_source(source_arc)
.await
.map_err(|e| {
SourceInitError::RadioFranceError(format!("Failed to init API routes: {}", e))
})?;
tracing::info!("✅ Radio France source registered successfully");
Ok(())
}
}
#[cfg(test)]

View File

@@ -270,11 +270,7 @@ impl Album {
pub fn formatted_title(&self) -> String {
if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) {
// Convertir Hz en kHz, en gérant les valeurs qui pourraient déjà être en kHz
let rate_khz = if rate > 1000.0 {
rate / 1000.0
} else {
rate
};
let rate_khz = if rate > 1000.0 { rate / 1000.0 } else { rate };
format!("{} ({:.1} kHz / {} bits)", self.title, rate_khz, depth)
} else {
self.title.clone()

90
pmoradiofrance/Cargo.toml Normal file
View File

@@ -0,0 +1,90 @@
[package]
name = "pmoradiofrance"
version = "0.1.0"
edition = "2021"
authors = ["PMOMusic Contributors"]
description = "Rust client for Radio France streaming services"
license = "MIT OR Apache-2.0"
repository = "https://github.com/yourusername/pmomusic"
keywords = ["radio", "france", "streaming", "music", "aac"]
categories = ["multimedia", "api-bindings"]
[dependencies]
# HTTP client for Radio France API requests
reqwest = { version = "0.12", features = ["json"] }
# Async runtime
tokio = { workspace = true }
# Serialization/Deserialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
# Helpers
chrono = { workspace = true }
async-trait = { workspace = true }
# Error handling
thiserror = { workspace = true }
anyhow = { workspace = true }
# Logging
tracing = { workspace = true }
# URL manipulation
url = "2.5"
# HTML scraping for station discovery
scraper = "0.22"
regex = "1.11"
# Common music source traits
pmosource = { path = "../pmosource" }
# DIDL-Lite structures (for playlist support)
pmodidl = { path = "../pmodidl", optional = true }
# Configuration support
pmoconfig = { path = "../pmoconfig", optional = true }
# Cache support
pmocache = { path = "../pmocache", optional = true }
pmocovers = { path = "../pmocovers", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
# Playlist management for FIFO support
pmoplaylist = { path = "../pmoplaylist", optional = true }
# Server integration (optional)
pmoserver = { path = "../pmoserver", optional = true }
pmoupnp = { path = "../pmoupnp", optional = true }
axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
[features]
default = ["pmoconfig"]
# Feature for pmoconfig support
pmoconfig = ["dep:pmoconfig"]
# Feature for cache support
cache = ["dep:pmocache", "dep:pmocovers", "dep:pmoaudiocache"]
# Feature for playlist/FIFO support
playlist = ["dep:pmoplaylist", "dep:pmodidl"]
# Feature for logging (tracing)
logging = []
# Feature for server support (MusicSource + HTTP API routes)
server = ["pmosource/server", "pmoconfig", "cache", "playlist", "dep:pmoserver", "dep:pmoupnp", "dep:axum", "dep:futures"]
# Full feature set
full = ["server", "logging"]
[dev-dependencies]
tokio-test = { workspace = true }
tracing-subscriber = { workspace = true }
[[example]]
name = "discover_stations"
path = "examples/discover_stations.rs"
[[example]]
name = "live_metadata"
path = "examples/live_metadata.rs"

View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Create a simple PNG first, then convert to WebP
# Since we don't have image tools, we'll create a minimal valid WebP file
# Create a minimal 1x1 red WebP image (Radio France red: #e20613)
# This is a hex dump of a minimal WebP file
cat > radiofrance-logo.webp << 'WEBP'
UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=
WEBP
# Decode from base64
base64 -d -i radiofrance-logo.webp > radiofrance-logo-tmp.webp 2>/dev/null
mv radiofrance-logo-tmp.webp radiofrance-logo.webp 2>/dev/null || true
echo "WebP placeholder created"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,40 @@
//! Example: Discover all Radio France stations
//!
//! Run with: cargo run -p pmoradiofrance --example discover_stations
use pmoradiofrance::RadioFranceClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
tracing_subscriber::fmt::init();
println!("Discovering Radio France stations...\n");
let client = RadioFranceClient::new().await?;
let stations = client.discover_all_stations().await?;
// Count by type
let main_count = stations.iter().filter(|s| s.is_main()).count();
let webradio_count = stations.iter().filter(|s| s.is_webradio()).count();
let local_count = stations.iter().filter(|s| s.is_local_radio()).count();
println!("Found {} stations total:\n", stations.len());
println!("=== Main Stations ({}) ===", main_count);
for station in stations.iter().filter(|s| s.is_main()) {
println!(" {} ({})", station.name, station.slug);
}
println!("\n=== Webradios ({}) ===", webradio_count);
for station in stations.iter().filter(|s| s.is_webradio()) {
println!(" {} ({})", station.name, station.slug);
}
println!("\n=== Local Radios ({}) ===", local_count);
for station in stations.iter().filter(|s| s.is_local_radio()) {
println!(" {} ({})", station.name, station.slug);
}
Ok(())
}

View File

@@ -0,0 +1,107 @@
//! Example: Get live metadata for Radio France stations
//!
//! Run with: cargo run -p pmoradiofrance --example live_metadata
//! Or with a specific station: cargo run -p pmoradiofrance --example live_metadata -- fip_rock
use pmoradiofrance::RadioFranceClient;
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
tracing_subscriber::fmt::init();
// Get station from command line or use default
let station = env::args()
.nth(1)
.unwrap_or_else(|| "franceculture".to_string());
println!("Fetching live metadata for {}...\n", station);
let client = RadioFranceClient::new().await?;
let metadata = client.live_metadata(&station).await?;
println!("Station: {}", metadata.station_name);
println!("---");
// Current show
println!("Now playing:");
println!(" Show: {}", metadata.now.first_line.title_or_default());
println!(" Episode: {}", metadata.now.second_line.title_or_default());
if let Some(producer) = &metadata.now.producer {
println!(" Producer: {}", producer);
}
if let Some(intro) = &metadata.now.intro {
let short_intro = if intro.len() > 100 {
format!("{}...", &intro[..100])
} else {
intro.clone()
};
println!(" Description: {}", short_intro);
}
// Song info (for music stations)
if let Some(song) = &metadata.now.song {
println!("\nSong info:");
println!(" Artist: {}", song.artists_display());
if let Some(album) = &song.release.title {
println!(" Album: {}", album);
}
if let Some(year) = song.year {
println!(" Year: {}", year);
}
if let Some(label) = &song.release.label {
println!(" Label: {}", label);
}
}
// Timing
println!("\nTiming:");
if let Some(start) = metadata.now.start_time {
let start_time = chrono::DateTime::from_timestamp(start as i64, 0)
.map(|dt| dt.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
println!(" Started at: {}", start_time);
}
if let Some(end) = metadata.now.end_time {
let end_time = chrono::DateTime::from_timestamp(end as i64, 0)
.map(|dt| dt.format("%H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
println!(" Ends at: {}", end_time);
}
println!(
" Next refresh in: {} seconds",
metadata.delay_to_refresh / 1000
);
// Streams
println!("\nAvailable streams:");
for source in &metadata.now.media.sources {
println!(
" {:?} {} {} kbps: {}",
source.broadcast_type,
source.format.mime_type(),
source.bitrate,
source.url
);
}
// Best HiFi stream
if let Some(best) = metadata.now.media.best_hifi_stream() {
println!("\nRecommended HiFi stream:");
println!(" {}", best.url);
}
// Next show preview
if let Some(next) = &metadata.next {
println!("\nComing up next:");
println!(" {}", next.first_line.title_or_default());
if let Some(producer) = &next.producer {
println!(" by {}", producer);
}
}
Ok(())
}

View File

@@ -0,0 +1,195 @@
//! Endpoints API REST pour Radio France
//!
//! Ce module définit les handlers HTTP pour accéder aux stations Radio France,
//! leurs métadonnées live et les flux de streaming.
use crate::models::LiveResponse;
use crate::playlist::StationGroups;
use crate::pmoserver_ext::RadioFranceState;
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use futures::StreamExt;
use serde_json;
use std::sync::Arc;
// ============ Gestion des erreurs ============
struct AppError(String);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self.0.as_str() {
"not_found" => (StatusCode::NOT_FOUND, self.0),
"internal_error" => (StatusCode::INTERNAL_SERVER_ERROR, self.0),
"bad_gateway" => (StatusCode::BAD_GATEWAY, self.0),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.0),
};
let body = Json(serde_json::json!({
"error": message
}));
(status, body).into_response()
}
}
impl From<String> for AppError {
fn from(err: String) -> Self {
Self(err)
}
}
/// Crée le router pour l'API Radio France
pub fn create_router(state: RadioFranceState) -> Router {
Router::new()
.route("/stations", get(get_stations))
.route("/{slug}/metadata", get(get_metadata))
.route("/{slug}/stream", get(proxy_stream))
.route("/default-logo", get(get_default_logo))
.with_state(state)
}
// ============================================================================
// Route Handlers
// ============================================================================
/// GET /api/radiofrance/stations
/// Returns the grouped list of stations
#[axum::debug_handler]
async fn get_stations(
State(state): State<RadioFranceState>,
) -> Result<Json<StationGroups>, AppError> {
let stations = state
.client
.get_stations()
.await
.map_err(|e| AppError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
Ok(Json(groups))
}
/// GET /api/radiofrance/{slug}/metadata
/// Returns live metadata for a station (with caching)
async fn get_metadata(
State(state): State<RadioFranceState>,
Path(slug): Path<String>,
) -> Result<Json<LiveResponse>, AppError> {
let metadata = state
.client
.get_live_metadata(&slug)
.await
.map_err(|e| AppError(e.to_string()))?;
Ok(Json(metadata))
}
/// GET /api/radiofrance/{slug}/stream
/// Proxies the AAC stream from Radio France (passthrough, no transcoding)
async fn proxy_stream(
State(state): State<RadioFranceState>,
Path(slug): Path<String>,
) -> Result<Response, AppError> {
// Start metadata refresh when stream is accessed
#[cfg(feature = "logging")]
tracing::info!("Stream proxy accessed for station: {}", slug);
if let Some(ref source) = state.source {
// Spawn refresh task (non-blocking)
let source_clone = Arc::clone(source);
let slug_clone = slug.clone();
tokio::spawn(async move {
if let Err(e) = source_clone.start_metadata_refresh(&slug_clone).await {
#[cfg(feature = "logging")]
tracing::error!("Failed to start metadata refresh for {}: {}", slug_clone, e);
}
});
} else {
#[cfg(feature = "logging")]
tracing::warn!("No source available to start metadata refresh");
}
// Get the stream URL
let stream_url = state
.client
.get_stream_url(&slug)
.await
.map_err(|e| AppError(format!("Stream not found: {}", e)))?;
// Connect to the Radio France stream
let response = reqwest::get(&stream_url)
.await
.map_err(|e| AppError(format!("Failed to connect: {}", e)))?;
if !response.status().is_success() {
return Err(AppError(format!("Upstream returned {}", response.status())));
}
// Build response headers
let mut headers = HeaderMap::new();
headers.insert("content-type", "audio/aac".parse().unwrap());
headers.insert("cache-control", "no-cache".parse().unwrap());
// Create streaming body with cleanup on disconnect
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
// Wrap the stream to detect when client disconnects
let source_for_cleanup = state.source.clone();
let slug_for_cleanup = slug.clone();
let monitored_stream =
futures::stream::unfold((stream, false), move |(mut stream, mut done)| {
let source = source_for_cleanup.clone();
let slug = slug_for_cleanup.clone();
async move {
if done {
return None;
}
match stream.next().await {
Some(Ok(chunk)) => Some((Ok(chunk), (stream, false))),
Some(Err(e)) => {
// Error occurred, stop refresh
#[cfg(feature = "logging")]
tracing::info!("Stream error for {}, stopping refresh", slug);
if let Some(src) = source {
src.stop_metadata_refresh(&slug).await;
}
Some((Err(e), (stream, true)))
}
None => {
// Stream ended normally, stop refresh
#[cfg(feature = "logging")]
tracing::info!("Stream ended for {}, stopping refresh", slug);
if let Some(src) = source {
src.stop_metadata_refresh(&slug).await;
}
None
}
}
}
});
let body = Body::from_stream(monitored_stream);
Ok((headers, body).into_response())
}
/// GET /api/radiofrance/default-logo
/// Returns the default Radio France logo (embedded in binary)
async fn get_default_logo() -> impl IntoResponse {
use crate::source::RADIOFRANCE_DEFAULT_IMAGE;
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "image/webp".parse().unwrap());
headers.insert("Cache-Control", "public, max-age=86400".parse().unwrap());
(headers, RADIOFRANCE_DEFAULT_IMAGE).into_response()
}

1212
pmoradiofrance/src/client.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
//! Extension pour intégrer Radio France dans pmoconfig
//!
//! Ce module fournit le trait `RadioFranceConfigExt` qui permet d'ajouter
//! des méthodes de gestion de la configuration Radio France à pmoconfig::Config.
//!
//! # Fonctionnalités
//!
//! - Activation/désactivation de la source
//! - Cache de la liste des stations (TTL configurable, défaut 7 jours)
//! - Configuration minimale (pas de sur-configuration)
//!
//! # Exemple
//!
//! ```no_run
//! use pmoconfig::get_config;
//! use pmoradiofrance::RadioFranceConfigExt;
//!
//! # fn main() -> anyhow::Result<()> {
//! let config = get_config();
//!
//! // Check if enabled
//! if !config.get_radiofrance_enabled()? {
//! println!("Radio France is disabled");
//! return Ok(());
//! }
//!
//! // Get cached stations (or None if cache expired/empty)
//! if let Some(cached) = config.get_radiofrance_cached_stations()? {
//! println!("Found {} cached stations", cached.stations.len());
//! }
//! # Ok(())
//! # }
//! ```
use crate::models::{CachedStationList, Station};
use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;
/// Default TTL for station list cache (7 days in seconds)
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
/// à la gestion de Radio France, incluant :
///
/// - Activation/désactivation
/// - Cache de la liste des stations
///
/// # Auto-persist des valeurs par défaut
///
/// Les getters persistent automatiquement les valeurs par défaut dans la
/// configuration si elles n'existent pas encore.
pub trait RadioFranceConfigExt {
// ========================================================================
// Enable/Disable
// ========================================================================
/// Vérifie si Radio France est activé
///
/// # Returns
///
/// `true` si la source est activée (default), `false` sinon.
fn get_radiofrance_enabled(&self) -> Result<bool>;
/// Active ou désactive Radio France
fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()>;
// ========================================================================
// Station Cache
// ========================================================================
/// Récupère la liste des stations en cache
///
/// # Returns
///
/// - `Some(CachedStationList)` si le cache existe et est valide
/// - `None` si le cache n'existe pas ou est expiré
///
/// # Cache Validation
///
/// Le cache est considéré invalide si :
/// - Il n'existe pas
/// - Son TTL est dépassé (configurable, défaut 7 jours)
/// - Sa version ne correspond pas à la version actuelle de l'algorithme
fn get_radiofrance_cached_stations(&self) -> Result<Option<CachedStationList>>;
/// Enregistre la liste des stations en cache
///
/// # Arguments
///
/// * `stations` - Liste des stations découvertes
fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()>;
/// Récupère le TTL du cache des stations (en secondes)
///
/// # Returns
///
/// Le TTL en secondes (default: 7 jours)
fn get_radiofrance_station_cache_ttl(&self) -> Result<u64>;
/// Définit le TTL du cache des stations (en secondes)
fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()>;
/// Vérifie si le cache des stations est valide
///
/// Raccourci pour `get_radiofrance_cached_stations()?.is_some()`
fn is_radiofrance_station_cache_valid(&self) -> bool;
/// Efface le cache des stations (force re-découverte)
fn clear_radiofrance_station_cache(&self) -> Result<()>;
// ========================================================================
// High-level helpers
// ========================================================================
/// Récupère les stations, en utilisant le cache si valide
///
/// Cette méthode est un helper qui :
/// 1. Vérifie le cache
/// 2. Si valide, retourne les stations du cache
/// 3. Si invalide, retourne None (l'appelant doit découvrir et mettre en cache)
///
/// # Example
///
/// ```no_run
/// # use pmoconfig::get_config;
/// # use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient};
/// # #[tokio::main]
/// # async fn main() -> anyhow::Result<()> {
/// let config = get_config();
/// let stations = if let Some(cached) = config.get_radiofrance_stations_cached()? {
/// cached
/// } else {
/// let client = RadioFranceClient::new().await?;
/// let discovered = client.discover_all_stations().await?;
/// config.set_radiofrance_cached_stations(&discovered)?;
/// discovered
/// };
/// # Ok(())
/// # }
/// ```
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>>;
}
impl RadioFranceConfigExt for Config {
fn get_radiofrance_enabled(&self) -> Result<bool> {
match self.get_value(&["sources", "radiofrance", "enabled"]) {
Ok(Value::Bool(b)) => Ok(b),
_ => {
// Default: enabled
self.set_radiofrance_enabled(true)?;
Ok(true)
}
}
}
fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()> {
self.set_value(&["sources", "radiofrance", "enabled"], Value::Bool(enabled))
}
fn get_radiofrance_cached_stations(&self) -> Result<Option<CachedStationList>> {
let ttl = self.get_radiofrance_station_cache_ttl()?;
match self.get_value(&["sources", "radiofrance", "station_cache"]) {
Ok(value) => {
// Try to deserialize the cached data
let cached: CachedStationList = serde_yaml::from_value(value)?;
// Check validity
if cached.is_valid(ttl) {
Ok(Some(cached))
} else {
// Cache expired or version mismatch
Ok(None)
}
}
Err(_) => Ok(None),
}
}
fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()> {
let cached = CachedStationList::new(stations.to_vec());
let value = serde_yaml::to_value(&cached)?;
self.set_value(&["sources", "radiofrance", "station_cache"], value)
}
fn get_radiofrance_station_cache_ttl(&self) -> Result<u64> {
match self.get_value(&["sources", "radiofrance", "station_cache_ttl_secs"]) {
Ok(Value::Number(n)) => {
if let Some(ttl) = n.as_u64() {
Ok(ttl)
} else {
// Invalid number, use default
self.set_radiofrance_station_cache_ttl(DEFAULT_STATION_CACHE_TTL_SECS)?;
Ok(DEFAULT_STATION_CACHE_TTL_SECS)
}
}
_ => {
// Not set, use default and persist
self.set_radiofrance_station_cache_ttl(DEFAULT_STATION_CACHE_TTL_SECS)?;
Ok(DEFAULT_STATION_CACHE_TTL_SECS)
}
}
}
fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> {
self.set_value(
&["sources", "radiofrance", "station_cache_ttl_secs"],
Value::Number(serde_yaml::Number::from(ttl_secs)),
)
}
fn is_radiofrance_station_cache_valid(&self) -> bool {
self.get_radiofrance_cached_stations()
.ok()
.flatten()
.is_some()
}
fn clear_radiofrance_station_cache(&self) -> Result<()> {
// Set to null to clear
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
}
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>> {
Ok(self
.get_radiofrance_cached_stations()?
.map(|cached| cached.stations))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_ttl() {
// 7 days in seconds
assert_eq!(DEFAULT_STATION_CACHE_TTL_SECS, 7 * 24 * 3600);
}
}

View File

@@ -0,0 +1,77 @@
//! Error types for the Radio France client
/// Result type alias for Radio France operations
pub type Result<T> = std::result::Result<T, Error>;
/// Errors that can occur when using the Radio France client
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// HTTP request failed
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
/// JSON parsing failed
#[error("JSON parsing failed: {0}")]
Json(#[from] serde_json::Error),
/// Invalid URL
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
/// IO error
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// API returned an error status
#[error("API error: {0}")]
ApiError(String),
/// Station not found
#[error("Station not found: {0}")]
StationNotFound(String),
/// No HiFi stream available for station
#[error("No HiFi stream found for station: {0}")]
NoHifiStream(String),
/// Scraping failed (HTML parsing error)
#[error("Scraping failed: {0}")]
ScrapingError(String),
/// Regex error
#[error("Regex error: {0}")]
RegexError(#[from] regex::Error),
/// Invalid station slug format
#[error("Invalid station slug: {0}")]
InvalidSlug(String),
/// Timeout error
#[error("Request timeout")]
Timeout,
/// Configuration error (from pmoconfig/anyhow)
#[error("Configuration error: {0}")]
Config(#[from] anyhow::Error),
/// Generic error
#[error("{0}")]
Other(String),
}
impl Error {
/// Create a generic error from a string
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
/// Create an API error
pub fn api_error(msg: impl Into<String>) -> Self {
Self::ApiError(msg.into())
}
/// Create a scraping error
pub fn scraping_error(msg: impl Into<String>) -> Self {
Self::ScrapingError(msg.into())
}
}

137
pmoradiofrance/src/lib.rs Normal file
View File

@@ -0,0 +1,137 @@
//! Radio France client library for PMOMusic
//!
//! This crate provides a Rust client for accessing Radio France's public APIs,
//! including live metadata, station discovery, and stream URLs.
//!
//! # Features
//!
//! - **Station Discovery**: Discover all Radio France stations dynamically
//! (main stations, webradios, and local France Bleu radios)
//! - **Live Metadata**: Get current show information, producers, visuals
//! - **Stream URLs**: Get HiFi stream URLs (AAC 192 kbps, HLS)
//! - **Polling Support**: Intelligent refresh delay based on API recommendations
//! - **Configuration Extension**: Cache station lists with configurable TTL
//!
//! # Supported Stations
//!
//! - **Main Stations**: France Inter, France Info, France Culture, France Musique,
//! FIP, Mouv', France Bleu
//! - **Webradios**: FIP Rock, FIP Jazz, France Musique Baroque, etc.
//! - **Local Radios**: ~40 France Bleu local stations
//!
//! # Example
//!
//! ```no_run
//! use pmoradiofrance::{RadioFranceClient, ImageSize};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = RadioFranceClient::new().await?;
//!
//! // Discover all stations
//! let stations = client.discover_all_stations().await?;
//! println!("Found {} stations", stations.len());
//!
//! // Get live metadata
//! let live = client.live_metadata("franceculture").await?;
//! println!("Now: {} - {}",
//! live.now.first_line.title_or_default(),
//! live.now.second_line.title_or_default()
//! );
//!
//! // Get HiFi stream URL
//! let stream_url = client.get_hifi_stream_url("franceculture").await?;
//! println!("Stream: {}", stream_url);
//!
//! Ok(())
//! }
//! ```
//!
//! # Configuration Extension
//!
//! When the `pmoconfig` feature is enabled, this crate provides a configuration
//! extension trait for caching station lists:
//!
//! ```no_run
//! use pmoconfig::get_config;
//! use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient};
//!
//! # #[tokio::main]
//! # async fn main() -> anyhow::Result<()> {
//! let config = get_config();
//!
//! // Check cached stations (default TTL: 7 days)
//! if let Some(stations) = config.get_radiofrance_stations_cached()? {
//! println!("Using {} cached stations", stations.len());
//! } else {
//! // Cache miss - need to discover
//! let client = RadioFranceClient::new().await?;
//! let stations = client.discover_all_stations().await?;
//! config.set_radiofrance_cached_stations(&stations)?;
//! }
//! # Ok(())
//! # }
//! ```
//!
//! # API Rate Limiting
//!
//! Radio France's APIs don't have documented rate limits, but the `delayToRefresh`
//! field in responses indicates the recommended polling interval. Always use
//! `RadioFranceClient::next_refresh_delay()` to respect this.
//!
//! # Audio Quality
//!
//! This client focuses on HiFi quality only:
//! - **AAC 192 kbps**: Primary format (best quality)
//! - **HLS**: Adaptive streaming fallback
//!
//! Lower quality formats (lofi, midfi) are not prioritized but are available
//! in the `StreamSource` list if needed.
pub mod client;
pub mod error;
pub mod models;
#[cfg(feature = "pmoconfig")]
pub mod config_ext;
#[cfg(feature = "pmoconfig")]
pub mod stateful_client;
#[cfg(feature = "playlist")]
pub mod playlist;
#[cfg(feature = "server")]
pub mod source;
#[cfg(feature = "server")]
pub mod pmoserver_ext;
#[cfg(feature = "server")]
pub mod pmoserver_impl;
#[cfg(feature = "server")]
pub mod api_rest;
// Re-exports
pub use client::{ClientBuilder, RadioFranceClient};
pub use error::{Error, Result};
pub use models::{
BroadcastType, CachedStationList, EmbedImage, ImageSize, Line, LiveResponse, LocalRadio, Media,
Release, ShowMetadata, Song, Station, StationType, StreamFormat, StreamSource, Visuals,
};
#[cfg(feature = "pmoconfig")]
pub use config_ext::RadioFranceConfigExt;
#[cfg(feature = "pmoconfig")]
pub use stateful_client::RadioFranceStatefulClient;
#[cfg(feature = "playlist")]
pub use playlist::{StationGroup, StationGroups, StationPlaylist};
#[cfg(feature = "server")]
pub use source::RadioFranceSource;
#[cfg(feature = "server")]
pub use pmoserver_ext::{RadioFranceExt, RadioFranceState};

View File

@@ -0,0 +1,518 @@
//! Data models for Radio France API responses
//!
//! This module contains all the structures needed to deserialize
//! responses from Radio France's public APIs.
use serde::{Deserialize, Serialize};
// ============================================================================
// Station Discovery Models
// ============================================================================
/// A discovered Radio France station
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Station {
/// Unique slug identifier (e.g., "franceculture", "fip_rock")
pub slug: String,
/// Human-readable name (e.g., "France Culture", "FIP Rock")
pub name: String,
/// Type of station
pub station_type: StationType,
}
/// Type of Radio France station
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StationType {
/// Main station (France Inter, France Culture, FIP, etc.)
Main,
/// Webradio variant of a main station
Webradio {
/// Parent station slug (e.g., "fip" for "fip_rock")
parent_station: String,
},
/// Local France Bleu radio
LocalRadio {
/// Region name
region: String,
/// Internal Radio France ID
id: u32,
},
}
impl Station {
/// Create a new main station
pub fn main(slug: impl Into<String>, name: impl Into<String>) -> Self {
Self {
slug: slug.into(),
name: name.into(),
station_type: StationType::Main,
}
}
/// Create a new webradio station
pub fn webradio(
slug: impl Into<String>,
name: impl Into<String>,
parent: impl Into<String>,
) -> Self {
Self {
slug: slug.into(),
name: name.into(),
station_type: StationType::Webradio {
parent_station: parent.into(),
},
}
}
/// Create a new local radio station
pub fn local_radio(
slug: impl Into<String>,
name: impl Into<String>,
region: impl Into<String>,
id: u32,
) -> Self {
Self {
slug: slug.into(),
name: name.into(),
station_type: StationType::LocalRadio {
region: region.into(),
id,
},
}
}
/// Check if this is a main station
pub fn is_main(&self) -> bool {
matches!(self.station_type, StationType::Main)
}
/// Check if this is a webradio
pub fn is_webradio(&self) -> bool {
matches!(self.station_type, StationType::Webradio { .. })
}
/// Check if this is a local radio
pub fn is_local_radio(&self) -> bool {
matches!(self.station_type, StationType::LocalRadio { .. })
}
/// Get the parent station for webradios, or the station itself for main stations
pub fn base_station(&self) -> &str {
match &self.station_type {
StationType::Webradio { parent_station } => parent_station,
_ => &self.slug,
}
}
}
// ============================================================================
// Live API Response Models
// ============================================================================
/// Response from the /api/live? endpoint
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveResponse {
/// Station name (slug)
pub station_name: String,
/// Recommended delay before next refresh (milliseconds)
pub delay_to_refresh: u64,
/// Whether station has been migrated to new system
#[serde(default)]
pub migrated: bool,
/// Current show/track metadata
pub now: ShowMetadata,
/// Next show/track metadata (if available)
pub next: Option<ShowMetadata>,
}
impl LiveResponse {
/// Get local radios (France Bleu only) - convenience accessor
pub fn local_radios(&self) -> Option<&Vec<LocalRadio>> {
self.now.local_radios.as_ref()
}
}
/// Metadata for a show or track currently playing
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowMetadata {
/// Whether to display music program info
#[serde(default)]
pub print_prog_music: bool,
/// Start time (Unix timestamp)
pub start_time: Option<u64>,
/// End time (Unix timestamp)
pub end_time: Option<u64>,
/// Producer name
pub producer: Option<String>,
/// First line (usually show title)
#[serde(default)]
pub first_line: Line,
/// Second line (usually episode/track title)
#[serde(default)]
pub second_line: Line,
/// Third line (optional subtitle)
pub third_line: Option<Line>,
/// Show description/intro
pub intro: Option<String>,
/// React availability flag
#[serde(default)]
pub react_available: bool,
/// Background visual
pub visual_background: Option<EmbedImage>,
/// Song info (for music stations like FIP, France Musique)
pub song: Option<Song>,
/// Available media streams
#[serde(default)]
pub media: Media,
/// Visual assets (card, player)
pub visuals: Option<Visuals>,
/// Local radios list (France Bleu only)
#[serde(default)]
pub local_radios: Option<Vec<LocalRadio>>,
}
/// A line of text with optional link
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Line {
/// Text content
pub title: Option<String>,
/// UUID of the referenced object
pub id: Option<String>,
/// URL path to the referenced page
pub path: Option<String>,
}
impl Line {
/// Get the title or an empty string
pub fn title_or_default(&self) -> &str {
self.title.as_deref().unwrap_or("")
}
}
/// Song information (for music stations)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Song {
/// Song UUID
pub id: String,
/// Release year
pub year: Option<u32>,
/// Artist names
#[serde(default)]
pub interpreters: Vec<String>,
/// Album/release information
#[serde(default)]
pub release: Release,
}
impl Song {
/// Get artists as a comma-separated string
pub fn artists_display(&self) -> String {
self.interpreters.join(", ")
}
}
/// Album/release information
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Release {
/// Record label
pub label: Option<String>,
/// Album title
pub title: Option<String>,
/// Catalog reference
pub reference: Option<String>,
}
/// Available media streams
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Media {
/// List of available stream sources
#[serde(default)]
pub sources: Vec<StreamSource>,
}
impl Media {
/// Find the best HiFi stream (AAC 192 kbps or HLS)
pub fn best_hifi_stream(&self) -> Option<&StreamSource> {
// Priority: AAC 192 kbps > HLS
self.sources
.iter()
.find(|s| {
s.format == StreamFormat::Aac
&& s.broadcast_type == BroadcastType::Live
&& s.bitrate == 192
})
.or_else(|| {
self.sources.iter().find(|s| {
s.format == StreamFormat::Hls && s.broadcast_type == BroadcastType::Live
})
})
}
/// Find a stream by format and broadcast type
pub fn find_stream(
&self,
format: StreamFormat,
broadcast_type: BroadcastType,
) -> Option<&StreamSource> {
self.sources
.iter()
.find(|s| s.format == format && s.broadcast_type == broadcast_type)
}
/// Get all live streams
pub fn live_streams(&self) -> impl Iterator<Item = &StreamSource> {
self.sources
.iter()
.filter(|s| s.broadcast_type == BroadcastType::Live)
}
}
/// A stream source with URL and format info
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamSource {
/// Stream URL
pub url: String,
/// Broadcast type (live or timeshift)
pub broadcast_type: BroadcastType,
/// Stream format
pub format: StreamFormat,
/// Bitrate in kbps (0 for HLS adaptive)
pub bitrate: u32,
}
/// Type of broadcast
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BroadcastType {
/// Live stream
Live,
/// Timeshift (replay) stream
Timeshift,
}
/// Stream format
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StreamFormat {
/// MP3 format
Mp3,
/// AAC format
Aac,
/// HLS adaptive streaming
Hls,
}
impl StreamFormat {
/// Get the MIME type for this format
pub fn mime_type(&self) -> &'static str {
match self {
StreamFormat::Mp3 => "audio/mpeg",
StreamFormat::Aac => "audio/aac",
StreamFormat::Hls => "application/vnd.apple.mpegurl",
}
}
}
/// An embedded image
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedImage {
/// Model type (usually "EmbedImage")
#[serde(default)]
pub model: String,
/// Image URL or path
pub src: String,
/// Image width
pub width: Option<u32>,
/// Image height
pub height: Option<u32>,
/// Dominant color (hex)
pub dominant: Option<String>,
/// Copyright notice
pub copyright: Option<String>,
}
impl EmbedImage {
/// Extract the UUID from the image URL
///
/// Pikapi URLs are in format: https://www.radiofrance.fr/pikapi/images/{uuid}[/size]
pub fn extract_uuid(&self) -> Option<String> {
let re = regex::Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
re.captures(&self.src)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
}
}
/// Visual assets for different display contexts
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Visuals {
/// Card-sized image
pub card: Option<EmbedImage>,
/// Player-sized image
pub player: Option<EmbedImage>,
}
/// A local France Bleu radio station
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalRadio {
/// Internal ID
pub id: u32,
/// Display title (e.g., "ICI Alsace")
pub title: String,
/// Technical name (e.g., "francebleu_alsace")
pub name: String,
/// Whether the station is currently on air
#[serde(default)]
pub is_on_air: bool,
}
// ============================================================================
// Image Size Helpers
// ============================================================================
/// Available image sizes from Pikapi
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageSize {
/// 88x88 pixels
Tiny,
/// 200x200 pixels
Small,
/// 420x720 pixels (portrait)
Medium,
/// 560x960 pixels (portrait)
Large,
/// 1200x680 pixels (landscape)
XLarge,
/// Original size
Raw,
}
impl ImageSize {
/// Get the size string for Pikapi URLs
pub fn as_str(&self) -> &'static str {
match self {
ImageSize::Tiny => "88x88",
ImageSize::Small => "200x200",
ImageSize::Medium => "420x720",
ImageSize::Large => "560x960",
ImageSize::XLarge => "1200x680",
ImageSize::Raw => "raw",
}
}
/// Build a Pikapi image URL
pub fn build_url(&self, uuid: &str) -> String {
format!(
"https://www.radiofrance.fr/pikapi/images/{}/{}",
uuid,
self.as_str()
)
}
}
// ============================================================================
// Cached Station List
// ============================================================================
/// Cached list of discovered stations with timestamp
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedStationList {
/// List of discovered stations
pub stations: Vec<Station>,
/// Unix timestamp when the list was last updated
pub last_updated: u64,
/// Version of the discovery algorithm (for invalidation)
pub version: u32,
}
impl CachedStationList {
/// Current version of the discovery algorithm
pub const CURRENT_VERSION: u32 = 1;
/// Default TTL for station list cache (7 days in seconds)
pub const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
/// Create a new cached station list
pub fn new(stations: Vec<Station>) -> Self {
Self {
stations,
last_updated: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
version: Self::CURRENT_VERSION,
}
}
/// Check if the cache is still valid
pub fn is_valid(&self, ttl_secs: u64) -> bool {
if self.version != Self::CURRENT_VERSION {
return false;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(self.last_updated) < ttl_secs
}
/// Check if cache is valid with default TTL
pub fn is_valid_default(&self) -> bool {
self.is_valid(Self::DEFAULT_TTL_SECS)
}
/// Get the age of the cache in seconds
pub fn age_secs(&self) -> u64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(self.last_updated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_station_creation() {
let main = Station::main("franceculture", "France Culture");
assert!(main.is_main());
assert_eq!(main.base_station(), "franceculture");
let webradio = Station::webradio("fip_rock", "FIP Rock", "fip");
assert!(webradio.is_webradio());
assert_eq!(webradio.base_station(), "fip");
let local = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 12);
assert!(local.is_local_radio());
}
#[test]
fn test_image_size() {
let uuid = "436430f7-5b2b-43f2-9f3c-28f2ad6cae39";
let url = ImageSize::Small.build_url(uuid);
assert_eq!(
url,
"https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200"
);
}
#[test]
fn test_cached_station_list_validity() {
let stations = vec![Station::main("fip", "FIP")];
let cached = CachedStationList::new(stations);
assert!(cached.is_valid(3600)); // Valid for 1 hour
assert!(cached.is_valid_default()); // Valid with default TTL
}
}

View File

@@ -0,0 +1,806 @@
//! Structures et helpers pour la construction de playlists UPnP Radio France
//!
//! Ce module fournit les structures nécessaires pour organiser les stations
//! Radio France en groupes hiérarchiques et construire des playlists UPnP
//! avec métadonnées volatiles.
//!
//! # Architecture
//!
//! - `StationGroups` : Organisation hiérarchique de toutes les stations
//! - `StationGroup` : Groupe station principale + webradios associées
//! - `StationPlaylist` : Playlist UPnP volatile pour une station
//!
//! # Exemple
//!
//! ```rust,ignore
//! use pmoradiofrance::playlist::{StationGroups, StationPlaylist};
//!
//! // Organiser les stations en groupes
//! let groups = StationGroups::from_stations(stations);
//!
//! // Construire une playlist pour une station
//! let playlist = StationPlaylist::from_live_metadata(
//! station,
//! metadata,
//! &cover_cache,
//! server_base_url,
//! ).await?;
//! ```
use crate::error::Result;
use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat};
use pmodidl::{Item, Resource};
use serde::{Deserialize, Serialize};
#[cfg(feature = "cache")]
use pmocache::cache_trait::FileCache;
#[cfg(feature = "cache")]
use pmocovers::Cache as CoverCache;
#[cfg(feature = "cache")]
use std::sync::Arc;
// ============================================================================
// Groupes de stations
// ============================================================================
/// Groupes de stations organisés hiérarchiquement
///
/// Cette structure organise les stations Radio France en trois catégories :
/// - `standalone` : Stations sans webradios (France Culture, France Inter, France Info, Mouv')
/// - `with_webradios` : Groupes avec station principale + webradios (FIP, France Musique)
/// - `local_radios` : Toutes les radios ICI (ex-France Bleu)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StationGroups {
/// Stations sans webradios associées
pub standalone: Vec<Station>,
/// Groupes station principale + webradios
pub with_webradios: Vec<StationGroup>,
/// Radios locales ICI (ex-France Bleu)
pub local_radios: Vec<Station>,
}
/// Groupe station principale + webradios associées
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StationGroup {
/// Station principale (ex: FIP)
pub main: Station,
/// Webradios associées (ex: FIP Rock, FIP Jazz, ...)
pub webradios: Vec<Station>,
}
impl StationGroups {
/// Organise une liste de stations en groupes hiérarchiques
///
/// # Logique de regroupement
///
/// 1. Les stations locales (France Bleu/ICI) sont regroupées dans `local_radios`
/// 2. Les webradios sont associées à leur station parente
/// 3. Les stations principales sans webradios vont dans `standalone`
/// 4. Les stations avec au moins une webradio vont dans `with_webradios`
pub fn from_stations(stations: Vec<Station>) -> Self {
use std::collections::HashMap;
let mut standalone = Vec::new();
let mut local_radios = Vec::new();
let mut main_stations: HashMap<String, Station> = HashMap::new();
let mut webradios_by_parent: HashMap<String, Vec<Station>> = HashMap::new();
// Premier passage : trier par type
for station in stations {
match &station.station_type {
StationType::Main => {
// Filtrer France Bleu : ce n'est pas une vraie radio mais le nom générique
// pour toutes les radios locales ICI (ex-France Bleu)
if station.slug != "francebleu" {
main_stations.insert(station.slug.clone(), station);
}
}
StationType::Webradio { parent_station } => {
webradios_by_parent
.entry(parent_station.clone())
.or_default()
.push(station);
}
StationType::LocalRadio { .. } => {
local_radios.push(station);
}
}
}
// Deuxième passage : construire les groupes
let mut with_webradios = Vec::new();
for (slug, main) in main_stations {
if let Some(webradios) = webradios_by_parent.remove(&slug) {
// Cette station a des webradios
with_webradios.push(StationGroup { main, webradios });
} else {
// Station standalone
standalone.push(main);
}
}
// Trier pour un affichage cohérent
standalone.sort_by(|a, b| a.name.cmp(&b.name));
local_radios.sort_by(|a, b| a.name.cmp(&b.name));
with_webradios.sort_by(|a, b| a.main.name.cmp(&b.main.name));
for group in &mut with_webradios {
group.webradios.sort_by(|a, b| a.name.cmp(&b.name));
}
Self {
standalone,
with_webradios,
local_radios,
}
}
/// Retourne toutes les stations dans un ordre de navigation logique
///
/// Ordre : standalone, puis groupes (main + webradios), puis locales
pub fn all_stations(&self) -> impl Iterator<Item = &Station> {
self.standalone
.iter()
.chain(
self.with_webradios
.iter()
.flat_map(|g| std::iter::once(&g.main).chain(g.webradios.iter())),
)
.chain(self.local_radios.iter())
}
/// Nombre total de stations
pub fn total_count(&self) -> usize {
self.standalone.len()
+ self
.with_webradios
.iter()
.map(|g| 1 + g.webradios.len())
.sum::<usize>()
+ self.local_radios.len()
}
}
impl StationGroup {
/// Retourne toutes les stations du groupe (main + webradios)
pub fn all_stations(&self) -> impl Iterator<Item = &Station> {
std::iter::once(&self.main).chain(self.webradios.iter())
}
/// Nombre de stations dans le groupe
pub fn count(&self) -> usize {
1 + self.webradios.len()
}
}
// ============================================================================
// Playlist UPnP pour une station
// ============================================================================
/// Playlist UPnP volatile pour une station Radio France
///
/// Contient UN SEUL item représentant le stream live.
/// Les métadonnées de l'item changent au fil du temps (émissions, morceaux)
/// mais l'URL du stream reste identique.
///
/// # Volatilité
///
/// - L'URL du stream ne change JAMAIS
/// - Le titre, artiste, album changent toutes les 2-5 minutes
/// - La cover change avec chaque nouvelle émission/morceau
#[derive(Debug, Clone)]
pub struct StationPlaylist {
/// ID de la playlist (ex: "radiofrance:franceculture")
pub id: String,
/// Station source
pub station: Station,
/// Item UPnP unique représentant le stream
pub stream_item: Item,
}
impl StationPlaylist {
/// Construit une playlist depuis les métadonnées live
///
/// # Arguments
///
/// * `station` - Station Radio France
/// * `metadata` - Métadonnées live de l'API
/// * `cover_cache` - Cache des covers (optionnel)
/// * `server_base_url` - URL de base du serveur pour les covers cachées
///
/// # Mapping des métadonnées
///
/// Pour **radios parlées** (France Culture, France Inter, France Info) :
/// - `title` = émission + titre du jour
/// - `artist` = producteur
/// - `album` = nom de l'émission
///
/// Pour **radios musicales** (FIP, France Musique) :
/// - Si morceau en cours : titre, artiste, album du morceau
/// - Sinon : fallback sur le mapping radio parlée
#[cfg(feature = "cache")]
pub async fn from_live_metadata(
station: Station,
metadata: &LiveResponse,
cover_cache: Option<&Arc<CoverCache>>,
server_base_url: Option<&str>,
) -> Result<Self> {
let id = format!("radiofrance:{}", station.slug);
let stream_item =
Self::build_item_from_metadata(&station, metadata, cover_cache, server_base_url)
.await?;
Ok(Self {
id,
station,
stream_item,
})
}
/// Construit une playlist sans cache de covers
pub fn from_live_metadata_no_cache(
station: Station,
metadata: &LiveResponse,
server_base_url: Option<&str>,
) -> Result<Self> {
let id = format!("radiofrance:{}", station.slug);
let stream_item = Self::build_item_from_metadata_sync(&station, metadata, server_base_url)?;
Ok(Self {
id,
station,
stream_item,
})
}
/// Met à jour les métadonnées volatiles de l'item
///
/// Met à jour uniquement les champs volatiles :
/// - title, artist, album (depuis nouvelles métadonnées)
/// - album_art / album_art_pk (si nouvelle cover)
///
/// L'URL du stream (resource.url) ne change JAMAIS.
#[cfg(feature = "cache")]
pub async fn update_metadata(
&mut self,
metadata: &LiveResponse,
cover_cache: Option<&Arc<CoverCache>>,
server_base_url: Option<&str>,
) -> Result<()> {
// Reconstruire l'item avec les nouvelles métadonnées
// mais conserver l'URL du stream
let old_url = self
.stream_item
.resources
.first()
.map(|r| r.url.clone())
.unwrap_or_default();
let mut new_item =
Self::build_item_from_metadata(&self.station, metadata, cover_cache, server_base_url)
.await?;
// S'assurer que l'URL du stream n'a pas changé
if let Some(res) = new_item.resources.first_mut() {
if !old_url.is_empty() {
res.url = old_url;
}
}
self.stream_item = new_item;
Ok(())
}
/// Met à jour les métadonnées sans cache
pub fn update_metadata_no_cache(
&mut self,
metadata: &LiveResponse,
server_base_url: Option<&str>,
) -> Result<()> {
let old_url = self
.stream_item
.resources
.first()
.map(|r| r.url.clone())
.unwrap_or_default();
let mut new_item =
Self::build_item_from_metadata_sync(&self.station, metadata, server_base_url)?;
if let Some(res) = new_item.resources.first_mut() {
if !old_url.is_empty() {
res.url = old_url;
}
}
self.stream_item = new_item;
Ok(())
}
/// Construit un Item UPnP depuis les métadonnées live (avec cache)
#[cfg(feature = "cache")]
async fn build_item_from_metadata(
station: &Station,
metadata: &LiveResponse,
cover_cache: Option<&Arc<CoverCache>>,
server_base_url: Option<&str>,
) -> Result<Item> {
let (title, creator, artist, album, genre, class) =
Self::extract_metadata_fields(station, metadata);
// Gestion de la cover
let (album_art, album_art_pk) = if let Some(cache) = cover_cache {
Self::cache_cover(metadata, cache, server_base_url).await
} else {
Self::extract_cover_url(metadata, server_base_url)
};
#[cfg(feature = "logging")]
tracing::debug!(
"Cover for {}: album_art={:?}, album_art_pk={:?}",
station.slug,
album_art,
album_art_pk
);
// Construction de la ressource (stream)
let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url);
let item = Item {
id: format!("radiofrance:{}:stream", station.slug),
parent_id: format!("radiofrance:{}", station.slug),
restricted: Some("1".to_string()),
title,
creator,
class,
artist,
album,
genre,
album_art,
album_art_pk,
date: None,
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
};
#[cfg(feature = "logging")]
if let Some(res) = item.resources.first() {
tracing::info!(
"Item built for {}: title='{}', duration={:?}",
station.slug,
item.title,
res.duration
);
}
Ok(item)
}
/// Construit un Item UPnP depuis les métadonnées live (sans cache async)
fn build_item_from_metadata_sync(
station: &Station,
metadata: &LiveResponse,
server_base_url: Option<&str>,
) -> Result<Item> {
let (title, creator, artist, album, genre, class) =
Self::extract_metadata_fields(station, metadata);
let (album_art, album_art_pk) = Self::extract_cover_url(metadata, server_base_url);
let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url);
Ok(Item {
id: format!("radiofrance:{}:stream", station.slug),
parent_id: format!("radiofrance:{}", station.slug),
restricted: Some("1".to_string()),
title,
creator,
class,
artist,
album,
genre,
album_art,
album_art_pk,
date: None,
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
})
}
/// Extrait les champs de métadonnées selon le type de radio
fn extract_metadata_fields(
station: &Station,
metadata: &LiveResponse,
) -> (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
String,
) {
let now = &metadata.now;
// Détecter si c'est une radio musicale avec un morceau en cours
if let Some(ref song) = now.song {
// Radio musicale avec morceau
let title = now.first_line.title_or_default().to_string();
let song_artist = if song.interpreters.is_empty() {
None
} else {
Some(song.artists_display())
};
// Artist affiché = "Station - Artiste du morceau" pour identifier la radio
// Éviter la duplication si l'artiste est égal au nom de la station
let artist = if let Some(ref art) = song_artist {
if art != &station.name && art != station.display_name() {
Some(format!("{} - {}", station.display_name(), art))
} else {
Some(station.display_name().to_string())
}
} else {
Some(station.display_name().to_string())
};
let album = song.release.title.clone();
let creator = song_artist; // Creator reste l'artiste du morceau pour compatibilité
let genre = Some("Music".to_string());
let class = "object.item.audioItem.musicTrack".to_string();
(title, creator, artist, album, genre, class)
} else {
// Radio parlée ou segment talk sur radio musicale
let first = now.first_line.title_or_default();
let second = now.second_line.title_or_default();
// Construire le titre en évitant les duplications
let title = if !first.is_empty() && !second.is_empty() {
// Si first contient déjà second, utiliser seulement first
if first.contains(second) {
first.to_string()
} else {
format!("{}{}", first, second)
}
} else if !first.is_empty() {
first.to_string()
} else {
station.display_name().to_string()
};
// Artist/Creator = "{Station} - {Subtitle}"
// Éviter la duplication si subtitle == nom de la station
let artist =
if !second.is_empty() && second != station.name && second != station.display_name()
{
Some(format!("{} - {}", station.name, second))
} else {
Some(station.name.clone())
};
let creator = artist.clone();
// Album = nom de l'émission principale
let album = if !first.is_empty() {
Some(first.to_string())
} else {
Some(station.name.clone())
};
let genre = Some("Talk Radio".to_string());
let class = "object.item.audioItem.audioBroadcast".to_string();
(title, creator, artist, album, genre, class)
}
}
/// Extrait l'URL de cover depuis les métadonnées (sans cache)
fn extract_cover_url(
metadata: &LiveResponse,
server_base_url: Option<&str>,
) -> (Option<String>, Option<String>) {
// Priorité : visual_background > visuals.card > visuals.player > logo par défaut
// 1. visual_background
if let Some(ref visual) = metadata.now.visual_background {
if let Some(uuid) = visual.extract_uuid() {
let url = ImageSize::Large.build_url(&uuid);
return (Some(url), None);
}
}
// 2. visuals.card
if let Some(ref visuals) = metadata.now.visuals {
if let Some(ref card) = visuals.card {
if let Some(uuid) = card.extract_uuid() {
let url = ImageSize::Large.build_url(&uuid);
return (Some(url), None);
}
}
// 3. visuals.player
if let Some(ref player) = visuals.player {
if let Some(uuid) = player.extract_uuid() {
let url = ImageSize::Large.build_url(&uuid);
return (Some(url), None);
}
}
}
// Fallback sur le logo par défaut via l'API REST
if let Some(base) = server_base_url {
let logo_url = format!(
"{}/api/radiofrance/default-logo",
base.trim_end_matches('/')
);
#[cfg(feature = "logging")]
tracing::debug!("Using default Radio France logo: {}", logo_url);
return (Some(logo_url), None);
}
// Pas de cover trouvée et pas de serveur configuré
#[cfg(feature = "logging")]
tracing::warn!("No cover found and no server_base_url configured");
(None, None)
}
/// Cache la cover et retourne (url_publique, pk)
#[cfg(feature = "cache")]
async fn cache_cover(
metadata: &LiveResponse,
cache: &Arc<CoverCache>,
server_base_url: Option<&str>,
) -> (Option<String>, Option<String>) {
// Extraire l'UUID de la cover (priorité : visual_background > visuals.card > visuals.player)
let uuid = metadata
.now
.visual_background
.as_ref()
.and_then(|v| v.extract_uuid())
.or_else(|| {
metadata.now.visuals.as_ref().and_then(|visuals| {
visuals
.card
.as_ref()
.and_then(|c| c.extract_uuid())
.or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid()))
})
});
let uuid = match uuid {
Some(u) => u,
None => {
// Fallback sur le logo par défaut via l'API REST
if let Some(base) = server_base_url {
let logo_url = format!(
"{}/api/radiofrance/default-logo",
base.trim_end_matches('/')
);
return (Some(logo_url), None);
}
return (None, None);
}
};
// 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) => {
// Construire l'URL publique si server_base_url est fourni
let public_url = server_base_url.map(|base| {
format!(
"{}{}",
base.trim_end_matches('/'),
cache.route_for(&pk, None)
)
});
(public_url.or(Some(cover_url)), Some(pk))
}
Err(e) => {
tracing::warn!("Failed to cache Radio France cover: {}", e);
// Fallback sur le logo par défaut via l'API REST en cas d'erreur
if let Some(base) = server_base_url {
let logo_url = format!(
"{}/api/radiofrance/default-logo",
base.trim_end_matches('/')
);
(Some(logo_url), None)
} else {
(Some(cover_url), None)
}
}
}
}
/// Construit la ressource stream avec URL du proxy
fn build_stream_resource(
metadata: &LiveResponse,
station_slug: &str,
server_base_url: Option<&str>,
) -> Resource {
// Calculer la durée restante (maintenant -> end_time)
// Cela permet au curseur de progresser de 0 jusqu'à la fin de l'émission
let duration = if let Some(end) = metadata.now.end_time {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
#[cfg(feature = "logging")]
tracing::debug!(
"Calculating duration: end_time={}, now={}, diff={}",
end,
now,
end.saturating_sub(now)
);
if end > now {
let duration_secs = end - now;
// Format UPnP: H:MM:SS ou H:MM:SS.F
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
let duration_str = format!("{}:{:02}:{:02}", hours, minutes, seconds);
#[cfg(feature = "logging")]
tracing::info!("Track duration set to: {}", duration_str);
Some(duration_str)
} else {
#[cfg(feature = "logging")]
tracing::warn!("end_time ({}) is in the past (now={})", end, now);
None
}
} else {
#[cfg(feature = "logging")]
tracing::debug!("No end_time available in metadata");
None
};
// Construire l'URL du proxy ou fallback direct
let url = if let Some(base_url) = server_base_url {
// Utiliser le proxy PMOMusic pour détecter quand le stream est actif
format!("{}/api/radiofrance/{}/stream", base_url, station_slug)
} else {
// Fallback : utiliser l'URL directe si pas de base_url
metadata
.now
.media
.best_hifi_stream()
.map(|s| s.url.clone())
.unwrap_or_default()
};
// Déterminer le protocol_info et caractéristiques audio
let best_stream = metadata.now.media.best_hifi_stream();
let (protocol_info, sample_frequency, nr_audio_channels) = match best_stream {
Some(stream) => {
let protocol_info = match stream.format {
StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(),
StreamFormat::Hls => "http-get:*:application/vnd.apple.mpegurl:*".to_string(),
StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(),
};
let sample_freq = match stream.format {
StreamFormat::Aac => Some("48000".to_string()),
_ => None,
};
let channels = match stream.format {
StreamFormat::Aac | StreamFormat::Mp3 => Some("2".to_string()),
_ => None,
};
(protocol_info, sample_freq, channels)
}
None => {
// Fallback : pas de stream trouvé
("http-get:*:audio/aac:*".to_string(), None, None)
}
};
let resource = Resource {
protocol_info,
bits_per_sample: None,
sample_frequency,
nr_audio_channels,
duration: duration.clone(), // Durée calculée depuis start_time/end_time si disponible
url,
};
#[cfg(feature = "logging")]
tracing::info!(
"Built resource with duration: {:?}, url: {}",
resource.duration,
if resource.url.is_empty() {
"<empty>"
} else {
&resource.url[..resource.url.len().min(50)]
}
);
resource
}
/// Retourne l'URL du stream
pub fn stream_url(&self) -> Option<&str> {
self.stream_item.resources.first().map(|r| r.url.as_str())
}
/// Retourne le titre actuel
pub fn current_title(&self) -> &str {
&self.stream_item.title
}
/// Retourne l'artiste actuel
pub fn current_artist(&self) -> Option<&str> {
self.stream_item.artist.as_deref()
}
}
// ============================================================================
// Helpers pour le renommage France Bleu → ICI
// ============================================================================
impl Station {
/// Retourne le nom d'affichage avec renommage France Bleu → ICI
///
/// Les slugs sont conservés (francebleu_alsace) mais l'affichage
/// utilise "ICI" (ICI Alsace).
pub fn display_name(&self) -> &str {
// Le renommage est déjà fait lors de la découverte via l'API
// qui retourne directement "ICI Alsace" etc.
&self.name
}
/// Vérifie si c'est une radio ICI (ex-France Bleu locale)
pub fn is_ici_radio(&self) -> bool {
self.name.starts_with("ICI ") || self.slug.starts_with("francebleu_")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_station_groups_organization() {
let stations = vec![
Station::main("franceculture", "France Culture"),
Station::main("fip", "FIP"),
Station::webradio("fip_rock", "FIP Rock", "fip"),
Station::webradio("fip_jazz", "FIP Jazz", "fip"),
Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1),
];
let groups = StationGroups::from_stations(stations);
assert_eq!(groups.standalone.len(), 1);
assert_eq!(groups.standalone[0].slug, "franceculture");
assert_eq!(groups.with_webradios.len(), 1);
assert_eq!(groups.with_webradios[0].main.slug, "fip");
assert_eq!(groups.with_webradios[0].webradios.len(), 2);
assert_eq!(groups.local_radios.len(), 1);
assert_eq!(groups.local_radios[0].slug, "francebleu_alsace");
assert_eq!(groups.total_count(), 5);
}
#[test]
fn test_station_display_name() {
let station = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1);
assert_eq!(station.display_name(), "ICI Alsace");
assert!(station.is_ici_radio());
let main = Station::main("franceculture", "France Culture");
assert_eq!(main.display_name(), "France Culture");
assert!(!main.is_ici_radio());
}
}

View File

@@ -0,0 +1,103 @@
//! Extension pmoserver pour Radio France
//!
//! Ce module fournit un trait d'extension pour ajouter l'API Radio France
//! à un serveur pmoserver.
use anyhow::Result;
use std::sync::Arc;
use crate::stateful_client::RadioFranceStatefulClient;
/// État partagé pour les handlers Radio France
#[derive(Clone)]
pub struct RadioFranceState {
pub client: Arc<RadioFranceStatefulClient>,
pub source: Option<Arc<crate::source::RadioFranceSource>>,
}
impl RadioFranceState {
pub fn new(client: RadioFranceStatefulClient) -> Self {
Self {
client: Arc::new(client),
source: None,
}
}
pub fn with_source(mut self, source: Arc<crate::source::RadioFranceSource>) -> Self {
self.source = Some(source);
self
}
}
/// Trait pour étendre pmoserver avec les fonctionnalités Radio France
///
/// Ce trait permet à `pmoradiofrance` d'ajouter des méthodes d'extension sur
/// `pmoserver::Server` sans que pmoserver dépende de pmoradiofrance.
///
/// # Architecture
///
/// Similaire au pattern utilisé par `pmoqobuz` avec `QobuzServerExt`, ce trait permet
/// une extension propre et découplée :
///
/// - `pmoserver` définit un serveur HTTP générique
/// - `pmoradiofrance` étend ce serveur avec les fonctionnalités Radio France via ce trait
/// - Le serveur n'a pas besoin de connaître `pmoradiofrance`
///
/// # Exemple
///
/// ```rust,no_run
/// use pmoradiofrance::RadioFranceExt;
/// use pmoserver::ServerBuilder;
///
/// #[tokio::main]
/// async fn main() -> anyhow::Result<()> {
/// let mut server = ServerBuilder::new_configured().build();
///
/// // Initialise le client Radio France
/// server.init_radiofrance().await?;
///
/// server.start().await;
/// server.wait().await;
/// Ok(())
/// }
/// ```
pub trait RadioFranceExt {
/// Initialise l'extension Radio France et enregistre les routes HTTP
///
/// Cette méthode :
/// - Crée un client stateful Radio France
/// - Configure les routes API pour les stations et métadonnées
/// - Configure le proxy streaming pour les flux AAC
///
/// # Returns
/// État partagé de Radio France
///
/// # Routes enregistrées
///
/// - `GET /api/radiofrance/stations` - Liste groupée des stations
/// - `GET /api/radiofrance/:slug/metadata` - Métadonnées live d'une station
/// - `GET /api/radiofrance/:slug/stream` - Proxy du flux AAC
///
/// # Exemple
/// ```ignore
/// use pmoserver::ServerBuilder;
/// use pmoradiofrance::RadioFranceExt;
///
/// let mut server = ServerBuilder::new_configured().build();
/// server.init_radiofrance().await?;
/// ```
async fn init_radiofrance(&mut self) -> Result<Arc<RadioFranceState>>;
/// Initialise l'extension Radio France avec une source existante
///
/// Cette méthode est similaire à `init_radiofrance()` mais utilise une source
/// déjà créée et enregistrée, permettant de partager la même instance entre
/// le MediaServer UPnP et les routes API REST.
async fn init_radiofrance_with_source(
&mut self,
source: Arc<crate::source::RadioFranceSource>,
) -> Result<Arc<RadioFranceState>>;
}
// L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs)
// pour éviter les dépendances circulaires

View File

@@ -0,0 +1,84 @@
//! Implémentation du trait RadioFranceExt pour pmoserver::Server
//!
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités du client Radio France
//! en implémentant le trait [`RadioFranceExt`](crate::RadioFranceExt).
//!
//! ## Architecture
//!
//! `pmoradiofrance` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoradiofrance`.
//! C'est le pattern d'extension : `pmoradiofrance` ajoute des fonctionnalités à un type
//! externe via un trait, similaire au pattern utilisé par `pmoqobuz` pour `QobuzServerExt`.
//!
//! ## Exemple d'utilisation
//!
//! ```rust,no_run
//! use pmoradiofrance::RadioFranceExt;
//! use pmoserver::ServerBuilder;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new_configured().build();
//!
//! // Le trait RadioFranceExt est automatiquement disponible
//! let state = server.init_radiofrance().await?;
//!
//! server.start().await;
//! # Ok(())
//! # }
//! ```
use crate::api_rest::create_router;
use crate::pmoserver_ext::{RadioFranceExt, RadioFranceState};
use crate::stateful_client::RadioFranceStatefulClient;
use anyhow::Result;
use pmoserver::Server;
use std::sync::Arc;
use tracing::info;
impl RadioFranceExt for Server {
async fn init_radiofrance(&mut self) -> Result<Arc<RadioFranceState>> {
info!("Initializing Radio France API...");
// Créer le client stateful
let config = pmoconfig::get_config();
let client = RadioFranceStatefulClient::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create Radio France client: {}", e))?;
// Créer l'état partagé (RadioFranceState est Clone et contient déjà un Arc<client>)
let state = RadioFranceState::new(client);
// Créer et enregistrer le router
let router = create_router(state.clone());
self.add_router("/api/radiofrance", router).await;
info!("Radio France API initialized");
info!("API endpoints available at /api/radiofrance/*");
Ok(Arc::new(state))
}
async fn init_radiofrance_with_source(
&mut self,
source: Arc<crate::source::RadioFranceSource>,
) -> Result<Arc<RadioFranceState>> {
info!("Initializing Radio France API with existing source...");
// Créer le client stateful
let config = pmoconfig::get_config();
let client = RadioFranceStatefulClient::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create Radio France client: {}", e))?;
// Créer l'état partagé avec la source
let state = RadioFranceState::new(client).with_source(source);
// Créer et enregistrer le router
let router = create_router(state.clone());
self.add_router("/api/radiofrance", router).await;
info!("Radio France API initialized with source");
info!("API endpoints available at /api/radiofrance/*");
Ok(Arc::new(state))
}
}

View File

@@ -0,0 +1,810 @@
//! MusicSource implementation for Radio France
//!
//! This module implements the `MusicSource` trait from `pmosource` for Radio France,
//! providing UPnP/DLNA integration with dynamic container generation.
use crate::error::Result;
use crate::models::Station;
use crate::playlist::{StationGroup, StationGroups, StationPlaylist};
use crate::stateful_client::RadioFranceStatefulClient;
use pmoconfig::Config;
use pmodidl::{Container, Item};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, SourceCapabilities};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
#[cfg(feature = "cache")]
use pmocovers::Cache as CoverCache;
#[cfg(feature = "server")]
use pmoupnp;
/// Default image for Radio France source (embedded in binary)
pub const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofrance-logo.webp");
/// Radio France music source
///
/// Provides access to ~70 Radio France stations via UPnP/DLNA with:
/// - Dynamic container generation based on station structure
/// - Automatic metadata refresh for active streams
/// - Hierarchical organization (standalone, groups, local radios)
pub struct RadioFranceSource {
/// Stateful client with automatic caching
client: RadioFranceStatefulClient,
/// Cache of playlists by station slug (volatile metadata)
playlists: Arc<RwLock<HashMap<String, StationPlaylist>>>,
/// Background tasks for metadata refresh
refresh_handles: Arc<RwLock<HashMap<String, JoinHandle<()>>>>,
/// Cover cache (optional)
#[cfg(feature = "cache")]
cover_cache: Option<Arc<CoverCache>>,
/// Server base URL for cover URLs
server_base_url: Option<String>,
/// Update counter for change tracking
update_id: Arc<RwLock<u32>>,
/// Last change timestamp
last_change: Arc<RwLock<Option<SystemTime>>>,
/// Callback for notifying container updates (UPnP GENA)
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync + 'static>>,
}
impl RadioFranceSource {
/// Create a new Radio France source
///
/// # Arguments
///
/// * `config` - Configuration for the client
///
/// # Example
///
/// ```no_run
/// use pmoradiofrance::RadioFranceSource;
/// use pmoconfig::get_config;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let config = get_config();
/// let source = RadioFranceSource::new(config).await?;
/// Ok(())
/// }
/// ```
pub async fn new(config: Arc<Config>) -> Result<Self> {
let client = RadioFranceStatefulClient::new(config).await?;
Ok(Self {
client,
playlists: Arc::new(RwLock::new(HashMap::new())),
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
#[cfg(feature = "cache")]
cover_cache: None,
server_base_url: None,
update_id: Arc::new(RwLock::new(0)),
last_change: Arc::new(RwLock::new(None)),
container_notifier: None,
})
}
/// Set the container notifier for UPnP GENA events
pub fn with_container_notifier(
mut self,
notifier: Arc<dyn Fn(&[String]) + Send + Sync + 'static>,
) -> Self {
self.container_notifier = Some(notifier);
self
}
/// Set the cover cache
#[cfg(feature = "cache")]
pub fn with_cover_cache(mut self, cache: Arc<CoverCache>) -> Self {
self.cover_cache = Some(cache);
self
}
/// Set the server base URL for cover serving
pub fn with_server_base_url(mut self, url: impl Into<String>) -> Self {
self.server_base_url = Some(url.into());
self
}
/// Create a new Radio France source from the cache registry
///
/// This is the recommended way to create a source when using the UPnP server.
/// The cover cache is automatically retrieved from the global registry.
///
/// # Arguments
///
/// * `client` - Radio France stateful client
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
///
/// # Errors
///
/// Returns an error if the cover cache is not initialized in the registry
#[cfg(feature = "server")]
pub fn from_registry(
client: RadioFranceStatefulClient,
base_url: impl Into<String>,
) -> Result<Self> {
#[cfg(feature = "cache")]
let cover_cache = pmoupnp::cache_registry::get_cover_cache();
Ok(Self {
client,
playlists: Arc::new(RwLock::new(HashMap::new())),
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
#[cfg(feature = "cache")]
cover_cache,
server_base_url: Some(base_url.into()),
update_id: Arc::new(RwLock::new(0)),
last_change: Arc::new(RwLock::new(None)),
container_notifier: None,
})
}
/// Start metadata refresh task for a station
pub async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> {
let mut handles = self.refresh_handles.write().await;
// If already running, do nothing
if handles.contains_key(station_slug) {
return Ok(());
}
let client = self.client.clone();
let playlists = self.playlists.clone();
let slug = station_slug.to_string();
let update_id = self.update_id.clone();
let last_change = self.last_change.clone();
let container_notifier = self.container_notifier.clone();
#[cfg(feature = "cache")]
let cover_cache = self.cover_cache.clone();
let server_base_url = self.server_base_url.clone();
let handle = tokio::spawn(async move {
loop {
match client.get_live_metadata(&slug).await {
Ok(metadata) => {
let delay = std::time::Duration::from_millis(metadata.delay_to_refresh);
#[cfg(feature = "logging")]
{
let artist = metadata
.now
.song
.as_ref()
.and_then(|s| {
if s.interpreters.is_empty() {
None
} else {
Some(s.artists_display())
}
})
.unwrap_or_else(|| "".to_string());
tracing::debug!(
"Refreshed metadata for {}: title='{}' artist='{}' delay={}ms",
slug,
metadata.now.first_line.title.as_deref().unwrap_or(""),
artist,
metadata.delay_to_refresh
);
}
// Update the playlist metadata
#[cfg(feature = "cache")]
{
let mut pls = playlists.write().await;
#[cfg(feature = "logging")]
tracing::debug!(
"Looking for playlist '{}' in cache, found: {}",
slug,
pls.contains_key(&slug)
);
if let Some(playlist) = pls.get_mut(&slug) {
let old_title = playlist.stream_item.title.clone();
#[cfg(feature = "logging")]
tracing::debug!(
"Updating playlist for {}: current title = '{}'",
slug,
old_title
);
let _: Result<()> = playlist
.update_metadata(
&metadata,
cover_cache.as_ref(),
server_base_url.as_deref(),
)
.await;
let new_title = playlist.stream_item.title.clone();
#[cfg(feature = "logging")]
if old_title != new_title {
tracing::info!(
"Metadata updated for {}: {} -> {}",
slug,
old_title,
new_title
);
}
// Update change tracking
*update_id.write().await = update_id.read().await.wrapping_add(1);
*last_change.write().await = Some(SystemTime::now());
// Notify UPnP ContentDirectory of the change
if let Some(ref notifier) = container_notifier {
// Notify the station's stream item container
let container_id = format!("radiofrance:{}", slug);
#[cfg(feature = "logging")]
tracing::info!(
"Notifying UPnP container update: {}",
container_id
);
notifier(&[container_id]);
}
}
}
#[cfg(not(feature = "cache"))]
{
let mut pls = playlists.write().await;
#[cfg(feature = "logging")]
tracing::debug!(
"Looking for playlist '{}' in cache, found: {}",
slug,
pls.contains_key(&slug)
);
if let Some(playlist) = pls.get_mut(&slug) {
let old_title = playlist.stream_item.title.clone();
#[cfg(feature = "logging")]
tracing::debug!(
"Updating playlist for {}: current title = '{}'",
slug,
old_title
);
let _: Result<()> = playlist.update_metadata_no_cache(
&metadata,
server_base_url.as_deref(),
);
let new_title = playlist.stream_item.title.clone();
#[cfg(feature = "logging")]
if old_title != new_title {
tracing::info!(
"Metadata updated for {}: {} -> {}",
slug,
old_title,
new_title
);
}
// Update change tracking
*update_id.write().await = update_id.read().await.wrapping_add(1);
*last_change.write().await = Some(SystemTime::now());
// Notify UPnP ContentDirectory of the change
if let Some(ref notifier) = container_notifier {
// Notify the station's stream item container
let container_id = format!("radiofrance:{}", slug);
#[cfg(feature = "logging")]
tracing::info!(
"Notifying UPnP container update: {}",
container_id
);
notifier(&[container_id]);
}
}
}
tokio::time::sleep(delay).await;
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::warn!("Failed to refresh metadata for {}: {}", slug, e);
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}
}
});
handles.insert(station_slug.to_string(), handle);
#[cfg(feature = "logging")]
tracing::debug!("Started metadata refresh for station: {}", station_slug);
Ok(())
}
/// Stop metadata refresh task for a station
pub async fn stop_metadata_refresh(&self, station_slug: &str) {
let mut handles = self.refresh_handles.write().await;
if let Some(handle) = handles.remove(station_slug) {
handle.abort();
#[cfg(feature = "logging")]
tracing::debug!("Stopped metadata refresh for station: {}", station_slug);
}
}
/// Build the UPnP container tree dynamically from station data
async fn build_container_tree(&self) -> Result<Container> {
#[cfg(feature = "logging")]
tracing::debug!("Building container tree");
let stations = self.client.get_stations().await?;
let groups = StationGroups::from_stations(stations);
#[cfg(feature = "logging")]
tracing::debug!(
"Groups: {} standalone, {} with webradios, {} local radios",
groups.standalone.len(),
groups.with_webradios.len(),
groups.local_radios.len()
);
let mut containers = Vec::new();
let mut items = Vec::new();
// 1. Standalone stations → direct items (avec appels API)
#[cfg(feature = "logging")]
tracing::debug!(
"Building {} standalone station items",
groups.standalone.len()
);
for station in &groups.standalone {
items.push(self.build_station_item(station).await?);
}
// 2. Stations with webradios → containers
#[cfg(feature = "logging")]
tracing::debug!("Building {} group containers", groups.with_webradios.len());
for group in &groups.with_webradios {
#[cfg(feature = "logging")]
tracing::debug!("Building container for group: {}", group.main.name);
containers.push(self.build_station_container(group).await?);
}
// 3. Local radios → single "Radios ICI" container
#[cfg(feature = "logging")]
tracing::debug!(
"Building ICI container with {} local radios",
groups.local_radios.len()
);
if !groups.local_radios.is_empty() {
containers.push(self.build_ici_container(&groups.local_radios).await?);
}
#[cfg(feature = "logging")]
tracing::debug!(
"Container tree built: {} containers, {} items",
containers.len(),
items.len()
);
Ok(Container {
id: "radiofrance".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: Some((containers.len() + items.len()).to_string()),
searchable: Some("0".to_string()),
title: "Radio France".to_string(),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers,
items,
})
}
/// Build a container for a station group (main + webradios)
/// Returns an empty container - items will be built when browsing into it
async fn build_station_container(&self, group: &StationGroup) -> Result<Container> {
let child_count = 1 + group.webradios.len(); // main + webradios
// Utiliser le logo par défaut si server_base_url est configuré
let album_art = self.server_base_url.as_ref().map(|base| {
format!(
"{}/api/radiofrance/default-logo",
base.trim_end_matches('/')
)
});
Ok(Container {
id: format!("radiofrance:group:{}", group.main.slug),
parent_id: "radiofrance".to_string(),
restricted: Some("1".to_string()),
child_count: Some(child_count.to_string()),
searchable: Some("0".to_string()),
title: group.main.name.clone(),
class: "object.container".to_string(),
artist: None,
album_art,
containers: vec![],
items: vec![],
})
}
/// Build the "Radios ICI" container
/// Returns an empty container - items will be built when browsing into it
async fn build_ici_container(&self, local_radios: &[Station]) -> Result<Container> {
// Utiliser le logo par défaut si server_base_url est configuré
let album_art = self.server_base_url.as_ref().map(|base| {
format!(
"{}/api/radiofrance/default-logo",
base.trim_end_matches('/')
)
});
Ok(Container {
id: "radiofrance:ici".to_string(),
parent_id: "radiofrance".to_string(),
restricted: Some("1".to_string()),
child_count: Some(local_radios.len().to_string()),
searchable: Some("0".to_string()),
title: "Radios ICI".to_string(),
class: "object.container".to_string(),
artist: None,
album_art,
containers: vec![],
items: vec![],
})
}
/// Build a UPnP item for a station
///
/// Fetches live metadata to create a complete item with stream URL.
async fn build_station_item(&self, station: &Station) -> Result<Item> {
#[cfg(feature = "logging")]
tracing::debug!(
"Building station item for: {} ({})",
station.name,
station.slug
);
let playlists = self.playlists.read().await;
// If we already have this station in cache, use it
if let Some(existing) = playlists.get(&station.slug) {
#[cfg(feature = "logging")]
tracing::debug!("Using cached item for: {}", station.slug);
return Ok(existing.stream_item.clone());
}
// Release read lock before fetching metadata
drop(playlists);
// Fetch metadata from API
let metadata = self.client.get_live_metadata(&station.slug).await?;
// Create playlist with metadata
#[cfg(feature = "cache")]
let playlist = StationPlaylist::from_live_metadata(
station.clone(),
&metadata,
self.cover_cache.as_ref(),
self.server_base_url.as_deref(),
)
.await?;
#[cfg(not(feature = "cache"))]
let playlist = StationPlaylist::from_live_metadata_no_cache(
station.clone(),
&metadata,
self.server_base_url.as_deref(),
)?;
// Cache it
let mut playlists_write = self.playlists.write().await;
playlists_write.insert(station.slug.clone(), playlist.clone());
drop(playlists_write);
// Note: We don't start metadata refresh here to avoid blocking during browse.
// Refresh will be started in resolve_uri() when the stream is actually played.
#[cfg(feature = "logging")]
tracing::debug!(
"Built item for {}: {} resources, album_art: {:?}",
station.slug,
playlist.stream_item.resources.len(),
playlist.stream_item.album_art.is_some()
);
Ok(playlist.stream_item)
}
}
impl std::fmt::Debug for RadioFranceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioFranceSource")
.field("client", &self.client)
.field("playlists_count", &"<locked>")
.field("refresh_handles_count", &"<locked>")
.finish()
}
}
#[async_trait]
impl MusicSource for RadioFranceSource {
fn name(&self) -> &str {
"Radio France"
}
fn id(&self) -> &str {
"radiofrance"
}
fn default_image(&self) -> &[u8] {
RADIOFRANCE_DEFAULT_IMAGE
}
fn capabilities(&self) -> SourceCapabilities {
SourceCapabilities {
supports_fifo: false,
supports_search: false,
supports_favorites: false,
supports_playlists: false,
supports_user_content: false,
supports_high_res_audio: false,
max_sample_rate: Some(48000), // AAC 48kHz
supports_multiple_formats: false,
supports_advanced_search: false,
supports_pagination: false,
}
}
async fn root_container(&self) -> pmosource::Result<Container> {
Ok(Container {
id: "radiofrance".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: None,
searchable: Some("0".to_string()),
title: "Radio France".to_string(),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers: vec![],
items: vec![],
})
}
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
match object_id {
"radiofrance" => {
let container = self
.build_container_tree()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
Ok(BrowseResult::Mixed {
containers: container.containers,
items: container.items,
})
}
id if id.starts_with("radiofrance:group:") => {
let slug = id
.strip_prefix("radiofrance:group:")
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
let stations = self
.client
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
let group = groups
.with_webradios
.iter()
.find(|g| g.main.slug == slug)
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
// Build items for this group only (main + webradios)
let group_id = format!("radiofrance:group:{}", slug);
let mut main_item = self
.build_station_item(&group.main)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Fix parent_id to point to the group container
main_item.parent_id = group_id.clone();
let mut items = vec![main_item];
for webradio in &group.webradios {
let mut item = self
.build_station_item(webradio)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Fix parent_id to point to the group container
item.parent_id = group_id.clone();
items.push(item);
}
Ok(BrowseResult::Items(items))
}
"radiofrance:ici" => {
let stations = self
.client
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
// Build items for local radios only
let mut items = Vec::new();
for station in &groups.local_radios {
let mut item = self
.build_station_item(station)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Fix parent_id to point to the ICI container
item.parent_id = "radiofrance:ici".to_string();
items.push(item);
}
Ok(BrowseResult::Items(items))
}
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
}
}
async fn get_item(&self, object_id: &str) -> pmosource::Result<Item> {
// Format: radiofrance:{slug}:stream
let slug = object_id
.strip_prefix("radiofrance:")
.and_then(|s| s.strip_suffix(":stream"))
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let playlists = self.playlists.read().await;
playlists
.get(slug)
.map(|p| p.stream_item.clone())
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))
}
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
// Extract station slug from object_id (format: radiofrance:{slug}:stream)
let slug = object_id
.strip_prefix("radiofrance:")
.and_then(|s| s.strip_suffix(":stream"))
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
// Ensure we have metadata for this station
let playlists = self.playlists.read().await;
let needs_metadata = !playlists.contains_key(slug);
drop(playlists);
if needs_metadata {
// Fetch metadata and create playlist
let stations = self
.client
.get_stations()
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let station = stations
.iter()
.find(|s| s.slug == slug)
.ok_or_else(|| MusicSourceError::ObjectNotFound(slug.to_string()))?;
let metadata = self
.client
.get_live_metadata(slug)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
#[cfg(feature = "cache")]
let playlist = StationPlaylist::from_live_metadata(
station.clone(),
&metadata,
self.cover_cache.as_ref(),
self.server_base_url.as_deref(),
)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
#[cfg(not(feature = "cache"))]
let playlist = StationPlaylist::from_live_metadata_no_cache(
station.clone(),
&metadata,
self.server_base_url.as_deref(),
)
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let mut playlists_write = self.playlists.write().await;
playlists_write.insert(slug.to_string(), playlist);
// Start metadata refresh
drop(playlists_write);
let _ = self.start_metadata_refresh(slug).await;
}
let item = self.get_item(object_id).await?;
item.resources
.first()
.map(|r| r.url.clone())
.ok_or_else(|| MusicSourceError::UriResolutionError("No resource found".to_string()))
}
fn supports_fifo(&self) -> bool {
false
}
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
Err(MusicSourceError::FifoNotSupported)
}
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
*self.update_id.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
*self.last_change.read().await
}
async fn get_items(&self, offset: usize, count: usize) -> pmosource::Result<Vec<Item>> {
// Not applicable for radio stations
let _ = (offset, count);
Ok(vec![])
}
}
impl Drop for RadioFranceSource {
fn drop(&mut self) {
// Abort all refresh tasks on drop
if let Ok(handles) = self.refresh_handles.try_write() {
for (_, handle) in handles.iter() {
handle.abort();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Note: These tests require a valid pmoconfig setup
// They are primarily structural tests
#[test]
fn test_source_metadata() {
// Test that we can create a source with proper metadata
// Actual async tests would go in integration tests
}
}

View File

@@ -0,0 +1,484 @@
//! Stateful client for Radio France with automatic caching
//!
//! This module provides a higher-level client that automatically manages
//! station discovery caching through pmoconfig, providing a simpler API
//! for integration into PMOMusic.
//!
//! # Example
//!
//! ```no_run
//! use pmoradiofrance::RadioFranceStatefulClient;
//! use pmoconfig::get_config;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = get_config();
//! let client = RadioFranceStatefulClient::new(config).await?;
//!
//! // Get stations (automatically cached with 7-day TTL)
//! let stations = client.get_stations().await?;
//!
//! // Get live metadata (handles caching internally)
//! let metadata = client.get_live_metadata("franceculture").await?;
//!
//! Ok(())
//! }
//! ```
use crate::client::RadioFranceClient;
use crate::config_ext::RadioFranceConfigExt;
use crate::error::{Error, Result};
use crate::models::{LiveResponse, Station};
use pmoconfig::Config;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
/// Cache entry for live metadata
#[derive(Debug, Clone)]
struct LiveMetadataCache {
/// Cached metadata
metadata: LiveResponse,
/// When the cache should be invalidated (based on delayToRefresh)
valid_until: SystemTime,
}
impl LiveMetadataCache {
/// Create a new cache entry
fn new(metadata: LiveResponse) -> Self {
let delay = Duration::from_millis(metadata.delay_to_refresh);
let valid_until = SystemTime::now() + delay;
Self {
metadata,
valid_until,
}
}
/// Check if the cache is still valid
fn is_valid(&self) -> bool {
SystemTime::now() < self.valid_until
}
/// Get the remaining time until the cache expires
#[cfg(feature = "logging")]
fn remaining_ttl(&self) -> Duration {
self.valid_until
.duration_since(SystemTime::now())
.unwrap_or(Duration::ZERO)
}
}
/// Stateful Radio France client with automatic caching
///
/// This client wraps `RadioFranceClient` and adds:
/// - Automatic station list caching via pmoconfig
/// - Live metadata caching (in-memory, respecting delayToRefresh)
/// - Simple high-level API for PMOMusic integration
///
/// # Caching Strategy
///
/// - **Station List**: Cached in pmoconfig with 7-day TTL (configurable)
/// - **Live Metadata**: Cached in-memory per station, TTL from API's delayToRefresh
///
/// # Thread Safety
///
/// This client is thread-safe (Clone + Send + Sync) and can be shared
/// across async tasks.
#[derive(Clone)]
pub struct RadioFranceStatefulClient {
/// Underlying HTTP client
client: RadioFranceClient,
/// Configuration handle (Arc for sharing)
config: Arc<Config>,
/// In-memory cache for live metadata (thread-safe)
metadata_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, LiveMetadataCache>>>,
}
impl RadioFranceStatefulClient {
/// Create a new stateful client
///
/// # Arguments
///
/// * `config` - Configuration handle for caching station lists
///
/// # Example
///
/// ```no_run
/// use pmoconfig::get_config;
/// use pmoradiofrance::RadioFranceStatefulClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let config = get_config();
/// let client = RadioFranceStatefulClient::new(config).await?;
/// Ok(())
/// }
/// ```
pub async fn new(config: Arc<Config>) -> Result<Self> {
let client = RadioFranceClient::new().await?;
Ok(Self {
client,
config,
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
})
}
/// Create a client from global configuration
///
/// This is a convenience method that reads the configuration from
/// the global config singleton.
///
/// # Example
///
/// ```ignore
/// use pmoradiofrance::RadioFranceStatefulClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioFranceStatefulClient::from_config().await?;
/// Ok(())
/// }
/// ```
pub async fn from_config() -> Result<Self> {
let config = pmoconfig::get_config();
Self::new(config).await
}
/// Create a client with a custom RadioFranceClient
pub fn with_client(client: RadioFranceClient, config: Arc<Config>) -> Self {
Self {
client,
config,
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
}
}
/// Get the underlying HTTP client
pub fn client(&self) -> &RadioFranceClient {
&self.client
}
/// Get the configuration
pub fn config(&self) -> &Arc<Config> {
&self.config
}
// ========================================================================
// Station Discovery (with automatic caching)
// ========================================================================
/// Get all stations, using cache if valid
///
/// This method automatically:
/// 1. Checks if Radio France is enabled in config
/// 2. Tries to use cached station list
/// 3. If cache miss/expired, discovers and caches stations
///
/// # Errors
///
/// Returns error if:
/// - Radio France is disabled in config
/// - Discovery fails and no valid cache exists
///
/// # Example
///
/// ```no_run
/// # use pmoradiofrance::RadioFranceStatefulClient;
/// # use pmoconfig::get_config;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let config = get_config();
/// # let client = RadioFranceStatefulClient::new(config).await?;
/// let stations = client.get_stations().await?;
/// for station in stations {
/// println!("{} - {}", station.name, station.slug);
/// }
/// # Ok(())
/// # }
/// ```
pub async fn get_stations(&self) -> Result<Vec<Station>> {
// Check if Radio France is enabled
if !self.config.get_radiofrance_enabled()? {
return Err(Error::other("Radio France is disabled in configuration"));
}
// Try to get from cache
if let Some(stations) = self.config.get_radiofrance_stations_cached()? {
#[cfg(feature = "logging")]
tracing::debug!("Using {} cached stations", stations.len());
return Ok(stations);
}
// Cache miss - discover and cache with timeout
#[cfg(feature = "logging")]
tracing::info!("Station cache miss - discovering stations");
let stations = tokio::time::timeout(
std::time::Duration::from_secs(10),
self.client.discover_all_stations(),
)
.await
.map_err(|_| Error::other("Timeout while discovering Radio France stations (10s)"))??;
// Cache the results
self.config.set_radiofrance_cached_stations(&stations)?;
#[cfg(feature = "logging")]
tracing::info!("Discovered and cached {} stations", stations.len());
Ok(stations)
}
/// Force refresh of the station list (bypass cache)
///
/// Use this to force re-discovery, for example after a manual
/// cache invalidation or to get the latest station list.
pub async fn refresh_stations(&self) -> Result<Vec<Station>> {
#[cfg(feature = "logging")]
tracing::info!("Force refreshing station list");
let stations = self.client.discover_all_stations().await?;
self.config.set_radiofrance_cached_stations(&stations)?;
#[cfg(feature = "logging")]
tracing::info!("Refreshed {} stations", stations.len());
Ok(stations)
}
/// Clear the station cache
///
/// Forces next `get_stations()` call to re-discover stations.
pub fn clear_station_cache(&self) -> Result<()> {
Ok(self.config.clear_radiofrance_station_cache()?)
}
// ========================================================================
// Live Metadata (with intelligent caching)
// ========================================================================
/// Get live metadata for a station, using cache if valid
///
/// This method automatically:
/// 1. Checks in-memory cache
/// 2. If cache valid (based on delayToRefresh), returns cached data
/// 3. If cache expired, fetches fresh data and updates cache
///
/// # Arguments
///
/// * `station` - Station slug (e.g., "franceculture", "fip_rock")
///
/// # Caching Behavior
///
/// The cache TTL is determined by the API's `delayToRefresh` field,
/// which respects Radio France's recommended polling interval.
///
/// # Example
///
/// ```no_run
/// # use pmoradiofrance::RadioFranceStatefulClient;
/// # use pmoconfig::get_config;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let config = get_config();
/// # let client = RadioFranceStatefulClient::new(config).await?;
/// let metadata = client.get_live_metadata("franceculture").await?;
/// println!("Now: {} - {}",
/// metadata.now.first_line.title_or_default(),
/// metadata.now.second_line.title_or_default()
/// );
/// # Ok(())
/// # }
/// ```
pub async fn get_live_metadata(&self, station: &str) -> Result<LiveResponse> {
// Check cache first
{
let cache = self.metadata_cache.read().unwrap();
if let Some(entry) = cache.get(station) {
if entry.is_valid() {
#[cfg(feature = "logging")]
tracing::debug!(
"Using cached metadata for {} (TTL: {:?})",
station,
entry.remaining_ttl()
);
return Ok(entry.metadata.clone());
}
}
}
// Cache miss or expired - fetch fresh data with timeout
#[cfg(feature = "logging")]
tracing::debug!("Fetching live metadata for {}", station);
let metadata = tokio::time::timeout(
std::time::Duration::from_secs(5),
self.client.live_metadata(station),
)
.await
.map_err(|_| {
Error::other(format!(
"Timeout while fetching metadata for {} (5s)",
station
))
})??;
// Update cache
{
let mut cache = self.metadata_cache.write().unwrap();
cache.insert(
station.to_string(),
LiveMetadataCache::new(metadata.clone()),
);
}
#[cfg(feature = "logging")]
tracing::debug!(
"Cached metadata for {} (TTL: {} ms)",
station,
metadata.delay_to_refresh
);
Ok(metadata)
}
/// Force refresh of live metadata (bypass cache)
///
/// Use this when you need the absolute latest metadata,
/// ignoring the cached version.
pub async fn refresh_live_metadata(&self, station: &str) -> Result<LiveResponse> {
#[cfg(feature = "logging")]
tracing::debug!("Force refreshing metadata for {}", station);
let metadata = self.client.live_metadata(station).await?;
// Update cache
{
let mut cache = self.metadata_cache.write().unwrap();
cache.insert(
station.to_string(),
LiveMetadataCache::new(metadata.clone()),
);
}
Ok(metadata)
}
/// Clear the metadata cache for a specific station
pub fn clear_metadata_cache(&self, station: &str) {
let mut cache = self.metadata_cache.write().unwrap();
cache.remove(station);
#[cfg(feature = "logging")]
tracing::debug!("Cleared metadata cache for {}", station);
}
/// Clear all metadata caches
pub fn clear_all_metadata_caches(&self) {
let mut cache = self.metadata_cache.write().unwrap();
cache.clear();
#[cfg(feature = "logging")]
tracing::debug!("Cleared all metadata caches");
}
// ========================================================================
// Convenience Methods
// ========================================================================
/// Get the HiFi stream URL for a station
///
/// Convenience wrapper around `get_live_metadata()` that extracts
/// the best HiFi stream URL.
pub async fn get_stream_url(&self, station: &str) -> Result<String> {
self.client.get_hifi_stream_url(station).await
}
/// Check if Radio France is enabled in configuration
pub fn is_enabled(&self) -> Result<bool> {
Ok(self.config.get_radiofrance_enabled()?)
}
/// Enable Radio France in configuration
pub fn set_enabled(&self, enabled: bool) -> Result<()> {
Ok(self.config.set_radiofrance_enabled(enabled)?)
}
/// Get the station cache TTL in seconds
pub fn get_station_cache_ttl(&self) -> Result<u64> {
Ok(self.config.get_radiofrance_station_cache_ttl()?)
}
/// Set the station cache TTL in seconds
pub fn set_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> {
Ok(self.config.set_radiofrance_station_cache_ttl(ttl_secs)?)
}
/// Get cache statistics
///
/// Returns (number of cached stations, number of cached metadata entries)
pub fn cache_stats(&self) -> (usize, usize) {
let station_count = self
.config
.get_radiofrance_stations_cached()
.ok()
.flatten()
.map(|s| s.len())
.unwrap_or(0);
let metadata_count = self.metadata_cache.read().unwrap().len();
(station_count, metadata_count)
}
}
impl std::fmt::Debug for RadioFranceStatefulClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (station_cache, metadata_cache) = self.cache_stats();
f.debug_struct("RadioFranceStatefulClient")
.field("client", &self.client)
.field("cached_stations", &station_cache)
.field("cached_metadata_entries", &metadata_cache)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
// Note: Real integration tests would require pmoconfig setup
// These are just structural tests
#[test]
fn test_live_metadata_cache_validity() {
let response = LiveResponse {
station_name: "test".to_string(),
delay_to_refresh: 5000, // 5 seconds
migrated: true,
now: crate::models::ShowMetadata {
print_prog_music: false,
start_time: None,
end_time: None,
producer: None,
first_line: Default::default(),
second_line: Default::default(),
third_line: None,
intro: None,
react_available: false,
visual_background: None,
song: None,
media: Default::default(),
visuals: None,
local_radios: None,
},
next: None,
};
let cache = LiveMetadataCache::new(response);
assert!(cache.is_valid());
// Verify the cache expires in the future
assert!(cache.valid_until > SystemTime::now());
}
}

View File

@@ -1 +1 @@
0.3.11
0.3.12