feat(pmoradiofrance): implémentation complète du client Radio France
Cette mise à jour implémente complètement le client Radio France avec : - Découverte dynamique des stations (principales, webradios et locales) - Accès aux métadonnées live via l'API publique - Gestion des flux audio HiFi (AAC 192 kbps, HLS) - Support du cache des stations avec TTL configurable - Extension de configuration pour pmoconfig - Exemples d'utilisation et tests d'intégration Les stations découvertes incluent : France Inter, France Info, France Culture, France Musique, FIP, Mouv', France Bleu (locales), et leurs variantes webradios respectives. Les fonctionnalités incluent : - Récupération des métadonnées live (émission en cours, producteur, visuels) - Accès aux flux audio HiFi - Gestion intelligente des rafraîchissements via delayToRefresh - Cache des listes de stations avec TTL configurable (7 jours par défaut) Les tests d'intégration couvrent : découverte des stations, métadonnées live, flux audio, cache, et gestion des erreurs.
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
|
||||||
|
|||||||
206
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
206
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Rapport : Implémentation de pmoradiofrance (Round 3)
|
||||||
|
|
||||||
|
**Date** : 2026-01-22
|
||||||
|
**Crate** : `pmoradiofrance`
|
||||||
|
**Statut** : Implémentation initiale complète
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Création de la crate `pmoradiofrance` qui fournit un client Rust pour accéder aux APIs publiques de Radio France. Le client permet :
|
||||||
|
|
||||||
|
- La découverte dynamique de toutes les stations (~70+)
|
||||||
|
- La récupération des métadonnées live (émission en cours, producteur, visuels)
|
||||||
|
- L'accès aux flux audio HiFi (AAC 192 kbps, HLS)
|
||||||
|
- Le cache de la liste des stations avec TTL configurable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers créés
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pmoradiofrance/Cargo.toml` | Configuration de la crate avec dépendances |
|
||||||
|
| `pmoradiofrance/src/lib.rs` | Point d'entrée et exports publics |
|
||||||
|
| `pmoradiofrance/src/error.rs` | Types d'erreurs (`Error`, `Result`) |
|
||||||
|
| `pmoradiofrance/src/models.rs` | Structures de données pour l'API |
|
||||||
|
| `pmoradiofrance/src/client.rs` | `RadioFranceClient` et `ClientBuilder` |
|
||||||
|
| `pmoradiofrance/src/config_ext.rs` | Extension `RadioFranceConfigExt` pour pmoconfig |
|
||||||
|
| `pmoradiofrance/examples/discover_stations.rs` | Exemple de découverte |
|
||||||
|
| `pmoradiofrance/examples/live_metadata.rs` | Exemple de métadonnées live |
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `Cargo.toml` (racine) | Ajout de `pmoradiofrance` au workspace |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture du client
|
||||||
|
|
||||||
|
### `RadioFranceClient`
|
||||||
|
|
||||||
|
Client HTTP stateless pour interroger les APIs Radio France :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Création
|
||||||
|
let client = RadioFranceClient::new().await?;
|
||||||
|
|
||||||
|
// Découverte des stations
|
||||||
|
let stations = client.discover_all_stations().await?;
|
||||||
|
|
||||||
|
// Métadonnées live
|
||||||
|
let metadata = client.live_metadata("franceculture").await?;
|
||||||
|
|
||||||
|
// URL du flux HiFi
|
||||||
|
let stream_url = client.get_hifi_stream_url("fip_rock").await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Découverte des stations
|
||||||
|
|
||||||
|
Le client découvre dynamiquement :
|
||||||
|
|
||||||
|
1. **Stations principales** (7) : France Inter, France Info, France Culture, France Musique, FIP, Mouv', France Bleu
|
||||||
|
2. **Webradios** (~15-20) : FIP Rock, FIP Jazz, France Musique Baroque, etc.
|
||||||
|
3. **Radios locales France Bleu** (~44) : via le champ `now.localRadios` de l'API
|
||||||
|
|
||||||
|
### Gestion des webradios
|
||||||
|
|
||||||
|
Le parsing des slugs gère automatiquement les webradios :
|
||||||
|
|
||||||
|
| Slug | Base station | Paramètre webradio |
|
||||||
|
|------|--------------|-------------------|
|
||||||
|
| `fip` | `fip` | - |
|
||||||
|
| `fip_rock` | `fip` | `?webradio=fip_rock` |
|
||||||
|
| `francemusique_jazz` | `francemusique` | `?webradio=francemusique_jazz` |
|
||||||
|
| `francebleu_alsace` | `francebleu_alsace` | - (slug direct) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extension de configuration
|
||||||
|
|
||||||
|
Le trait `RadioFranceConfigExt` permet de cacher la liste des stations :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoconfig::get_config;
|
||||||
|
use pmoradiofrance::RadioFranceConfigExt;
|
||||||
|
|
||||||
|
let config = get_config();
|
||||||
|
|
||||||
|
// Vérifier le cache (TTL par défaut : 7 jours)
|
||||||
|
if let Some(stations) = config.get_radiofrance_stations_cached()? {
|
||||||
|
// Utiliser les stations du cache
|
||||||
|
} else {
|
||||||
|
// Découvrir et mettre en cache
|
||||||
|
let client = RadioFranceClient::new().await?;
|
||||||
|
let stations = client.discover_all_stations().await?;
|
||||||
|
config.set_radiofrance_cached_stations(&stations)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration YAML générée
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sources:
|
||||||
|
radiofrance:
|
||||||
|
enabled: true
|
||||||
|
station_cache_ttl_secs: 604800 # 7 jours
|
||||||
|
station_cache:
|
||||||
|
stations: [...]
|
||||||
|
last_updated: 1769112000
|
||||||
|
version: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests d'intégration
|
||||||
|
|
||||||
|
17 tests d'intégration qui appellent la vraie API Radio France :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exécuter tous les tests d'intégration
|
||||||
|
cargo test -p pmoradiofrance -- --ignored
|
||||||
|
|
||||||
|
# Avec output visible
|
||||||
|
cargo test -p pmoradiofrance -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
| Test | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| `test_client_creation` | Création du client | ✅ |
|
||||||
|
| `test_live_metadata_franceculture` | Métadonnées France Culture | ✅ |
|
||||||
|
| `test_live_metadata_franceinter` | Métadonnées France Inter | ✅ |
|
||||||
|
| `test_live_metadata_fip` | Métadonnées FIP (avec chanson) | ✅ |
|
||||||
|
| `test_live_metadata_fip_rock` | Métadonnées webradio FIP Rock | ✅ |
|
||||||
|
| `test_live_metadata_francemusique` | Métadonnées France Musique | ✅ |
|
||||||
|
| `test_live_metadata_francebleu` | Métadonnées + radios locales | ✅ |
|
||||||
|
| `test_live_metadata_mouv` | Métadonnées Mouv' | ✅ |
|
||||||
|
| `test_get_hifi_stream_url` | URLs des flux HiFi | ✅ |
|
||||||
|
| `test_get_available_streams` | Liste des flux disponibles | ✅ |
|
||||||
|
| `test_discover_main_stations` | Découverte stations principales | ✅ |
|
||||||
|
| `test_discover_fip_webradios` | Découverte webradios FIP | ✅ |
|
||||||
|
| `test_discover_francemusique_webradios` | Découverte webradios FM | ✅ |
|
||||||
|
| `test_discover_local_radios` | Découverte radios locales | ✅ |
|
||||||
|
| `test_discover_all_stations` | Découverte complète | ✅ |
|
||||||
|
| `test_invalid_station` | Gestion d'erreur | ✅ |
|
||||||
|
| `test_refresh_delay` | Calcul délai refresh | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Correction effectuée pendant l'implémentation
|
||||||
|
|
||||||
|
**Bug découvert** : Le champ `localRadios` de l'API France Bleu est dans `now.localRadios` (imbriqué dans `ShowMetadata`), pas au niveau racine de `LiveResponse`.
|
||||||
|
|
||||||
|
**Correction** :
|
||||||
|
1. Déplacé le champ `local_radios` de `LiveResponse` vers `ShowMetadata`
|
||||||
|
2. Ajouté une méthode helper `LiveResponse::local_radios()` pour accès simplifié
|
||||||
|
3. Mis à jour le client et les tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features Cargo
|
||||||
|
|
||||||
|
| Feature | Description | Dépendances |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `default` | Configuration de base | `pmoconfig` |
|
||||||
|
| `pmoconfig` | Support extension config | `dep:pmoconfig` |
|
||||||
|
| `cache` | Support cache audio/covers | `pmocovers`, `pmoaudiocache` |
|
||||||
|
| `playlist` | Support playlists FIFO | `pmoplaylist` |
|
||||||
|
| `logging` | Logs avec tracing | - |
|
||||||
|
| `server` | Support serveur complet | Toutes les features |
|
||||||
|
| `full` | Toutes les features | `server`, `logging` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
1. **Implémenter `source.rs`** : Trait `MusicSource` pour intégration UPnP
|
||||||
|
2. **Ajouter support FIFO** : Pour les radios musicales (FIP, France Musique)
|
||||||
|
3. **Cache des métadonnées live** : Respecter `delayToRefresh`
|
||||||
|
4. **Intégration serveur** : Routes API REST via pmoserver
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemples d'utilisation
|
||||||
|
|
||||||
|
### Découverte des stations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p pmoradiofrance --example discover_stations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Métadonnées live
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Station par défaut (France Culture)
|
||||||
|
cargo run -p pmoradiofrance --example live_metadata
|
||||||
|
|
||||||
|
# Station spécifique
|
||||||
|
cargo run -p pmoradiofrance --example live_metadata -- fip_rock
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fin du rapport**
|
||||||
64
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
64
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
** 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 n'est pas une tâche de codage, c'est une tâche de réflexion. Elle doit conduire à la rédaction d'un rapport dans le répertoire [@ToThinkAbout](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout) **
|
||||||
|
|
||||||
|
à parir de la page [web](https://www.radiofrance.fr/franceculture) peux-tu comprendre comment elle obtient les informations prsésenté ci-dessous:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div role="heading" aria-level="1" slot="title" class="CoverRadio-title qg-tt3 svelte-1thibul"><!----><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" data-testid="link" class="svelte-1t7i9vq underline-hover"><!----><!---->Le Journal de l'éco • Le jouet profite de la morosité ambiante<!----></a><!----></span><!----></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
et
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="CoverRadio-subtitle qg-tt5 qg-focus-container svelte-1thibul" slot="subtitle"><!----><!----><!----><a href="/franceculture/podcasts/les-matins" data-testid="link" class="svelte-1t7i9vq"><!---->Les Matins<!----></a><!----> <span class="CoverRadio-producer qg-tx1 svelte-qz676b">par Guillaume Erner</span><!----><!----></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Round 2:
|
||||||
|
|
||||||
|
À partir de [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md) et de [@music_source.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/music_source.md)
|
||||||
|
|
||||||
|
Nous allons commencer l'implémentation de pmoradiofrance en implémentant dans un fichier client.rs Une API de requêtes sur Radio France. Pour les fonctionnalités, il faut effectivement se reporter au fichier `api_radiofrance_complete.md` Et pour l'architecture, il est possible de s'inspirer de [@client.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoparadise/src/client.rs)
|
||||||
|
|
||||||
|
## Round 3
|
||||||
|
|
||||||
|
A partir des découvertes faites durant le round 2, Tu as maintenant le droit d'écrire du code. Tu peux donc écrire le fichier: client.rs de la nouvelle crate pmoradiofrance. Pour cela, tu devras t'appuyer sur les rapports [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md) et de [@music_source.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/music_source.md)
|
||||||
|
|
||||||
|
Il faut absolument cacher les réponses de l'API, Afin de limiter au maximum les requêtes inutiles, Notamment, on sait que les chaînes et les web radios ne changent que très rarement. On peut peut-être penser à faire une extension de configuration [@pmoconfig_ext.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/pmoconfig_ext.md) Pour stocker les informations principales ainsi qu'un timestamp et se dire que sauf requêtes forcées on ne va pas mettre à jour cette liste plus d'une fois par semaine.
|
||||||
|
|
||||||
|
## Round 4
|
||||||
|
|
||||||
|
Je pense qu'il faut maintenant implémenter le deuxième niveau de la Crate Radio France, En implémentant un client Stateful qui peut retourner facilement des listes de radio, des playlists, qui charge ces informations soit à part de la configuration, soit à partir de l'API, suivant que le TTL est dépassé ou pas. Le tout pour préparer la construction de la source Radio France.
|
||||||
|
|
||||||
|
Pensez comme rêgle métier à renommer les choses qui apparaissent comme `France Bleue` en `ICI` Au niveau des labels d'affichage, pas des slugs évidemment.
|
||||||
|
|
||||||
|
Voilà le type d'arborescence de browsing qu'on pourrait avoir.
|
||||||
|
On ne présente à chaque fois qu'un lien vers le flux de plus haute résolution.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
Radio France
|
||||||
|
├── France Culture
|
||||||
|
├── FIP
|
||||||
|
│ ├── FIP
|
||||||
|
│ ├── FIP Cultes
|
||||||
|
│ ├── FIP Nouveautes
|
||||||
|
│ ├── FIP ...
|
||||||
|
│ └── FIP Pop
|
||||||
|
├── Mouv'
|
||||||
|
├── ...
|
||||||
|
└── ICI
|
||||||
|
├── ICI Alsace
|
||||||
|
├── ICI Armorique
|
||||||
|
├── ICI Auxerre
|
||||||
|
├── ...
|
||||||
|
└── ICI Vaucluse
|
||||||
|
```
|
||||||
|
|
||||||
|
Sous le folder principal Radio France, On doit avoir une playlist par station. Les stations qui n'ont qu'une seule chaîne ne contiennent que cette chaîne dans leur PMOplaylist, Les autres, si elles ont une station principale comme FIP, commencent par cette station principale puis leur station annexe.
|
||||||
|
Évidemment, normalement, le contenu en titre des playlists ne doit pas évoluer. Mais les métadonnées si régulièrement Si l'on met le titre de la station comme équivalent d'un titre d'album, Le nom de l'émission pourrait être l'auteur. Et le titre de l'émission du jour, le titre du morceau. Ainsi, au fur et à mesure du temps, on fait évoluer des métadonnées pour changer la couverture, le nom de l'émission, le titre de l'émissions du jour.
|
||||||
|
|
||||||
|
Je pense que les playlists doivent être des playlists volatiles. Les covers doivent être cachés dans [@pmocovers](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocovers) Par contre les URL doivent être passées telles qu'elles, C'est du pur stream, on ne va pas les cacher dans le PMOaudiocache.
|
||||||
|
|
||||||
|
étend le document de réflexion pour proposer une architecture: [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md)
|
||||||
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**
|
||||||
309
Cargo.lock
generated
309
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.10"
|
version = "0.3.11"
|
||||||
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"
|
||||||
@@ -3963,6 +4141,32 @@ dependencies = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pmoradiofrance"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"pmoaudiocache",
|
||||||
|
"pmoconfig",
|
||||||
|
"pmocovers",
|
||||||
|
"pmoplaylist",
|
||||||
|
"pmosource",
|
||||||
|
"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 +4350,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 +4657,7 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-truncate",
|
"unicode-truncate",
|
||||||
"unicode-width",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4836,6 +5046,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 +5084,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 +5203,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 +5300,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 +5417,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 +5793,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 +6297,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 +6306,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]
|
||||||
|
|||||||
80
pmoradiofrance/Cargo.toml
Normal file
80
pmoradiofrance/Cargo.toml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
[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" }
|
||||||
|
|
||||||
|
# Configuration support
|
||||||
|
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||||
|
|
||||||
|
# Cache support
|
||||||
|
pmocovers = { path = "../pmocovers", optional = true }
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||||
|
|
||||||
|
# Playlist management for FIFO support
|
||||||
|
pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["pmoconfig"]
|
||||||
|
# Feature for pmoconfig support
|
||||||
|
pmoconfig = ["dep:pmoconfig"]
|
||||||
|
# Feature for cache support
|
||||||
|
cache = ["dep:pmocovers", "dep:pmoaudiocache"]
|
||||||
|
# Feature for playlist/FIFO support
|
||||||
|
playlist = ["dep:pmoplaylist"]
|
||||||
|
# Feature for logging (tracing)
|
||||||
|
logging = []
|
||||||
|
# Feature for server support (cache registry)
|
||||||
|
server = ["pmosource/server", "pmoconfig", "cache", "playlist"]
|
||||||
|
# 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"
|
||||||
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(())
|
||||||
|
}
|
||||||
1212
pmoradiofrance/src/client.rs
Normal file
1212
pmoradiofrance/src/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
234
pmoradiofrance/src/config_ext.rs
Normal file
234
pmoradiofrance/src/config_ext.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
//! 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
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoconfig::get_config;
|
||||||
|
//! use pmoradiofrance::RadioFranceConfigExt;
|
||||||
|
//!
|
||||||
|
//! 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());
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
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
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// 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
|
||||||
|
/// };
|
||||||
|
/// ```
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
pmoradiofrance/src/error.rs
Normal file
73
pmoradiofrance/src/error.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! 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,
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
103
pmoradiofrance/src/lib.rs
Normal file
103
pmoradiofrance/src/lib.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! 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:
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoconfig::get_config;
|
||||||
|
//! use pmoradiofrance::RadioFranceConfigExt;
|
||||||
|
//!
|
||||||
|
//! 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)?;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
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)]
|
||||||
|
#[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)]
|
||||||
|
#[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)]
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
#[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)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BroadcastType {
|
||||||
|
/// Live stream
|
||||||
|
Live,
|
||||||
|
/// Timeshift (replay) stream
|
||||||
|
Timeshift,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream format
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[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)]
|
||||||
|
#[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)]
|
||||||
|
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)]
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user