Merge pull request 'push-pruvwtuysnnn' (#66) from push-pruvwtuysnnn into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 11m37s
Some checks failed
Build and Push Docker Image / build (push) Failing after 11m37s
Reviewed-on: #66
This commit was merged in pull request #66.
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,7 +8,7 @@
|
|||||||
**/*.o
|
**/*.o
|
||||||
**/*.o.d
|
**/*.o.d
|
||||||
**/*.a
|
**/*.a
|
||||||
**/*.flac
|
**/*.flac
|
||||||
**/*.aif
|
**/*.aif
|
||||||
**/*.aiff
|
**/*.aiff
|
||||||
**/*.wav
|
**/*.wav
|
||||||
@@ -25,7 +25,7 @@ xxx
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
target
|
target
|
||||||
/.pmomusic_covers
|
/.pmomusic_covers
|
||||||
/.pmomusic_audio/**
|
/.pmomusic_audio/**
|
||||||
C/src/soxr-0.1.3/Release/tests
|
C/src/soxr-0.1.3/Release/tests
|
||||||
**/Release/
|
**/Release/
|
||||||
**/Debug/
|
**/Debug/
|
||||||
@@ -42,4 +42,6 @@ setup-env.sh
|
|||||||
cache
|
cache
|
||||||
gupnp-tools
|
gupnp-tools
|
||||||
pmo*_[0_9]*.txt
|
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]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.10"
|
version = "0.3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
@@ -693,7 +693,7 @@ dependencies = [
|
|||||||
"bevy_ptr",
|
"bevy_ptr",
|
||||||
"bevy_reflect_derive",
|
"bevy_reflect_derive",
|
||||||
"bevy_utils",
|
"bevy_utils",
|
||||||
"derive_more",
|
"derive_more 2.0.1",
|
||||||
"disqualified",
|
"disqualified",
|
||||||
"downcast-rs",
|
"downcast-rs",
|
||||||
"erased-serde",
|
"erased-serde",
|
||||||
@@ -1276,6 +1276,29 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "ctr"
|
name = "ctr"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
@@ -1335,6 +1358,17 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1438,12 +1472,33 @@ version = "2.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
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]]
|
[[package]]
|
||||||
name = "dunce"
|
name = "dunce"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ego-tree"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -1708,6 +1763,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1810,6 +1875,15 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gcc"
|
name = "gcc"
|
||||||
version = "0.3.55"
|
version = "0.3.55"
|
||||||
@@ -1848,6 +1922,15 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -2044,6 +2127,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "htmlescape"
|
name = "htmlescape"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2713,6 +2808,12 @@ dependencies = [
|
|||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -2722,6 +2823,31 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3528,6 +3654,58 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -3729,6 +3907,7 @@ dependencies = [
|
|||||||
"pmodidl",
|
"pmodidl",
|
||||||
"pmoserver",
|
"pmoserver",
|
||||||
"pmoupnp",
|
"pmoupnp",
|
||||||
|
"pmoutils",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -3832,6 +4011,7 @@ dependencies = [
|
|||||||
"pmoparadise",
|
"pmoparadise",
|
||||||
"pmoplaylist",
|
"pmoplaylist",
|
||||||
"pmoqobuz",
|
"pmoqobuz",
|
||||||
|
"pmoradiofrance",
|
||||||
"pmoserver",
|
"pmoserver",
|
||||||
"pmosource",
|
"pmosource",
|
||||||
"pmoupnp",
|
"pmoupnp",
|
||||||
@@ -3963,6 +4143,38 @@ dependencies = [
|
|||||||
"utoipa",
|
"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]]
|
[[package]]
|
||||||
name = "pmoserver"
|
name = "pmoserver"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4146,6 +4358,12 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "precomputed-hash"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -4447,7 +4665,7 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-truncate",
|
"unicode-truncate",
|
||||||
"unicode-width",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4836,6 +5054,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -4859,6 +5092,25 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -4959,6 +5211,15 @@ dependencies = [
|
|||||||
"unsafe-libyaml",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -5047,6 +5308,12 @@ version = "2.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
@@ -5158,6 +5425,31 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
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]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
version = "0.26.3"
|
version = "0.26.3"
|
||||||
@@ -5509,6 +5801,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -6002,7 +6305,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6011,6 +6314,12 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ members = [
|
|||||||
"pmoaudio",
|
"pmoaudio",
|
||||||
"pmoqobuz",
|
"pmoqobuz",
|
||||||
"pmoparadise",
|
"pmoparadise",
|
||||||
|
"pmoradiofrance",
|
||||||
"pmosource",
|
"pmosource",
|
||||||
"pmoplaylist",
|
"pmoplaylist",
|
||||||
"pmoflac",
|
"pmoflac",
|
||||||
"pmometadata", "pmocontrol",
|
"pmometadata",
|
||||||
|
"pmocontrol",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -45,6 +47,8 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
|||||||
reqwest = { version = "0.12", default-features = false }
|
reqwest = { version = "0.12", default-features = false }
|
||||||
ureq = "3.1"
|
ureq = "3.1"
|
||||||
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
||||||
|
axum = "0.8.4"
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.11"
|
version = "0.3.12"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
pmoupnp = { path = "../pmoupnp"}
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
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"] }
|
pmosource = { path = "../pmosource", features = ["server"] }
|
||||||
pmoserver = { path = "../pmoserver" }
|
pmoserver = { path = "../pmoserver" }
|
||||||
pmocovers = { path = "../pmocovers", features = ["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
|
// Lister toutes les sources enregistrées
|
||||||
let sources = server.read().await.list_music_sources().await;
|
let sources = server.read().await.list_music_sources().await;
|
||||||
info!("✅ {} music source(s) registered", sources.len());
|
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]
|
[dependencies]
|
||||||
pmoupnp = { path = "../pmoupnp" }
|
pmoupnp = { path = "../pmoupnp" }
|
||||||
pmodidl = { path = "../pmodidl" }
|
pmodidl = { path = "../pmodidl" }
|
||||||
|
pmoutils = { path = "../pmoutils" }
|
||||||
quick-xml = { workspace = true }
|
quick-xml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
ureq = "3.1.4"
|
ureq = "3.1.4"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ fn ensure_crypto_provider_initialized() {
|
|||||||
|
|
||||||
INIT.call_once(|| {
|
INIT.call_once(|| {
|
||||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
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
|
// Connect to the device
|
||||||
println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT);
|
println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT);
|
||||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
let cast_device =
|
||||||
Ok(device) => {
|
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||||
println!(" ✓ Connected");
|
Ok(device) => {
|
||||||
device
|
println!(" ✓ Connected");
|
||||||
}
|
device
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!(" ✗ Failed: {}", e);
|
Err(e) => {
|
||||||
std::process::exit(1);
|
eprintln!(" ✗ Failed: {}", e);
|
||||||
}
|
std::process::exit(1);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Connect to receiver channel
|
// Connect to receiver channel
|
||||||
println!();
|
println!();
|
||||||
println!("→ Connecting to receiver channel...");
|
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);
|
eprintln!(" ✗ Failed: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fn ensure_crypto_provider_initialized() {
|
|||||||
|
|
||||||
INIT.call_once(|| {
|
INIT.call_once(|| {
|
||||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider(),
|
||||||
);
|
);
|
||||||
println!("✓ Rustls CryptoProvider initialized");
|
println!("✓ Rustls CryptoProvider initialized");
|
||||||
});
|
});
|
||||||
@@ -88,7 +88,10 @@ fn main() {
|
|||||||
eprintln!("Usage: {} <chromecast_ip> [media_url]", args[0]);
|
eprintln!("Usage: {} <chromecast_ip> [media_url]", args[0]);
|
||||||
eprintln!("\nExample:");
|
eprintln!("\nExample:");
|
||||||
eprintln!(" {} 192.168.1.100", args[0]);
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,21 +119,25 @@ fn main() {
|
|||||||
// Step 1: Connect to the device
|
// Step 1: Connect to the device
|
||||||
println!("──────────────────────────────────────────────────────────");
|
println!("──────────────────────────────────────────────────────────");
|
||||||
println!("STEP 1: Connecting to Chromecast...");
|
println!("STEP 1: Connecting to Chromecast...");
|
||||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
let cast_device =
|
||||||
Ok(device) => {
|
match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||||
println!("✓ Connected to Chromecast");
|
Ok(device) => {
|
||||||
device
|
println!("✓ Connected to Chromecast");
|
||||||
}
|
device
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("✗ Failed to connect: {}", e);
|
Err(e) => {
|
||||||
std::process::exit(1);
|
eprintln!("✗ Failed to connect: {}", e);
|
||||||
}
|
std::process::exit(1);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Step 2: Connect to the default receiver channel
|
// Step 2: Connect to the default receiver channel
|
||||||
println!();
|
println!();
|
||||||
println!("STEP 2: Connecting to receiver channel...");
|
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);
|
eprintln!("✗ Failed to connect channel: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -151,7 +158,10 @@ fn main() {
|
|||||||
let status = match cast_device.receiver.get_status() {
|
let status = match cast_device.receiver.get_status() {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
println!("✓ Receiver status obtained");
|
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!(" - Muted: {}", status.volume.muted.unwrap_or(false));
|
||||||
println!(" - Running apps: {}", status.applications.len());
|
println!(" - Running apps: {}", status.applications.len());
|
||||||
status
|
status
|
||||||
@@ -165,7 +175,10 @@ fn main() {
|
|||||||
// Step 5: Launch DefaultMediaReceiver
|
// Step 5: Launch DefaultMediaReceiver
|
||||||
println!();
|
println!();
|
||||||
println!("STEP 5: Launching DefaultMediaReceiver app...");
|
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) => {
|
Ok(app) => {
|
||||||
println!("✓ App launched successfully");
|
println!("✓ App launched successfully");
|
||||||
println!(" - App ID: {}", app.app_id);
|
println!(" - App ID: {}", app.app_id);
|
||||||
@@ -201,11 +214,10 @@ fn main() {
|
|||||||
metadata: None,
|
metadata: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match cast_device.media.load(
|
match cast_device
|
||||||
app.transport_id.as_str(),
|
.media
|
||||||
app.session_id.as_str(),
|
.load(app.transport_id.as_str(), app.session_id.as_str(), &media)
|
||||||
&media,
|
{
|
||||||
) {
|
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
println!("✓ Media loaded successfully!");
|
println!("✓ Media loaded successfully!");
|
||||||
println!(" - Media status entries: {}", status.entries.len());
|
println!(" - Media status entries: {}", status.entries.len());
|
||||||
@@ -241,7 +253,10 @@ fn main() {
|
|||||||
Ok(ChannelMessage::Heartbeat(response)) => {
|
Ok(ChannelMessage::Heartbeat(response)) => {
|
||||||
if let HeartbeatResponse::Ping = response {
|
if let HeartbeatResponse::Ping = response {
|
||||||
heartbeat_count += 1;
|
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() {
|
if let Err(e) = cast_device.heartbeat.pong() {
|
||||||
eprintln!("✗ Failed to send pong: {}", e);
|
eprintln!("✗ Failed to send pong: {}", e);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pmocontrol::RendererProtocol;
|
use pmocontrol::RendererProtocol;
|
||||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
|
use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo, UpnpMediaServer};
|
||||||
|
|
||||||
fn main() -> std::io::Result<()> {
|
fn main() -> std::io::Result<()> {
|
||||||
// Un tout petit logging optionnel
|
// Un tout petit logging optionnel
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ use crossterm::terminal::{
|
|||||||
};
|
};
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, PlaybackItem,
|
||||||
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, RendererEvent, RendererInfo,
|
||||||
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
TransportControl, UpnpMediaServer, UpnpMediaServer, VolumeControl,
|
||||||
};
|
};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use std::time::Duration;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent,
|
||||||
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
MusicRendererBackend, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||||
|
UpnpMediaServer, UpnpMediaServer,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossbeam_channel::RecvTimeoutError;
|
use crossbeam_channel::RecvTimeoutError;
|
||||||
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
|
use pmocontrol::{ControlPoint, MediaServerEvent, ServerId, UpnpMediaServer};
|
||||||
|
|
||||||
const DISCOVERY_WAIT_SECS: u64 = 5;
|
const DISCOVERY_WAIT_SECS: u64 = 5;
|
||||||
const MONITOR_DURATION_SECS: u64 = 90;
|
const MONITOR_DURATION_SECS: u64 = 90;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
|
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider,
|
||||||
RendererInfo,
|
MusicRendererBackend, RendererInfo,
|
||||||
control_point::ControlPoint,
|
control_point::ControlPoint,
|
||||||
openhome_client::{
|
openhome_client::{
|
||||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||||
|
|||||||
@@ -344,7 +344,12 @@ fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<(
|
|||||||
Ok(())
|
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!(
|
println!(
|
||||||
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
||||||
iterations, interval_secs
|
iterations, interval_secs
|
||||||
|
|||||||
@@ -1647,7 +1647,7 @@ fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
|
|||||||
bits_per_sample: None,
|
bits_per_sample: None,
|
||||||
sample_frequency: None,
|
sample_frequency: None,
|
||||||
nr_audio_channels: None,
|
nr_audio_channels: None,
|
||||||
duration: None,
|
duration: metadata.and_then(|m| m.duration.clone()),
|
||||||
url: item.uri.clone(),
|
url: item.uri.clone(),
|
||||||
}],
|
}],
|
||||||
descriptions: Vec::new(),
|
descriptions: Vec::new(),
|
||||||
@@ -1686,6 +1686,7 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata {
|
|||||||
date: None,
|
date: None,
|
||||||
track_number: None,
|
track_number: None,
|
||||||
creator: None,
|
creator: None,
|
||||||
|
duration: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use std::{
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
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();
|
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ impl ChromecastDiscoveryManager {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if addresses.is_empty() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,22 +130,19 @@ impl ChromecastDiscoveryManager {
|
|||||||
|
|
||||||
// Extract friendly name from TXT record "fn" if available
|
// Extract friendly name from TXT record "fn" if available
|
||||||
// Otherwise, extract from service instance name (PTR record)
|
// Otherwise, extract from service instance name (PTR record)
|
||||||
let friendly_name = txt_records
|
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
|
||||||
.get("fn")
|
// Fallback: extract from service name, removing the UUID suffix if present
|
||||||
.cloned()
|
service_name
|
||||||
.unwrap_or_else(|| {
|
.split("._googlecast._tcp.local")
|
||||||
// Fallback: extract from service name, removing the UUID suffix if present
|
.next()
|
||||||
service_name
|
.unwrap_or("Unknown Chromecast")
|
||||||
.split("._googlecast._tcp.local")
|
.split('-')
|
||||||
.next()
|
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||||
.unwrap_or("Unknown Chromecast")
|
.collect::<Vec<_>>()
|
||||||
.split('-')
|
.join("-")
|
||||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
.trim()
|
||||||
.collect::<Vec<_>>()
|
.to_string()
|
||||||
.join("-")
|
});
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug};
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceId;
|
use crate::DeviceId;
|
||||||
use crate::discovery::arylic::detect_arylic_tcp;
|
use crate::discovery::arylic::detect_arylic_tcp;
|
||||||
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
|
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
|
||||||
use crate::media_server::UpnpMediaServer;
|
use crate::media_server::UpnpMediaServer;
|
||||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
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;
|
use ureq::Agent;
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ pub struct ParsedDeviceDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ParsedDeviceDescription {
|
impl ParsedDeviceDescription {
|
||||||
|
|
||||||
/// Fetch and parse the device description.xml at endpoint.location.
|
/// Fetch and parse the device description.xml at endpoint.location.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
udn: &str,
|
udn: &str,
|
||||||
@@ -108,7 +107,7 @@ impl ParsedDeviceDescription {
|
|||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut parsed = ParsedDeviceDescription::default();
|
let mut parsed = ParsedDeviceDescription::default();
|
||||||
|
|
||||||
parsed.timeout_secs=timeout_secs;
|
parsed.timeout_secs = timeout_secs;
|
||||||
parsed.location = location.to_string();
|
parsed.location = location.to_string();
|
||||||
parsed.udn = udn.to_string();
|
parsed.udn = udn.to_string();
|
||||||
parsed.server_header = server_header.to_string();
|
parsed.server_header = server_header.to_string();
|
||||||
@@ -354,9 +353,7 @@ impl ParsedDeviceDescription {
|
|||||||
parsed.require_fields()
|
parsed.require_fields()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_renderer(
|
pub fn build_renderer(&self) -> Option<RendererInfo> {
|
||||||
&self,
|
|
||||||
) -> Option<RendererInfo> {
|
|
||||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||||
&& !device_type.contains("urn:av-openhome-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 udn = self.udn.to_ascii_lowercase();
|
||||||
let mut caps = detect_renderer_capabilities(&self.service_types);
|
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;
|
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;
|
caps.has_arylic_tcp = true;
|
||||||
}
|
}
|
||||||
let protocol = detect_renderer_protocol(&caps);
|
let protocol = detect_renderer_protocol(&caps);
|
||||||
@@ -390,68 +393,54 @@ impl ParsedDeviceDescription {
|
|||||||
self.location.clone(),
|
self.location.clone(),
|
||||||
self.server_header.clone(),
|
self.server_header.clone(),
|
||||||
self.avtransport_service_type.clone(),
|
self.avtransport_service_type.clone(),
|
||||||
self
|
self.avtransport_control_url
|
||||||
.avtransport_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self.rendering_control_service_type.clone(),
|
self.rendering_control_service_type.clone(),
|
||||||
self
|
self.rendering_control_control_url
|
||||||
.rendering_control_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self.connection_manager_service_type.clone(),
|
self.connection_manager_service_type.clone(),
|
||||||
self
|
self.connection_manager_control_url
|
||||||
.connection_manager_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self.oh_playlist_service_type.clone(),
|
self.oh_playlist_service_type.clone(),
|
||||||
self
|
self.oh_playlist_control_url
|
||||||
.oh_playlist_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self
|
self.oh_playlist_event_sub_url
|
||||||
.oh_playlist_event_sub_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&self.location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
self.oh_info_service_type.clone(),
|
self.oh_info_service_type.clone(),
|
||||||
self
|
self.oh_info_control_url
|
||||||
.oh_info_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self
|
self.oh_info_event_sub_url
|
||||||
.oh_info_event_sub_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&self.location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
self.oh_time_service_type.clone(),
|
self.oh_time_service_type.clone(),
|
||||||
self
|
self.oh_time_control_url
|
||||||
.oh_time_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self
|
self.oh_time_event_sub_url
|
||||||
.oh_time_event_sub_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|url| resolve_control_url(&self.location, url)),
|
.map(|url| resolve_control_url(&self.location, url)),
|
||||||
self.oh_volume_service_type.clone(),
|
self.oh_volume_service_type.clone(),
|
||||||
self
|
self.oh_volume_control_url
|
||||||
.oh_volume_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self.oh_radio_service_type.clone(),
|
self.oh_radio_service_type.clone(),
|
||||||
self
|
self.oh_radio_control_url
|
||||||
.oh_radio_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
self.oh_product_service_type.clone(),
|
self.oh_product_service_type.clone(),
|
||||||
self
|
self.oh_product_control_url
|
||||||
.oh_product_control_url
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_server(
|
pub fn build_server(&self) -> Option<UpnpMediaServer> {
|
||||||
&self,
|
|
||||||
) -> Option<UpnpMediaServer> {
|
|
||||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||||
return None;
|
return None;
|
||||||
@@ -480,14 +469,11 @@ impl ParsedDeviceDescription {
|
|||||||
self.content_directory_service_type.clone(),
|
self.content_directory_service_type.clone(),
|
||||||
content_directory_control_url,
|
content_directory_control_url,
|
||||||
))
|
))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
||||||
/// Ok(None) if no AVTransport service was found.
|
/// Ok(None) if no AVTransport service was found.
|
||||||
pub fn build_avtransport_client(
|
pub fn build_avtransport_client(&self) -> Result<Option<AvTransportClient>, DescriptionError> {
|
||||||
&self,
|
|
||||||
) -> Result<Option<AvTransportClient>, DescriptionError> {
|
|
||||||
let service_type = match &self.avtransport_service_type {
|
let service_type = match &self.avtransport_service_type {
|
||||||
Some(st) => st.clone(),
|
Some(st) => st.clone(),
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
@@ -678,9 +664,6 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||||
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||||
let Some(host) = extract_linkplay_host(location) else {
|
let Some(host) = extract_linkplay_host(location) else {
|
||||||
@@ -697,4 +680,4 @@ pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ pub fn playback_item_from_entry(
|
|||||||
date: entry.date.clone(),
|
date: entry.date.clone(),
|
||||||
track_number: entry.track_number.clone(),
|
track_number: entry.track_number.clone(),
|
||||||
creator: entry.creator.clone(),
|
creator: entry.creator.clone(),
|
||||||
|
duration: resource.duration.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ pub struct TrackMetadata {
|
|||||||
pub date: Option<String>,
|
pub date: Option<String>,
|
||||||
pub track_number: Option<String>,
|
pub track_number: Option<String>,
|
||||||
pub creator: Option<String>,
|
pub creator: Option<String>,
|
||||||
|
pub duration: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Copy)]
|
#[derive(Clone, Debug, Copy)]
|
||||||
|
|||||||
@@ -1245,33 +1245,46 @@ pub(crate) fn build_didl_lite_metadata(
|
|||||||
uri: &str,
|
uri: &str,
|
||||||
protocol_info: &str,
|
protocol_info: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
use pmodidl::{DIDLLite, Item, Resource};
|
||||||
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/">
|
use pmoutils::ToXmlElement;
|
||||||
<item id="0" parentID="-1" restricted="1">
|
|
||||||
<dc:title>{}</dc:title>
|
// Construire l'Item DIDL avec toutes les métadonnées
|
||||||
<dc:creator>{}</dc:creator>
|
let item = Item {
|
||||||
<upnp:artist>{}</upnp:artist>
|
id: "0".to_string(),
|
||||||
<upnp:album>{}</upnp:album>
|
parent_id: "-1".to_string(),
|
||||||
{}
|
restricted: Some("1".to_string()),
|
||||||
<res protocolInfo="{}">{}</res>
|
title: metadata
|
||||||
</item>
|
.title
|
||||||
</DIDL-Lite>"#,
|
.clone()
|
||||||
metadata.title.as_deref().unwrap_or("Unknown Title"),
|
.unwrap_or_else(|| "Unknown Title".to_string()),
|
||||||
metadata
|
creator: metadata.creator.clone().or_else(|| metadata.artist.clone()),
|
||||||
.creator
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
.as_deref()
|
artist: metadata.artist.clone(),
|
||||||
.or(metadata.artist.as_deref())
|
album: metadata.album.clone(),
|
||||||
.unwrap_or("Unknown Artist"),
|
genre: metadata.genre.clone(),
|
||||||
metadata.artist.as_deref().unwrap_or("Unknown Artist"),
|
album_art: metadata.album_art_uri.clone(),
|
||||||
metadata.album.as_deref().unwrap_or("Unknown Album"),
|
album_art_pk: None,
|
||||||
metadata
|
date: metadata.date.clone(),
|
||||||
.album_art_uri
|
original_track_number: metadata.track_number.clone(),
|
||||||
.as_ref()
|
resources: vec![Resource {
|
||||||
.map(|art_uri| format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", art_uri))
|
protocol_info: protocol_info.to_string(),
|
||||||
.unwrap_or_default(),
|
bits_per_sample: None,
|
||||||
protocol_info,
|
sample_frequency: None,
|
||||||
uri
|
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 {
|
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 {
|
Ok(PlaybackPositionInfo {
|
||||||
track: track_id,
|
track: track_id,
|
||||||
rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)),
|
rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)),
|
||||||
abs_time: None,
|
abs_time: None,
|
||||||
track_duration: Some(format_hhmmss_u32(time_info.duration_secs)),
|
track_duration,
|
||||||
track_metadata: track_metadata_xml,
|
track_metadata: track_metadata_xml,
|
||||||
track_uri,
|
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 {
|
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||||
match raw.trim().to_ascii_uppercase().as_str() {
|
match raw.trim().to_ascii_uppercase().as_str() {
|
||||||
"PLAYING" => PlaybackState::Playing,
|
"PLAYING" => PlaybackState::Playing,
|
||||||
|
|||||||
@@ -63,17 +63,19 @@ pub fn parse_time_flexible(input: &str) -> Result<u32, ControlPointError> {
|
|||||||
let parts: Vec<&str> = input.split(':').collect();
|
let parts: Vec<&str> = input.split(':').collect();
|
||||||
|
|
||||||
if parts.is_empty() || parts.len() > 3 {
|
if parts.is_empty() || parts.len() > 3 {
|
||||||
return Err(ControlPointError::InvalidTimeFormat(
|
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||||
format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input)
|
"Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS",
|
||||||
));
|
input
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut total = 0u32;
|
let mut total = 0u32;
|
||||||
for part in parts {
|
for part in parts {
|
||||||
let value = part.parse::<u32>().map_err(|_| {
|
let value = part.parse::<u32>().map_err(|_| {
|
||||||
ControlPointError::InvalidTimeFormat(
|
ControlPointError::InvalidTimeFormat(format!(
|
||||||
format!("Invalid numeric value '{}' in time string '{}'", part, input)
|
"Invalid numeric value '{}' in time string '{}'",
|
||||||
)
|
part, input
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
total = total * 60 + value;
|
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();
|
let parts: Vec<&str> = input.split(':').collect();
|
||||||
|
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
return Err(ControlPointError::InvalidTimeFormat(
|
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||||
format!("Invalid time format '{}': expected exactly HH:MM:SS", input)
|
"Invalid time format '{}': expected exactly HH:MM:SS",
|
||||||
));
|
input
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let hours: u64 = parts[0].parse().map_err(|_| {
|
let hours: u64 = parts[0].parse().map_err(|_| {
|
||||||
ControlPointError::InvalidTimeFormat(
|
ControlPointError::InvalidTimeFormat(format!("Invalid hour component in '{}'", input))
|
||||||
format!("Invalid hour component in '{}'", input)
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let minutes: u64 = parts[1].parse().map_err(|_| {
|
let minutes: u64 = parts[1].parse().map_err(|_| {
|
||||||
ControlPointError::InvalidTimeFormat(
|
ControlPointError::InvalidTimeFormat(format!("Invalid minute component in '{}'", input))
|
||||||
format!("Invalid minute component in '{}'", input)
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let seconds: u64 = parts[2].parse().map_err(|_| {
|
let seconds: u64 = parts[2].parse().map_err(|_| {
|
||||||
ControlPointError::InvalidTimeFormat(
|
ControlPointError::InvalidTimeFormat(format!("Invalid second component in '{}'", input))
|
||||||
format!("Invalid second component in '{}'", input)
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if minutes >= 60 || seconds >= 60 {
|
if minutes >= 60 || seconds >= 60 {
|
||||||
return Err(ControlPointError::InvalidTimeFormat(
|
return Err(ControlPointError::InvalidTimeFormat(format!(
|
||||||
format!("Invalid time '{}': minutes and seconds must be < 60", input)
|
"Invalid time '{}': minutes and seconds must be < 60",
|
||||||
));
|
input
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||||
@@ -209,7 +207,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_ms_conversions() {
|
fn test_ms_conversions() {
|
||||||
assert_eq!(ms_to_seconds(1000), 1);
|
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!(ms_to_seconds(999), 0);
|
||||||
|
|
||||||
assert_eq!(seconds_to_ms(1), 1000);
|
assert_eq!(seconds_to_ms(1), 1000);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub struct UpnpRenderer {
|
|||||||
connection_manager: Option<ConnectionManagerClient>,
|
connection_manager: Option<ConnectionManagerClient>,
|
||||||
has_avtransport_set_next: bool,
|
has_avtransport_set_next: bool,
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
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 {
|
impl UpnpRenderer {
|
||||||
@@ -103,6 +105,7 @@ impl UpnpRenderer {
|
|||||||
connection_manager,
|
connection_manager,
|
||||||
has_avtransport_set_next,
|
has_avtransport_set_next,
|
||||||
queue,
|
queue,
|
||||||
|
cached_duration: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +156,7 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
|||||||
connection_manager,
|
connection_manager,
|
||||||
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||||
queue,
|
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
|
// UPNP: SetAVTransportURI + Play
|
||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
avt.set_av_transport_uri(&item.uri, &metadata)?;
|
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`].
|
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||||
///
|
///
|
||||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||||
impl TransportControl for UpnpRenderer {
|
impl TransportControl for UpnpRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
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()?;
|
let avt = self.avtransport()?;
|
||||||
avt.set_av_transport_uri(uri, meta)?;
|
avt.set_av_transport_uri(uri, meta)?;
|
||||||
avt.play(0, "1")
|
avt.play(0, "1")
|
||||||
@@ -381,11 +450,50 @@ impl PlaybackPosition for UpnpRenderer {
|
|||||||
let avt = self.avtransport()?;
|
let avt = self.avtransport()?;
|
||||||
let raw: PositionInfo = avt.get_position_info(0)?;
|
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 {
|
Ok(PlaybackPositionInfo {
|
||||||
track: Some(raw.track),
|
track: Some(raw.track),
|
||||||
rel_time: raw.rel_time,
|
rel_time: raw.rel_time,
|
||||||
abs_time: raw.abs_time,
|
abs_time: raw.abs_time,
|
||||||
track_duration: raw.track_duration,
|
track_duration,
|
||||||
track_metadata: raw.track_metadata,
|
track_metadata: raw.track_metadata,
|
||||||
track_uri: raw.track_uri,
|
track_uri: raw.track_uri,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMe
|
|||||||
date: item.date.clone(),
|
date: item.date.clone(),
|
||||||
track_number: item.original_track_number.clone(),
|
track_number: item.original_track_number.clone(),
|
||||||
creator: item.creator.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());
|
let escaped_protocol_info = escape(item.protocol_info.as_str());
|
||||||
xml.push_str(&format!(
|
|
||||||
r#"<res protocolInfo="{}">{}</res>"#,
|
// Build <res> element with optional duration attribute
|
||||||
escaped_protocol_info, escaped_uri
|
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.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
|
||||||
xml
|
xml
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,19 +274,19 @@ pub fn ensure_success_with_envelope<'a>(
|
|||||||
if let Some(env) = &call_result.envelope {
|
if let Some(env) = &call_result.envelope {
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
return Err(ControlPointError::SoapUpnpParseError(
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
action.to_string(),
|
action.to_string(),
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status.as_u16() as u32,
|
call_result.status.as_u16() as u32,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(ControlPointError::SoapActionWrongBody(
|
return Err(ControlPointError::SoapActionWrongBody(
|
||||||
action.to_string(),
|
action.to_string(),
|
||||||
call_result.status.as_u16() as u32,
|
call_result.status.as_u16() as u32,
|
||||||
call_result.raw_body.clone(),
|
call_result.raw_body.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result
|
||||||
@@ -295,12 +295,12 @@ pub fn ensure_success_with_envelope<'a>(
|
|||||||
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
|
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(ControlPointError::SoapUpnpParseError(
|
return Err(ControlPointError::SoapUpnpParseError(
|
||||||
action.to_string(),
|
action.to_string(),
|
||||||
err.error_code,
|
err.error_code,
|
||||||
err.error_description,
|
err.error_description,
|
||||||
call_result.status.as_u16() as u32,
|
call_result.status.as_u16() as u32,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(envelope)
|
Ok(envelope)
|
||||||
@@ -314,7 +314,10 @@ pub fn handle_action_response(
|
|||||||
ensure_success(action, call_result)
|
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)
|
let child = find_child_with_suffix(parent, suffix)
|
||||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
.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)
|
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)
|
let child = find_child_with_suffix(parent, suffix)
|
||||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
.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> {
|
pub fn extract_child_text_local(
|
||||||
let child = find_child_with_local_name(parent, local)
|
parent: &xmltree::Element,
|
||||||
.ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?;
|
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
|
let text = child
|
||||||
.get_text()
|
.get_text()
|
||||||
.map(|t| t.trim().to_string())
|
.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)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +420,6 @@ pub fn parse_bool(value: &str) -> bool {
|
|||||||
value.trim() == "1"
|
value.trim() == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
||||||
fn value(byte: u8) -> Option<u8> {
|
fn value(byte: u8) -> Option<u8> {
|
||||||
match byte {
|
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' {
|
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let val =
|
let val = value(byte).ok_or_else(|| {
|
||||||
value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?;
|
ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char))
|
||||||
|
})?;
|
||||||
buffer = (buffer << 6) | (val as u32);
|
buffer = (buffer << 6) | (val as u32);
|
||||||
bits_collected += 6;
|
bits_collected += 6;
|
||||||
if bits_collected >= 8 {
|
if bits_collected >= 8 {
|
||||||
@@ -446,7 +458,6 @@ pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_soap_body;
|
use super::build_soap_body;
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ impl AvTransportClient {
|
|||||||
/// - `uri` : CurrentURI
|
/// - `uri` : CurrentURI
|
||||||
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
||||||
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
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 = [
|
let args = [
|
||||||
("InstanceID", "0"),
|
("InstanceID", "0"),
|
||||||
("CurrentURI", uri),
|
("CurrentURI", uri),
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
mod openhome_client;
|
|
||||||
mod avtransport_client;
|
mod avtransport_client;
|
||||||
mod rendering_control_client;
|
|
||||||
mod connection_manager_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::avtransport_client::{AvTransportClient, PositionInfo};
|
||||||
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
|
pub use crate::upnp_clients::connection_manager_client::{
|
||||||
pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
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::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.
|
/// 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(),
|
date: item.date.clone(),
|
||||||
track_number: item.original_track_number.clone(),
|
track_number: item.original_track_number.clone(),
|
||||||
creator: item.creator.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 }
|
utoipa = { version = "5.3", optional = true }
|
||||||
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
||||||
pmoparadise = { path = "../pmoparadise", optional = true }
|
pmoparadise = { path = "../pmoparadise", optional = true }
|
||||||
|
pmoradiofrance = { path = "../pmoradiofrance", optional = true }
|
||||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||||
anyhow = { version = "1.0", optional = true }
|
anyhow = { version = "1.0", optional = true }
|
||||||
pmoaudiocache = { path = "../pmoaudiocache", 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)
|
# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP)
|
||||||
paradise-api = ["paradise", "pmoparadise/pmoserver"]
|
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.
|
// Retourne uniquement le corps DIDL, sans préfixer une seconde déclaration XML.
|
||||||
let body = didl.to_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)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,3 +93,6 @@ pub use paradise_streaming::ParadiseStreamingExt;
|
|||||||
// Re-export sources when features are enabled
|
// Re-export sources when features are enabled
|
||||||
#[cfg(feature = "qobuz")]
|
#[cfg(feature = "qobuz")]
|
||||||
pub use pmoqobuz;
|
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
|
//! Ce module fournit des helpers pour créer et enregistrer facilement des sources
|
||||||
//! musicales préconfigurées à partir de la configuration système.
|
//! musicales préconfigurées à partir de la configuration système.
|
||||||
|
|
||||||
|
use crate::contentdirectory::state;
|
||||||
use pmoserver::Server;
|
use pmoserver::Server;
|
||||||
use pmosource::MusicSourceExt;
|
use pmosource::MusicSourceExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -18,6 +19,10 @@ pub enum SourceInitError {
|
|||||||
#[error("Failed to initialize Radio Paradise: {0}")]
|
#[error("Failed to initialize Radio Paradise: {0}")]
|
||||||
ParadiseError(String),
|
ParadiseError(String),
|
||||||
|
|
||||||
|
#[cfg(feature = "radiofrance")]
|
||||||
|
#[error("Failed to initialize Radio France: {0}")]
|
||||||
|
RadioFranceError(String),
|
||||||
|
|
||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
ConfigError(String),
|
ConfigError(String),
|
||||||
|
|
||||||
@@ -117,6 +122,25 @@ pub trait SourcesExt {
|
|||||||
/// ```
|
/// ```
|
||||||
#[cfg(feature = "paradise")]
|
#[cfg(feature = "paradise")]
|
||||||
async fn register_paradise(&mut self) -> Result<()>;
|
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]
|
#[async_trait::async_trait]
|
||||||
@@ -217,6 +241,50 @@ impl SourcesExt for Server {
|
|||||||
|
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -270,11 +270,7 @@ impl Album {
|
|||||||
pub fn formatted_title(&self) -> String {
|
pub fn formatted_title(&self) -> String {
|
||||||
if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) {
|
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
|
// Convertir Hz en kHz, en gérant les valeurs qui pourraient déjà être en kHz
|
||||||
let rate_khz = if rate > 1000.0 {
|
let rate_khz = if rate > 1000.0 { rate / 1000.0 } else { rate };
|
||||||
rate / 1000.0
|
|
||||||
} else {
|
|
||||||
rate
|
|
||||||
};
|
|
||||||
format!("{} ({:.1} kHz / {} bits)", self.title, rate_khz, depth)
|
format!("{} ({:.1} kHz / {} bits)", self.title, rate_khz, depth)
|
||||||
} else {
|
} else {
|
||||||
self.title.clone()
|
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