push-pruvwtuysnnn #66
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,7 +8,7 @@
|
||||
**/*.o
|
||||
**/*.o.d
|
||||
**/*.a
|
||||
**/*.flac
|
||||
**/*.flac
|
||||
**/*.aif
|
||||
**/*.aiff
|
||||
**/*.wav
|
||||
@@ -25,7 +25,7 @@ xxx
|
||||
.DS_Store
|
||||
target
|
||||
/.pmomusic_covers
|
||||
/.pmomusic_audio/**
|
||||
/.pmomusic_audio/**
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
**/Release/
|
||||
**/Debug/
|
||||
@@ -42,4 +42,6 @@ setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
pmo*_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
RF.json
|
||||
RF_old.json
|
||||
|
||||
1000
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
1000
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
File diff suppressed because it is too large
Load Diff
1284
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
1284
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
File diff suppressed because it is too large
Load Diff
586
Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md
Normal file
586
Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md
Normal 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)
|
||||
474
Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
Normal file
474
Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
Normal 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.
|
||||
1227
Blackboard/ToThinkAbout/api_radiofrance_complete.md
Normal file
1227
Blackboard/ToThinkAbout/api_radiofrance_complete.md
Normal file
File diff suppressed because it is too large
Load Diff
831
Blackboard/ToThinkAbout/client_radiofrance_architecture.md
Normal file
831
Blackboard/ToThinkAbout/client_radiofrance_architecture.md
Normal 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
317
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
2559
metadata.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {:?})",
|
||||
|
||||
@@ -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 {
|
||||
@@ -697,4 +680,4 @@ pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
90
pmoradiofrance/Cargo.toml
Normal 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"
|
||||
16
pmoradiofrance/assets/create_logo.sh
Executable file
16
pmoradiofrance/assets/create_logo.sh
Executable 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"
|
||||
BIN
pmoradiofrance/assets/radiofrance-logo.jpg
Normal file
BIN
pmoradiofrance/assets/radiofrance-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
pmoradiofrance/assets/radiofrance-logo.webp
Normal file
BIN
pmoradiofrance/assets/radiofrance-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
40
pmoradiofrance/examples/discover_stations.rs
Normal file
40
pmoradiofrance/examples/discover_stations.rs
Normal 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(())
|
||||
}
|
||||
107
pmoradiofrance/examples/live_metadata.rs
Normal file
107
pmoradiofrance/examples/live_metadata.rs
Normal 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(())
|
||||
}
|
||||
195
pmoradiofrance/src/api_rest.rs
Normal file
195
pmoradiofrance/src/api_rest.rs
Normal 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
1212
pmoradiofrance/src/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
243
pmoradiofrance/src/config_ext.rs
Normal file
243
pmoradiofrance/src/config_ext.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
77
pmoradiofrance/src/error.rs
Normal file
77
pmoradiofrance/src/error.rs
Normal 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
137
pmoradiofrance/src/lib.rs
Normal 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};
|
||||
518
pmoradiofrance/src/models.rs
Normal file
518
pmoradiofrance/src/models.rs
Normal 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
|
||||
}
|
||||
}
|
||||
806
pmoradiofrance/src/playlist.rs
Normal file
806
pmoradiofrance/src/playlist.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
103
pmoradiofrance/src/pmoserver_ext.rs
Normal file
103
pmoradiofrance/src/pmoserver_ext.rs
Normal 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
|
||||
84
pmoradiofrance/src/pmoserver_impl.rs
Normal file
84
pmoradiofrance/src/pmoserver_impl.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
810
pmoradiofrance/src/source.rs
Normal file
810
pmoradiofrance/src/source.rs
Normal 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
|
||||
}
|
||||
}
|
||||
484
pmoradiofrance/src/stateful_client.rs
Normal file
484
pmoradiofrance/src/stateful_client.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.11
|
||||
0.3.12
|
||||
|
||||
Reference in New Issue
Block a user