From e10f217385d7b5849de343adb366c7c2575532ed Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 22 Jan 2026 19:04:44 +0100 Subject: [PATCH 01/13] =?UTF-8?q?feat(pmoradiofrance):=20impl=C3=A9mentati?= =?UTF-8?q?on=20compl=C3=A8te=20du=20client=20Radio=20France?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .DS_Store | Bin 16388 -> 18436 bytes .gitignore | 8 +- .../Report/Construire_pmoradiofrance.md | 206 +++ .../ToDiscuss/Construire_pmoradiofrance.md | 64 + .../analyse_metadonnees_franceculture.md | 474 +++++++ .../ToThinkAbout/api_radiofrance_complete.md | 1227 +++++++++++++++++ .../client_radiofrance_architecture.md | 831 +++++++++++ Cargo.lock | 309 ++++- Cargo.toml | 4 +- pmoradiofrance/Cargo.toml | 80 ++ pmoradiofrance/examples/discover_stations.rs | 40 + pmoradiofrance/examples/live_metadata.rs | 107 ++ pmoradiofrance/src/client.rs | 1212 ++++++++++++++++ pmoradiofrance/src/config_ext.rs | 234 ++++ pmoradiofrance/src/error.rs | 73 + pmoradiofrance/src/lib.rs | 103 ++ pmoradiofrance/src/models.rs | 518 +++++++ 17 files changed, 5482 insertions(+), 8 deletions(-) create mode 100644 Blackboard/Report/Construire_pmoradiofrance.md create mode 100644 Blackboard/ToDiscuss/Construire_pmoradiofrance.md create mode 100644 Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md create mode 100644 Blackboard/ToThinkAbout/api_radiofrance_complete.md create mode 100644 Blackboard/ToThinkAbout/client_radiofrance_architecture.md create mode 100644 pmoradiofrance/Cargo.toml create mode 100644 pmoradiofrance/examples/discover_stations.rs create mode 100644 pmoradiofrance/examples/live_metadata.rs create mode 100644 pmoradiofrance/src/client.rs create mode 100644 pmoradiofrance/src/config_ext.rs create mode 100644 pmoradiofrance/src/error.rs create mode 100644 pmoradiofrance/src/lib.rs create mode 100644 pmoradiofrance/src/models.rs diff --git a/.DS_Store b/.DS_Store index 5815a533f9321dc2e8c99fd962e8bee7a8d3e080..119d664b890d5a04fcbe2981ee30d29a7b02eb3c 100644 GIT binary patch delta 1573 zcmZ`(Jx^0n7=G^UEmSDBlz@Oj5KxiYro{+q7z~I&lxT>XLoF=|+7GBj!KFkoIv5p? zVe{h;h{~OGan;4y#KeKPxj5>AKJNjfAJFEWbK3hp&-=VzXJOu0cw)2zF#2ZVVc-;x zEi?)N83q%d296dev{$sAg}K`WOh? z#@-6&yZJwcUGB16MvvX;Z8K}CFoh%;l4Q6+h8Vike8V39D#_RW>JR+@)g^Wj+D3`V zioG)<_I97|y_GA+D9#{=YkZkTYB-UOjV01!B%xNvDBw)x5m##K2rUx~VFwiYi29y~ z@*$%IXAz)?6rTj{!&Z={kn{+joOdXw$&79C9oOUG*-iu^ z7{W!2;9Oy(QWwXv>S%rLZ=jGFCR=(xK*utK6FlYr+l6kNz)|&T{~qUKBAe_Ft;ZZ} z;W(p}rjYi{hXjSB(4`b{Q?VB8D?-P}C6T9zmAL0h+evc2#nDHaT{w;N=*L+MaEiB? z9GUhl2oV)T{1>fWbkvne*{x>v*2eO#ca1xtKJ6o+0O#grmC?*e5&o1(0QZDM_#!aB{eBMgK|ej z5W-PbLqu$D}FxYI}4uBI}bXPm0!K2Ust+tse04&~_$8&JLO gz55uB-1I|&F@Osj`(@eHRPQ~tSboIzcYQPe0sG>3jsO4v delta 324 zcmZpfz}V8jsKUU&!01$(V#vT?0c3IjF^Cr6VPIh4Vqj1J(gxfURkTI(fTAow2?mCI zhGd3BAkJV&-7Ltsn0c~+3(LfAfyt|lIVa~B@=xArBrtKa@Mb%!R;JB2O?4PIvvaU; z$ZG-hvojPhXPW#~Y0~DK%392mU#lLe Journal de l'éco • Le jouet profite de la morosité ambiante +``` + +et + +```html +

Les Matins par Guillaume Erner

+``` + +## 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) diff --git a/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md b/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md new file mode 100644 index 00000000..1ebd2833 --- /dev/null +++ b/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md @@ -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 `` +- 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=` : 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 +
+ + + + + +

+ Les Matins + par Guillaume Erner +

+ + +
+

+ En direct +

+
+ +
+``` + +### 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 { + 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, + now: ShowInfo, + next: Vec, + #[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, + #[serde(rename = "firstLinePath")] + first_line_path: Option, + #[serde(rename = "secondLine")] + second_line: String, + cover: String, + #[serde(rename = "startTime")] + start_time: Option, + #[serde(rename = "endTime")] + end_time: Option, +} + +async fn fetch_franceculture_live() -> Result { + let url = "https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player"; + + reqwest::get(url) + .await? + .json::() + .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. diff --git a/Blackboard/ToThinkAbout/api_radiofrance_complete.md b/Blackboard/ToThinkAbout/api_radiofrance_complete.md new file mode 100644 index 00000000..91ef83ab --- /dev/null +++ b/Blackboard/ToThinkAbout/api_radiofrance_complete.md @@ -0,0 +1,1227 @@ +# API Radio France - Documentation complète + +## Vue d'ensemble + +Radio France expose plusieurs APIs publiques **sans authentification** pour accéder aux métadonnées des émissions en direct et aux flux audio. + +**Date d'analyse :** 2026-01-22 +**Sources :** Analyse de fichiers HAR + tests directs + +--- + +## 1. API Live par station + +### Format général +``` +https://www.radiofrance.fr/{station}/api/live? +``` + +### Stations disponibles + +| Station | Endpoint | Status | +|---------|----------|--------| +| France Inter | `/franceinter/api/live?` | ✅ Fonctionne | +| France Info | `/franceinfo/api/live?` | ✅ Fonctionne | +| France Culture | `/franceculture/api/live?` | ✅ Fonctionne | +| France Musique | `/francemusique/api/live?` | ✅ Fonctionne | +| FIP | `/fip/api/live?` | ✅ Fonctionne | +| Mouv' | `/mouv/api/live?` | ✅ Fonctionne | +| France Bleu (national) | `/francebleu/api/live?` | ✅ Fonctionne | +| Mon Petit France Inter | `/monpetitfranceinter/api/live?` | ✅ Fonctionne | + +### Structure de réponse + +```json +{ + "stationName": "franceculture", + "delayToRefresh": 262000, + "migrated": true, + "now": { + "printProgMusic": true, + "startTime": 1769108400, + "endTime": 1769110122, + "producer": "Nom du producteur", + "firstLine": { + "title": "Nom de l'émission", + "id": "uuid-emission", + "path": "franceculture/podcasts/emission" + }, + "secondLine": { + "title": "Titre de l'épisode/chronique", + "id": "uuid-episode", + "path": "franceculture/podcasts/emission/episode" + }, + "thirdLine": { + "title": "Sous-titre éventuel", + "id": "uuid", + "path": null + }, + "intro": "Description de l'émission...", + "reactAvailable": false, + "visualBackground": { + "model": "EmbedImage", + "src": "https://www.radiofrance.fr/pikapi/images/uuid", + "width": 4000, + "height": 1000, + "dominant": "#c8e8f8", + "copyright": "Radio France" + }, + "song": { + "id": "uuid-morceau", + "year": 2024, + "interpreters": ["Artiste"], + "release": { + "label": "Label", + "title": "Album", + "reference": null + } + }, + "media": { + "sources": [ + { + "url": "https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance", + "broadcastType": "live", + "format": "mp3", + "bitrate": 32 + }, + { + "url": "https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance", + "broadcastType": "live", + "format": "hls", + "bitrate": 0 + }, + { + "url": "https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance", + "broadcastType": "live", + "format": "aac", + "bitrate": 192 + }, + { + "url": "https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance", + "broadcastType": "live", + "format": "aac", + "bitrate": 128 + }, + { + "url": "https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance", + "broadcastType": "timeshift", + "format": "hls", + "bitrate": 0 + } + ] + }, + "localRadios": [], + "visuals": { + "card": { /* Image pour la carte */ }, + "player": { /* Image pour le player */ } + } + }, + "next": { + /* Même structure pour l'émission suivante */ + } +} +``` + +### Champs importants + +- **`delayToRefresh`** : Temps en millisecondes avant le prochain rafraîchissement recommandé +- **`now.song`** : Présent si c'est une musique (FIP, France Musique) +- **`now.media.sources`** : Liste de tous les flux disponibles avec formats et bitrates +- **`localRadios`** : Liste des radios locales (pour France Bleu) + +--- + +## 2. API LiveMeta (ancienne API, toujours fonctionnelle) + +### Format +``` +https://api.radiofrance.fr/livemeta/live/{id}/transistor_{station}_player +``` + +### IDs connus + +| Station | ID | Endpoint | +|---------|-----|----------| +| France Culture | 5 | `/livemeta/live/5/transistor_culture_player` | + +### Exemple de réponse + +```json +{ + "prev": [{ + "firstLine": "Le direct", + "secondLine": "France Culture, l'esprit d'ouverture", + "cover": "uuid-image", + "startTime": null, + "endTime": null + }], + "now": { + "firstLine": "La Série fiction", + "firstLineUuid": "uuid", + "firstLinePath": "franceculture/podcasts/emission", + "secondLine": "Titre de l'épisode", + "cover": "uuid-image", + "startTime": 1769108400, + "endTime": 1769110122 + }, + "next": [{ /* émission suivante */ }], + "delayToRefresh": 742000 +} +``` + +**Note :** Cette API retourne moins de détails que `/api/live?` mais fonctionne toujours. + +--- + +## 3. Flux audio + +### Format des URLs + +#### HLS (recommandé) +``` +https://stream.radiofrance.fr/{station}/{station}.m3u8?id=radiofrance +``` + +#### Icecast (AAC et MP3) +``` +https://icecast.radiofrance.fr/{station}-{qualite}.{format}?id=radiofrance +``` + +### Qualités disponibles + +| Qualité | Bitrate AAC | Bitrate MP3 | Utilisation | +|---------|-------------|-------------|-------------| +| `lofi` | 32 kbps | 32 kbps | Connexions lentes | +| `midfi` | 128 kbps | 128 kbps | Standard | +| `hifi` | 192 kbps | - | Haute qualité | + +### Exemples d'URLs + +**France Culture :** +``` +https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance +https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceculture-midfi.mp3?id=radiofrance +https://icecast.radiofrance.fr/franceculture-lofi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance +``` + +**France Inter :** +``` +https://stream.radiofrance.fr/franceinter/franceinter.m3u8?id=radiofrance +https://icecast.radiofrance.fr/franceinter-hifi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceinter-midfi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceinter-midfi.mp3?id=radiofrance +https://icecast.radiofrance.fr/franceinter-lofi.aac?id=radiofrance +https://icecast.radiofrance.fr/franceinter-lofi.mp3?id=radiofrance +``` + +--- + +## 4. Webradios thématiques + +### FIP Webradios + +FIP propose plusieurs webradios thématiques. Format des URLs : + +``` +https://icecast.radiofrance.fr/fip{variant}-{qualite}.aac?id=radiofrance +``` + +#### Variantes disponibles (confirmées) + +| Variante | URL | Status | +|----------|-----|--------| +| FIP principale | `fip-hifi.aac` | ✅ | +| FIP Rock | `fiprock-hifi.aac` | ✅ | +| FIP Jazz | `fipjazz-hifi.aac` | ✅ | +| FIP Groove | `fipgroove-hifi.aac` | ✅ | +| FIP Reggae | `fipreggae-hifi.aac` | ✅ | +| FIP Electro | `fipelectro-hifi.aac` | ✅ | +| FIP Metal | `fipmetal-hifi.aac` | ✅ | +| FIP Nouveautés | `fipnouveautes-hifi.aac` | ✅ | +| FIP Pop | `fippop-hifi.aac` | ✅ | + +**Exemples :** +``` +https://icecast.radiofrance.fr/fiprock-hifi.aac?id=radiofrance +https://icecast.radiofrance.fr/fipjazz-midfi.aac?id=radiofrance +https://icecast.radiofrance.fr/fipgroove-lofi.aac?id=radiofrance +``` + +### France Musique Webradios + +Format similaire : + +``` +https://icecast.radiofrance.fr/francemusique{variant}-{qualite}.aac?id=radiofrance +``` + +#### Variantes disponibles (confirmées) + +| Variante | URL | Status | +|----------|-----|--------| +| France Musique principale | `francemusique-hifi.aac` | ✅ | +| La Jazz | `francemusiquelajazz-hifi.aac` | ✅ | +| La Contemporaine | `francemusiquelacontemporaine-hifi.aac` | ✅ | +| Baroque | `francemusiquebaroque-hifi.aac` | ✅ | +| Opéra | `francemusiqueopera-hifi.aac` | ✅ | + +**Exemples :** +``` +https://icecast.radiofrance.fr/francemusiquelajazz-hifi.aac?id=radiofrance +https://icecast.radiofrance.fr/francemusiquebaroque-midfi.aac?id=radiofrance +``` + +--- + +## 5. France Bleu - Radios locales + +### API +``` +https://www.radiofrance.fr/francebleu/api/live? +``` + +### Structure spécifique + +Le champ `localRadios` contient la liste de toutes les radios locales : + +```json +{ + "stationName": "francebleu", + "delayToRefresh": 2090000, + "now": { /* ... */ }, + "localRadios": [ + { + "id": 12, + "title": "ICI Alsace", + "name": "francebleu_alsace", + "isOnAir": true + }, + { + "id": 13, + "title": "ICI Armorique", + "name": "francebleu_armorique", + "isOnAir": true + } + // ... ~40 radios locales + ] +} +``` + +### Format des flux locaux + +**Hypothèse (à confirmer) :** +``` +https://icecast.radiofrance.fr/fb{nom}-hifi.aac?id=radiofrance +``` + +Exemple : +``` +https://icecast.radiofrance.fr/fbalsace-hifi.aac?id=radiofrance +``` + +--- + +## 6. API Pikapi (Images) + +### Format +``` +https://www.radiofrance.fr/pikapi/images/{uuid}/{taille} +``` + +### Tailles disponibles + +Basé sur l'analyse des réponses, plusieurs tailles semblent disponibles : + +- `88x88` - Miniature +- `200x200` - Petite +- `420x720` - Moyenne portrait +- `560x960` - Grande portrait +- `1200x680` - Grande paysage +- `raw` - Taille originale + +**Exemples :** +``` +https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200 +https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/1200x680 +``` + +--- + +## 7. Autres endpoints (configuration) + +D'après l'analyse du fichier HAR, voici d'autres endpoints internes utilisés : + +### Endpoints de configuration (dans `__data.json`) + +- **`https://kirby.radiofrance.fr`** - CMS Kirby +- **`https://www.radiofrance.fr/pikapi`** - API images +- **`https://www.radiofrance.fr/transistor`** - API Transistor +- **`https://api.radiofrance.fr/livemeta/live`** - API LiveMeta +- **`https://preroll.radiofrance.fr`** - Publicités pre-roll + +### API Expressions (contenu éditorial) + +``` +https://www.radiofrance.fr/api/expressions?variant=vertical&limit=36&ids={uuid,uuid,...} +``` + +Retourne des contenus éditoriaux par UUIDs. + +--- + +## 8. Résumé pour PMOMusic + +### Recommandations d'implémentation + +#### Pour les métadonnées live + +**Option 1 (recommandée) :** API `/api/live?` par station +```rust +async fn fetch_live_metadata(station: &str) -> Result { + let url = format!("https://www.radiofrance.fr/{}/api/live?", station); + reqwest::get(&url).await?.json().await +} +``` + +**Avantages :** +- ✅ Données complètes (émission, producteur, intro, visuels) +- ✅ Flux audio inclus dans la réponse +- ✅ `delayToRefresh` pour polling intelligent +- ✅ Support des radios locales (France Bleu) + +#### Pour les flux audio + +**Priorisation recommandée :** + +1. **HLS** (format moderne, adaptatif) +2. **AAC hifi** (192 kbps, meilleure qualité) +3. **AAC midfi** (128 kbps, bon compromis) +4. **MP3 midfi** (128 kbps, compatibilité maximale) +5. **AAC/MP3 lofi** (32 kbps, fallback) + +#### Polling intelligent + +Utiliser le champ `delayToRefresh` pour optimiser : + +```rust +loop { + let metadata = fetch_live_metadata("franceculture").await?; + + // Afficher/utiliser les métadonnées + println!("{} - {}", + metadata.now.first_line.title, + metadata.now.second_line.title + ); + + // Attendre le temps recommandé + tokio::time::sleep( + Duration::from_millis(metadata.delay_to_refresh) + ).await; +} +``` + +### Liste complète des stations à supporter + +**Stations principales :** +- France Inter +- France Info +- France Culture +- France Musique +- FIP +- Mouv' +- Mon Petit France Inter + +**Webradios FIP (9):** +- FIP principale +- FIP Rock, Jazz, Groove, Reggae, Electro, Metal, Nouveautés, Pop + +**Webradios France Musique (5+):** +- France Musique principale +- La Jazz, La Contemporaine, Baroque, Opéra + +**Radios locales France Bleu (~40):** +- À récupérer dynamiquement via `/francebleu/api/live?` + +--- + +## 9. Points d'attention + +### Rate limiting +- Pas de limite documentée observée +- Utiliser `delayToRefresh` pour respecter les recommandations +- Éviter les requêtes inutiles (cache local) + +### User-Agent +Pour un projet open-source, utiliser un User-Agent identifiable : +``` +PMOMusic/0.3.10 (https://github.com/votre-repo) +``` + +### Gestion d'erreurs +- Les APIs peuvent retourner des données vides (`null`) +- Le champ `song` n'existe que pour les radios musicales +- `localRadios` n'existe que pour France Bleu + +### Respect des CGU +- Ces APIs sont utilisées par le site officiel +- Usage pour un projet open-source personnel/non-commercial +- Ne pas redistribuer les flux audio commercialement + +--- + +## 10. Annexes + +### Exemple complet en Rust + +```rust +use serde::{Deserialize, Serialize}; +use reqwest; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveResponse { + pub station_name: String, + pub delay_to_refresh: u64, + pub migrated: bool, + pub now: ShowMetadata, + pub next: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShowMetadata { + pub start_time: Option, + pub end_time: Option, + pub producer: Option, + pub first_line: Line, + pub second_line: Line, + pub third_line: Option, + pub intro: Option, + pub song: Option, + pub media: Media, +} + +#[derive(Debug, Deserialize)] +pub struct Line { + pub title: Option, + pub id: Option, + pub path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Song { + pub id: String, + pub year: Option, + pub interpreters: Vec, + pub release: Release, +} + +#[derive(Debug, Deserialize)] +pub struct Release { + pub label: Option, + pub title: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Media { + pub sources: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Source { + pub url: String, + pub broadcast_type: String, + pub format: String, + pub bitrate: u32, +} + +pub async fn get_live_metadata(station: &str) -> Result { + let url = format!("https://www.radiofrance.fr/{}/api/live?", station); + + reqwest::Client::new() + .get(&url) + .header("User-Agent", "PMOMusic/0.3.10") + .send() + .await? + .json() + .await +} +``` + +### Stations complètes testées + +| Station | API Live | Flux HLS | Flux AAC | Flux MP3 | +|---------|----------|----------|----------|----------| +| France Inter | ✅ | ✅ | ✅ | ✅ | +| France Info | ✅ | ✅ | ✅ | ✅ | +| France Culture | ✅ | ✅ | ✅ | ✅ | +| France Musique | ✅ | ✅ | ✅ | ✅ | +| FIP | ✅ | ✅ | ✅ | ✅ | +| Mouv' | ✅ | ✅ | ✅ | ✅ | +| France Bleu | ✅ | ✅ | ✅ | ✅ | +| Mon Petit France Inter | ✅ | ✅ (à tester) | ✅ (à tester) | ✅ (à tester) | + +--- + +**Dernière mise à jour :** 2026-01-22 +**Méthode d'analyse :** Capture HAR + tests directs des endpoints +**Statut :** Toutes les APIs sont publiques et fonctionnelles sans authentification + +--- + +# Round 4 : Architecture Client Stateful pour PMORadioFrance + +## Date : 2026-01-22 + +## Contexte + +Le Round 3 a permis d'implémenter un client HTTP basique (`RadioFranceClient`) pour interroger l'API Radio France. Ce client est **stateless** : il ne gère pas de cache, ne maintient pas d'état, et doit interroger l'API à chaque requête. + +Le Round 4 vise à construire la couche suivante : un **client stateful** qui : +- Gère un cache des stations découvertes avec TTL +- Expose des méthodes de haut niveau pour obtenir des listes de radios +- Prépare les données pour la construction de la source UPnP +- Intègre avec pmoconfig pour stocker les informations persistantes + +## Objectifs du client stateful + +### 1. Cache intelligent des stations + +**Problématique** : La découverte de toutes les stations (méthode `discover_all_stations()`) fait ~10 requêtes HTTP et prend 3-5 secondes. Les stations Radio France ne changent que très rarement (nouvelles webradios ~1-2 fois par an, nouvelles stations locales jamais). + +**Solution** : Utiliser pmoconfig pour stocker la liste des stations avec un timestamp, et ne rafraîchir que si le TTL est dépassé (ou sur requête forcée). + +**Stratégie de cache** : +```yaml +# Dans .pmomusic/config.yaml +sources: + radiofrance: + stations_cache: + version: 1 # Version du schéma de découverte + last_updated: 1737565200 # Unix timestamp + ttl_days: 7 # TTL par défaut : 7 jours + stations: + - slug: "franceculture" + name: "France Culture" + type: "main" + - slug: "fip" + name: "FIP" + type: "main" + - slug: "fip_rock" + name: "FIP Rock" + type: "webradio" + parent: "fip" + - slug: "francebleu_alsace" + name: "ICI Alsace" + type: "local" + region: "Alsace" + id: 12 + # ... ~50+ stations au total +``` + +**Logique de rafraîchissement** : +1. Lire le cache depuis la config +2. Vérifier `version` (invalide si ancienne version de découverte) +3. Vérifier TTL : `now - last_updated < ttl_days * 86400` +4. Si valide : retourner le cache +5. Si invalide ou absent : appeler `discover_all_stations()` et mettre à jour la config + +### 2. Organisation des stations + +Les stations doivent être organisées logiquement pour la navigation UPnP : + +``` +Radio France (racine) +├── France Culture +├── France Inter +├── France Info +├── France Musique +├── FIP +│ ├── FIP (principale) +│ ├── FIP Rock +│ ├── FIP Jazz +│ ├── FIP Groove +│ ├── FIP Reggae +│ ├── FIP Electro +│ ├── FIP Metal +│ ├── FIP Nouveautés +│ └── FIP Pop +├── Mouv' +└── ICI (France Bleu renommé) + ├── ICI Alsace + ├── ICI Armorique + ├── ICI Auxerre + ├── ... (~40 radios locales) + └── ICI Vaucluse +``` + +**Règles de regroupement** : +- **Stations principales** : Une entrée par station principale (France Culture, France Inter, etc.) +- **Stations avec webradios** (FIP, France Musique) : Un folder contenant : + 1. La station principale en premier + 2. Les webradios triées alphabétiquement +- **France Bleu** : Renommé "ICI" avec toutes les radios locales dedans + +**Changement de label** : +- API retourne : `"France Bleu"` → Affichage : `"ICI"` +- API retourne : `"ICI Alsace"` → Affichage : `"ICI Alsace"` (inchangé) +- Slugs conservés tels quels : `francebleu_alsace`, `francebleu`, etc. + +### 3. Métadonnées live avec rafraîchissement intelligent + +Pour chaque station, on doit pouvoir obtenir les métadonnées live avec cache court terme : + +**Cache de métadonnées live** : +- Durée : Utiliser le champ `delayToRefresh` de l'API (généralement 2-5 minutes) +- Stockage : En mémoire uniquement (pas dans pmoconfig) +- Invalidation : Automatique après `delayToRefresh` millisecondes + +**Stratégie** : +```rust +struct LiveMetadataCache { + metadata: LiveResponse, + fetched_at: SystemTime, + valid_until: SystemTime, +} + +// Pseudo-code +fn get_live_metadata(station: &str) -> Result { + if let Some(cached) = memory_cache.get(station) { + if SystemTime::now() < cached.valid_until { + return Ok(cached.metadata.clone()); + } + } + + let metadata = client.live_metadata(station).await?; + let delay = Duration::from_millis(metadata.delay_to_refresh); + + memory_cache.insert(station, LiveMetadataCache { + metadata: metadata.clone(), + fetched_at: SystemTime::now(), + valid_until: SystemTime::now() + delay, + }); + + Ok(metadata) +} +``` + +### 4. Construction de playlists pour la source UPnP + +Chaque station doit être exposée comme une **playlist volatile** contenant un seul item : le stream de plus haute qualité. + +**Règles métier pour les playlists** : + +#### Format des playlists + +```rust +// Pseudo-structure d'une playlist de station +PMOPlaylist { + id: "radiofrance:franceculture", + role: PlaylistRole::Radio, + volatile: true, // Les métadonnées changent, pas le contenu + + // Métadonnées de la playlist (= métadonnées de la station) + title: "France Culture", // Nom de la station + artist: "Les Matins", // Nom de l'émission en cours (now.firstLine.title) + album: "France Culture", // Nom de la station (répété) + cover_pk: "COVER_PK", // Cover de l'émission en cours (now.visualBackground) + + // Contenu : UN SEUL ITEM + items: [ + PMOItem { + id: "radiofrance:franceculture:stream", + title: "Le Journal de l'éco • Le jouet profite...", // now.secondLine.title + artist: "Guillaume Erner", // now.producer ou show producer + album: "Les Matins", // now.firstLine.title (émission) + genre: "Talk Radio", // Type de station + + // Stream URL (AAC 192 kbps ou HLS) + url: "https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance", + + // Métadonnées techniques + protocol_info: "http-get:*:audio/aac:*", + bitrate: 192000, + sample_rate: 48000, + channels: 2, + + // Cover de l'émission/morceau + cover_pk: "COVER_PK", + } + ] +} +``` + +#### Mapping des métadonnées API → UPnP + +**Pour les radios parlées (France Culture, France Inter, France Info)** : + +| Champ UPnP | Source API | Exemple | +|------------|------------|---------| +| Playlist Title | Station name | "France Culture" | +| Playlist Artist | `now.firstLine.title` | "Les Matins" | +| Playlist Cover | `now.visualBackground` → cache | UUID de cover | +| Item Title | `now.firstLine.title` + `now.secondLine.title` | "Les Matins • Le Journal de l'éco" | +| Item Artist | `now.producer` | "Guillaume Erner" | +| Item Album | `now.firstLine.title` | "Les Matins" | +| Item Cover | `now.visualBackground` → cache | UUID de cover | +| Item Genre | "Talk Radio" | Fixe | + +**Pour les radios musicales (FIP, France Musique)** : + +| Champ UPnP | Source API | Exemple | +|------------|------------|---------| +| Playlist Title | Station name | "FIP Rock" | +| Playlist Artist | `now.song.artists` OU `now.firstLine.title` | "The Rolling Stones" | +| Playlist Cover | `now.song` image OU `now.visualBackground` | UUID de cover | +| Item Title | `now.song.title` OU `now.firstLine.title` | "Paint It Black" | +| Item Artist | `now.song.artists` | "The Rolling Stones" | +| Item Album | `now.song.release.title` | "Aftermath" | +| Item Cover | `now.song` image OU `now.visualBackground` | UUID de cover | +| Item Genre | "Music" OU genre spécifique | "Rock" | + +**Note importante** : Les métadonnées changent régulièrement (toutes les 2-5 minutes), mais l'URL du stream reste la même. C'est pour cela que les playlists sont **volatiles** : on ne change pas leur contenu (toujours 1 item), mais on met à jour les métadonnées de cet item. + +#### Gestion des covers + +**Stratégie de cache** : +- Les covers doivent être cachées dans `pmocovers` +- URL source : `now.visualBackground.src` ou image de `now.song` +- Extraction UUID : Parser l'URL Pikapi pour extraire l'UUID +- Transformation : Télécharger et convertir en WebP si nécessaire +- Stockage : Cache avec le PK = `RADIOFRANCE:{uuid}` + +**Workflow de cache de cover** : +```rust +// Pseudo-code +async fn cache_cover_from_metadata(metadata: &ShowMetadata) -> Option { + // 1. Extraire l'URL de l'image + let image_url = metadata.visual_background.as_ref()?.src.clone(); + + // 2. Extraire UUID + let uuid = extract_uuid_from_url(&image_url)?; + + // 3. Construire URL en haute résolution + let hires_url = ImageSize::XLarge.build_url(&uuid); + + // 4. Cacher avec pmocovers + let cover_pk = cache_manager.cache_cover(&hires_url).await.ok()?; + + Some(cover_pk) +} +``` + +**Tailles de cover** : +- Pour les métadonnées UPnP : Utiliser `ImageSize::XLarge` (1200x680) ou `ImageSize::Large` (560x960) +- Pikapi supporte plusieurs tailles, on choisit la plus grande disponible + +#### URL des streams + +**Règle métier** : Ne présenter que le stream de **plus haute résolution** disponible. + +**Priorité de sélection** : +1. AAC 192 kbps (HiFi) : `https://icecast.radiofrance.fr/{station}-hifi.aac?id=radiofrance` +2. HLS adaptatif : `https://stream.radiofrance.fr/{station}/{station}.m3u8?id=radiofrance` +3. AAC 128 kbps (MidFi) : Fallback si HiFi indisponible +4. MP3 128 kbps : Fallback ultime + +**Pas de cache audio** : Les streams sont des flux en direct, on ne les cache JAMAIS dans `pmoaudiocache`. Les URLs sont passées telles quelles au renderer. + +### 5. Interface du client stateful + +**Proposition d'API publique** : + +```rust +/// Client stateful pour Radio France avec cache et gestion d'état +pub struct RadioFranceStatefulClient { + client: RadioFranceClient, // Client HTTP basique + config: Arc, // Configuration pmoconfig + metadata_cache: Arc>>, + cache_manager: SourceCacheManager, // Pour covers +} + +impl RadioFranceStatefulClient { + /// Créer un nouveau client stateful + pub async fn new() -> Result; + + /// Créer avec un client HTTP personnalisé + pub fn with_client_and_config( + client: RadioFranceClient, + config: Arc, + ) -> Self; + + // ======================================================================== + // Station Discovery (avec cache) + // ======================================================================== + + /// Obtenir toutes les stations (depuis cache si valide, sinon découverte) + pub async fn get_all_stations(&self) -> Result>; + + /// Forcer la redécouverte des stations (ignore le cache) + pub async fn refresh_stations(&self) -> Result>; + + /// Obtenir les stations principales uniquement + pub async fn get_main_stations(&self) -> Result>; + + /// Obtenir les webradios d'une station (ex: FIP Rock, FIP Jazz) + pub async fn get_webradios(&self, parent_station: &str) -> Result>; + + /// Obtenir les radios locales ICI (France Bleu) + pub async fn get_local_radios(&self) -> Result>; + + // ======================================================================== + // Organisation hiérarchique + // ======================================================================== + + /// Obtenir les stations organisées par groupe + pub async fn get_stations_by_group(&self) -> Result; + + // ======================================================================== + // Métadonnées live (avec cache court terme) + // ======================================================================== + + /// Obtenir les métadonnées live d'une station (cache 2-5 min) + pub async fn get_live_metadata(&self, station: &str) -> Result; + + /// Forcer le rafraîchissement des métadonnées (ignore le cache) + pub async fn refresh_live_metadata(&self, station: &str) -> Result; + + // ======================================================================== + // Construction de playlists + // ======================================================================== + + /// Construire une playlist UPnP pour une station + pub async fn build_station_playlist(&self, station: &str) -> Result; + + /// Mettre à jour les métadonnées d'une playlist existante + pub async fn update_playlist_metadata( + &self, + station: &str, + playlist: &mut StationPlaylist, + ) -> Result<()>; + + // ======================================================================== + // Helpers + // ======================================================================== + + /// Obtenir l'URL du stream HiFi pour une station + pub async fn get_stream_url(&self, station: &str) -> Result; + + /// Vérifier si le cache des stations est valide + pub fn is_station_cache_valid(&self) -> bool; + + /// Obtenir l'âge du cache des stations (en secondes) + pub fn station_cache_age_secs(&self) -> Option; +} + +/// Groupes de stations organisés hiérarchiquement +pub struct StationGroups { + /// Stations principales sans webradios (France Culture, France Inter, etc.) + pub standalone: Vec, + + /// Stations avec webradios (FIP, France Musique) + pub with_webradios: Vec, + + /// Radios locales ICI (France Bleu) + pub local_radios: Vec, +} + +/// Groupe de stations (principale + webradios) +pub struct StationGroup { + /// Station principale + pub main: Station, + + /// Webradios associées (triées alphabétiquement) + pub webradios: Vec, +} + +/// Playlist UPnP pour une station +pub struct StationPlaylist { + /// ID de la playlist + pub id: String, + + /// Station source + pub station: Station, + + /// Métadonnées de la playlist (changent avec les émissions) + pub metadata: PlaylistMetadata, + + /// Item unique (stream) + pub stream_item: StreamItem, +} + +/// Métadonnées de playlist (volatiles) +pub struct PlaylistMetadata { + pub title: String, // Nom de la station + pub artist: Option, // Émission en cours + pub album: Option, // Nom de la station (répété) + pub cover_pk: Option, // Cover cachée +} + +/// Item de stream +pub struct StreamItem { + pub id: String, + pub title: String, // Titre de l'émission/morceau + pub artist: Option, // Producteur/artiste + pub album: Option, // Nom de l'émission/album + pub genre: Option, + pub url: String, // URL du stream (AAC HiFi ou HLS) + pub protocol_info: String, + pub bitrate: Option, + pub sample_rate: Option, + pub channels: Option, + pub cover_pk: Option, +} +``` + +### 6. Extension de configuration (config_ext.rs) + +**Trait d'extension pour pmoconfig** : + +```rust +pub trait RadioFranceConfigExt { + // ======================================================================== + // Activation de la source + // ======================================================================== + + fn get_radiofrance_enabled(&self) -> Result; + fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()>; + + // ======================================================================== + // Cache des stations + // ======================================================================== + + fn get_radiofrance_stations_cache(&self) -> Result>; + fn set_radiofrance_stations_cache(&self, cache: &CachedStationList) -> Result<()>; + fn clear_radiofrance_stations_cache(&self) -> Result<()>; + + fn get_radiofrance_cache_ttl_days(&self) -> Result; + fn set_radiofrance_cache_ttl_days(&self, days: u64) -> Result<()>; + + // ======================================================================== + // Configuration client HTTP + // ======================================================================== + + fn get_radiofrance_base_url(&self) -> Result; + fn set_radiofrance_base_url(&self, url: String) -> Result<()>; + + fn get_radiofrance_timeout_secs(&self) -> Result; + fn set_radiofrance_timeout_secs(&self, secs: u64) -> Result<()>; + + // ======================================================================== + // Factory method + // ======================================================================== + + fn create_radiofrance_client(&self) -> Result; +} +``` + +**Chemins de configuration** : + +```yaml +sources: + radiofrance: + enabled: true # Activation de la source + base_url: "https://www.radiofrance.fr" + timeout_secs: 30 + cache_ttl_days: 7 # TTL du cache des stations + + stations_cache: # Cache des stations découvertes + version: 1 + last_updated: 1737565200 + stations: + - slug: "franceculture" + name: "France Culture" + type: "main" + # ... reste des stations +``` + +## Workflow de mise à jour des métadonnées + +### Scénario 1 : Première utilisation + +1. Utilisateur ouvre la source Radio France dans son client UPnP +2. `RadioFranceSource::browse("radiofrance")` est appelé +3. Source appelle `stateful_client.get_all_stations()` +4. Cache vide → Appel `discover_all_stations()` (~3-5 secondes) +5. Résultat stocké dans config avec timestamp +6. Retour de la liste des stations + +### Scénario 2 : Utilisation ultérieure (cache valide) + +1. Utilisateur ouvre la source Radio France +2. Source appelle `stateful_client.get_all_stations()` +3. Cache présent et valide (< 7 jours) → Retour immédiat depuis config +4. Pas d'appel réseau + +### Scénario 3 : Lecture d'une station + +1. Utilisateur sélectionne "France Culture" et lance la lecture +2. Source appelle `stateful_client.build_station_playlist("franceculture")` +3. Stateful client : + - Appelle `get_live_metadata("franceculture")` (cache 2-5 min si présent) + - Extrait les métadonnées de l'émission en cours + - Cache la cover de l'émission via `pmocovers` + - Construit la playlist avec 1 item (stream HiFi) +4. Retour de la playlist au renderer + +### Scénario 4 : Mise à jour des métadonnées pendant la lecture + +1. Renderer lit le stream depuis 3 minutes +2. Control point demande les métadonnées à jour +3. Source appelle `stateful_client.update_playlist_metadata()` +4. Stateful client : + - Vérifie le cache des métadonnées live + - Si expiré (> `delayToRefresh` ms) : appelle l'API + - Met à jour les métadonnées de la playlist + - Cache la nouvelle cover si différente +5. Control point reçoit les nouvelles métadonnées + +**Important** : L'URL du stream ne change JAMAIS pendant la lecture. Seules les métadonnées (titre, artiste, cover) changent. + +## Architecture des fichiers + +``` +pmoradiofrance/ +├── src/ +│ ├── lib.rs # Exports publics +│ ├── client.rs # Client HTTP basique (Round 3) ✅ +│ ├── models.rs # Structures de données (Round 3) ✅ +│ ├── error.rs # Types d'erreur ✅ +│ ├── stateful_client.rs # Client stateful (Round 4) 🆕 +│ ├── playlist.rs # Construction de playlists (Round 4) 🆕 +│ ├── config_ext.rs # Extension pmoconfig (Round 4) 🆕 +│ └── source.rs # Implémentation MusicSource (Round 5) +├── assets/ +│ └── default.webp # Logo Radio France 300x300px +├── Cargo.toml +└── README.md +``` + +## Dépendances supplémentaires + +```toml +[dependencies] +# Déjà présentes (Round 3) +reqwest = { version = "0.12", features = ["json"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +chrono = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +url = "2.5" +scraper = "0.22" +regex = "1.11" +pmosource = { path = "../pmosource" } + +# Nouvelles (Round 4) +pmoconfig = { path = "../pmoconfig" } # Configuration persistante +pmocovers = { path = "../pmocovers" } # Cache de covers +# pmoaudiocache NON utilisé (pas de cache audio pour les streams live) + +[features] +default = ["pmoconfig"] +pmoconfig = ["dep:pmoconfig"] +cache = ["dep:pmocovers"] +logging = [] +server = ["pmosource/server", "pmoconfig", "cache"] +full = ["server", "logging"] +``` + +## Considérations d'implémentation + +### Thread safety + +Le client stateful doit être thread-safe car il sera partagé entre plusieurs threads (ContentDirectory, AVTransport, etc.) : + +```rust +pub struct RadioFranceStatefulClient { + client: RadioFranceClient, // Clone cheap (Arc interne) + config: Arc, // Partagé + metadata_cache: Arc>>, // Cache mémoire protégé + cache_manager: SourceCacheManager, // Thread-safe +} + +impl Clone for RadioFranceStatefulClient { + fn clone(&self) -> Self { + // Clone cheap : tous les champs sont Arc ou Clone + Self { + client: self.client.clone(), + config: self.config.clone(), + metadata_cache: self.metadata_cache.clone(), + cache_manager: self.cache_manager.clone(), + } + } +} +``` + +### Performances + +**Cache des stations** : +- Stockage : YAML dans config (~10-20 KB pour ~50 stations) +- Lecture : Désérialisation YAML (~1-2 ms) +- TTL : 7 jours (configurable) + +**Cache des métadonnées live** : +- Stockage : Mémoire (HashMap) +- Taille : ~5-10 KB par station +- TTL : 2-5 minutes (champ `delayToRefresh` de l'API) +- Limite : ~100 stations max = ~1 MB max + +**Cache des covers** : +- Via `pmocovers` (LRU disk cache) +- Taille moyenne : 50-200 KB par cover WebP +- Limite : Configurable via `pmocovers` (défaut : 2000 items) + +### Gestion d'erreurs + +**Stratégie de fallback** : + +1. **Cache des stations invalide ou absent** → Redécouverte (erreur propagée si échec) +2. **Métadonnées live indisponibles** → Utiliser cache expiré si présent, sinon erreur +3. **Cover indisponible** → Utiliser cover par défaut de la source +4. **Stream HiFi indisponible** → Fallback sur HLS puis AAC MidFi + +### Logging + +Utiliser `tracing` pour logger : +- Découverte des stations (nombre, durée) +- Hits/miss du cache +- Rafraîchissement des métadonnées +- Erreurs réseau + +## Tests + +### Tests unitaires + +- Validation du cache (TTL, version, invalidation) +- Parsing des métadonnées +- Construction des playlists +- Mapping API → UPnP + +### Tests d'intégration + +- Découverte réelle des stations +- Récupération des métadonnées live +- Cache et invalidation +- Construction de playlists complètes + +## Prochaines étapes (Round 5) + +Le Round 5 implémentera la `MusicSource` finale qui : +- Utilise le `RadioFranceStatefulClient` +- Implémente le trait `MusicSource` de `pmosource` +- Expose l'arborescence UPnP ContentDirectory +- Gère les playlists volatiles via `pmoplaylist` +- Notifie les changements de métadonnées + +--- + +**Fin du Round 4** diff --git a/Blackboard/ToThinkAbout/client_radiofrance_architecture.md b/Blackboard/ToThinkAbout/client_radiofrance_architecture.md new file mode 100644 index 00000000..e84837a1 --- /dev/null +++ b/Blackboard/ToThinkAbout/client_radiofrance_architecture.md @@ -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, + 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, + pub local_radios: Option>, // 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, + pub end_time: Option, + pub producer: Option, + pub first_line: Line, // Titre émission + pub second_line: Line, // Titre épisode/chronique + pub third_line: Option, // Sous-titre + pub intro: Option, // Description + pub song: Option, // Pour radios musicales (FIP, France Musique) + pub media: Media, // Flux audio disponibles + pub visual_background: Option, + pub visuals: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Line { + pub title: Option, + pub id: Option, + pub path: Option, +} +``` + +### 4. Morceau musical (FIP, France Musique) + +```rust +#[derive(Debug, Clone, Deserialize)] +pub struct Song { + pub id: String, + pub year: Option, + pub interpreters: Vec, + pub release: Release, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Release { + pub label: Option, + pub title: Option, + pub reference: Option, +} +``` + +### 5. Flux audio + +```rust +#[derive(Debug, Clone, Deserialize)] +pub struct Media { + pub sources: Vec, +} + +#[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, + pub height: Option, + pub dominant: Option, + pub copyright: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Visuals { + pub card: Option, + pub player: Option, +} + +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::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> { + 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> { + 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> { + 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> { + 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::() + c.as_str(), + } + }) + .collect::>() + .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 { + 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 { + 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 { + 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> { + 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 { + 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> { + 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> { + 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> { + 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> { + 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** diff --git a/Cargo.lock b/Cargo.lock index 3da10aa3..5f2098ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.10" +version = "0.3.11" dependencies = [ "axum 0.8.7", "console-subscriber", @@ -693,7 +693,7 @@ dependencies = [ "bevy_ptr", "bevy_reflect_derive", "bevy_utils", - "derive_more", + "derive_more 2.0.1", "disqualified", "downcast-rs", "erased-serde", @@ -1276,6 +1276,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.110", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1335,6 +1358,17 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -1438,12 +1472,33 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + [[package]] name = "either" version = "1.15.0" @@ -1708,6 +1763,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -1810,6 +1875,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gcc" version = "0.3.55" @@ -1848,6 +1922,15 @@ dependencies = [ "libc", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -2044,6 +2127,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -2713,6 +2808,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mach2" version = "0.4.3" @@ -2722,6 +2823,31 @@ dependencies = [ "libc", ] +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3528,6 +3654,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -3963,6 +4141,32 @@ dependencies = [ "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]] name = "pmoserver" version = "0.1.0" @@ -4146,6 +4350,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -4447,7 +4657,7 @@ dependencies = [ "strum", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -4836,6 +5046,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4859,6 +5084,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags 2.10.0", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.27" @@ -4959,6 +5203,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5047,6 +5300,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -5158,6 +5417,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strum" version = "0.26.3" @@ -5509,6 +5793,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6002,7 +6297,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -6011,6 +6306,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 8d468211..0ddf5dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,12 @@ members = [ "pmoaudio", "pmoqobuz", "pmoparadise", + "pmoradiofrance", "pmosource", "pmoplaylist", "pmoflac", - "pmometadata", "pmocontrol", + "pmometadata", + "pmocontrol", ] [workspace.dependencies] diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml new file mode 100644 index 00000000..1784c26c --- /dev/null +++ b/pmoradiofrance/Cargo.toml @@ -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" diff --git a/pmoradiofrance/examples/discover_stations.rs b/pmoradiofrance/examples/discover_stations.rs new file mode 100644 index 00000000..e47dfe3a --- /dev/null +++ b/pmoradiofrance/examples/discover_stations.rs @@ -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> { + // 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(()) +} diff --git a/pmoradiofrance/examples/live_metadata.rs b/pmoradiofrance/examples/live_metadata.rs new file mode 100644 index 00000000..1a563e3a --- /dev/null +++ b/pmoradiofrance/examples/live_metadata.rs @@ -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> { + // 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(()) +} diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs new file mode 100644 index 00000000..25fcc489 --- /dev/null +++ b/pmoradiofrance/src/client.rs @@ -0,0 +1,1212 @@ +//! HTTP client for Radio France API +//! +//! This module provides a client for accessing Radio France's public APIs, +//! including station discovery, live metadata, and stream URLs. +//! +//! # Example +//! +//! ```no_run +//! use pmoradiofrance::RadioFranceClient; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let client = RadioFranceClient::new().await?; +//! +//! // Get live metadata for France Culture +//! let live = client.live_metadata("franceculture").await?; +//! println!("{} - {}", +//! 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(()) +//! } +//! ``` + +use crate::error::{Error, Result}; +use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource}; +use regex::Regex; +use reqwest::Client; +use scraper::{Html, Selector}; +use std::collections::HashSet; +use std::time::Duration; +use url::Url; + +/// Default Radio France base URL +pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr"; + +/// Default timeout for HTTP requests (30 seconds) +pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// Default User-Agent +pub const DEFAULT_USER_AGENT: &str = "PMOMusic/0.3.10 (pmoradiofrance)"; + +/// Known main stations (fallback if scraping fails) +pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ + ("franceinter", "France Inter"), + ("franceinfo", "France Info"), + ("franceculture", "France Culture"), + ("francemusique", "France Musique"), + ("fip", "FIP"), + ("mouv", "Mouv'"), + ("francebleu", "France Bleu"), +]; + +/// Radio France HTTP client +/// +/// This client provides access to Radio France's public APIs for: +/// - Station discovery (main stations, webradios, local radios) +/// - Live metadata (current show, next show, stream URLs) +/// - Image URL construction (Pikapi) +/// +/// The client is stateless and does not cache responses internally. +/// Caching should be handled by higher layers (e.g., config extension). +#[derive(Debug, Clone)] +pub struct RadioFranceClient { + pub(crate) client: Client, + base_url: String, + timeout: Duration, +} + +impl RadioFranceClient { + /// Create a new client with default settings + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Create a builder for configuring the client + pub fn builder() -> ClientBuilder { + ClientBuilder::default() + } + + /// Create a client with a custom reqwest::Client + /// + /// Useful for sharing HTTP connection pools or custom proxy settings + pub fn with_client(client: Client) -> Self { + Self { + client, + base_url: DEFAULT_BASE_URL.to_string(), + timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + } + } + + /// Get the base URL + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Get the internal HTTP client + pub fn http_client(&self) -> &Client { + &self.client + } + + // ======================================================================== + // Station Discovery + // ======================================================================== + + /// Discover all available stations + /// + /// This method discovers stations through multiple sources: + /// 1. Main stations from the homepage + /// 2. Webradios for each main station (FIP, France Musique, etc.) + /// 3. Local radios from France Bleu API + /// + /// # Caching + /// + /// This method does NOT cache results. For caching, use the config extension + /// which stores results with a configurable TTL. + /// + /// # Performance + /// + /// This method makes multiple HTTP requests (~10) and may take 3-5 seconds. + /// Consider caching the results. + pub async fn discover_all_stations(&self) -> Result> { + let mut stations = Vec::new(); + + // 1. Discover main stations + let main_stations = self.discover_main_stations().await?; + + // 2. For each main station, discover webradios + // Note: Skip francebleu because its "webradios" are actually local radios + // which we get from the API with proper "ICI" names + for main_station in &main_stations { + stations.push(main_station.clone()); + + // Skip francebleu - its local radios are discovered via API below + if main_station.slug == "francebleu" { + continue; + } + + // Try to discover webradios (may return empty for some stations) + if let Ok(webradios) = self.discover_station_webradios(&main_station.slug).await { + stations.extend(webradios); + } + } + + // 3. Discover France Bleu local radios via API (with correct "ICI" names) + if let Ok(locals) = self.discover_local_radios().await { + stations.extend(locals); + } + + Ok(stations) + } + + /// Discover main stations from the homepage + /// + /// Scrapes the Radio France homepage to find main station links. + /// Falls back to known stations if scraping fails. + pub async fn discover_main_stations(&self) -> Result> { + let html = self + .client + .get(&self.base_url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; + + let mut slugs = HashSet::new(); + + // Method 1: Parse HTML and look for station links + let document = Html::parse_document(&html); + + // Look for links to station pages + if let Ok(selector) = Selector::parse("a[href]") { + for element in document.select(&selector) { + if let Some(href) = element.value().attr("href") { + // Match patterns like /franceculture, /fip, etc. + if let Some(slug) = self.extract_station_slug_from_href(href) { + slugs.insert(slug); + } + } + } + } + + // Method 2: Regex fallback for station names in JavaScript/JSON + let re = Regex::new( + r#"["'/](franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu)["'/]"#, + )?; + for cap in re.captures_iter(&html) { + slugs.insert(cap[1].to_string()); + } + + // If we found stations, convert to Station objects + if !slugs.is_empty() { + return Ok(slugs + .into_iter() + .map(|slug| { + let name = Self::slug_to_display_name(&slug); + Station::main(slug, name) + }) + .collect()); + } + + // Fallback to known stations + #[cfg(feature = "logging")] + tracing::warn!("Station discovery from HTML failed, using fallback list"); + + Ok(KNOWN_MAIN_STATIONS + .iter() + .map(|(slug, name)| Station::main(*slug, *name)) + .collect()) + } + + /// Discover webradios for a given main station + /// + /// Scrapes the station page to find webradio identifiers. + /// Works for FIP, France Musique, and potentially other stations. + pub async fn discover_station_webradios(&self, station: &str) -> Result> { + let url = format!("{}/{}", self.base_url, station); + let html = self + .client + .get(&url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; + + let mut slugs = HashSet::new(); + + // Look for webradio identifiers in the HTML + // Pattern: {station}_{variant} (e.g., fip_rock, francemusique_jazz) + let pattern = format!(r#"["']({}_[a-z_]+)["']"#, regex::escape(station)); + let re = Regex::new(&pattern)?; + + for cap in re.captures_iter(&html) { + let slug = cap[1].to_string(); + // Exclude the main station itself + if slug != station { + slugs.insert(slug); + } + } + + Ok(slugs + .into_iter() + .map(|slug| { + let name = Self::slug_to_display_name(&slug); + Station::webradio(slug, name, station) + }) + .collect()) + } + + /// Discover local France Bleu radios via API + /// + /// Uses the France Bleu /api/live? endpoint which includes + /// a `localRadios` array in the `now` field. + pub async fn discover_local_radios(&self) -> Result> { + let response = self.live_metadata("francebleu").await?; + + Ok(response + .local_radios() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|local| local.is_on_air) + .map(|local| { + let region = local.title.replace("ICI ", ""); + Station::local_radio(local.name, local.title, region, local.id) + }) + .collect()) + } + + /// Extract station slug from a href attribute + fn extract_station_slug_from_href(&self, href: &str) -> Option { + // Match patterns like: + // - /franceculture + // - /franceculture/... + // - https://www.radiofrance.fr/fip + let re = Regex::new( + r"^(?:https?://[^/]+)?/(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu)(?:/|$)", + ) + .ok()?; + + re.captures(href) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + } + + /// Convert a station slug to a human-readable display name + pub fn slug_to_display_name(slug: &str) -> String { + // Handle webradio slugs (e.g., fip_rock -> FIP Rock) + let parts: Vec<&str> = slug.split('_').collect(); + + if parts.len() == 1 { + // Main station + match slug { + "franceinter" => "France Inter".to_string(), + "franceinfo" => "France Info".to_string(), + "franceculture" => "France Culture".to_string(), + "francemusique" => "France Musique".to_string(), + "fip" => "FIP".to_string(), + "mouv" => "Mouv'".to_string(), + "francebleu" => "France Bleu".to_string(), + _ => Self::capitalize_words(slug), + } + } else { + // Webradio: combine parent name + variant + let parent = match parts[0] { + "fip" => "FIP", + "francemusique" => "France Musique", + "mouv" => "Mouv'", + "francebleu" => "France Bleu", + _ => return Self::capitalize_words(&slug.replace('_', " ")), + }; + + let variant = parts[1..] + .iter() + .map(|p| Self::capitalize_word(p)) + .collect::>() + .join(" "); + + format!("{} {}", parent, variant) + } + } + + fn capitalize_words(s: &str) -> String { + s.split(|c: char| c == '_' || c == ' ') + .map(Self::capitalize_word) + .collect::>() + .join(" ") + } + + fn capitalize_word(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + } + + // ======================================================================== + // Live Metadata + // ======================================================================== + + /// Get live metadata for a station + /// + /// # Arguments + /// + /// * `station` - Station slug (e.g., "franceculture", "fip_rock", "francebleu_alsace") + /// + /// # Webradio Handling + /// + /// For webradios (e.g., "fip_rock"), the client automatically adds the + /// `?webradio=` parameter to the API request. + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceClient; + /// # async fn example() -> Result<(), Box> { + /// let client = RadioFranceClient::new().await?; + /// + /// // Main station + /// let fc = client.live_metadata("franceculture").await?; + /// + /// // Webradio + /// let fip_rock = client.live_metadata("fip_rock").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn live_metadata(&self, station: &str) -> Result { + let (base_station, webradio) = Self::parse_station_slug(station); + + let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?; + + // Add webradio parameter if needed + if let Some(wr) = webradio { + url.query_pairs_mut().append_pair("webradio", wr); + } + + #[cfg(feature = "logging")] + tracing::debug!("Fetching live metadata: {}", url); + + 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() + ))); + } + + let live: LiveResponse = response.json().await?; + + #[cfg(feature = "logging")] + tracing::debug!( + "Received metadata for {}: {} - {}", + station, + live.now.first_line.title_or_default(), + live.now.second_line.title_or_default() + ); + + Ok(live) + } + + /// Get only the current show metadata + pub async fn now_playing(&self, station: &str) -> Result { + let response = self.live_metadata(station).await?; + Ok(response.now) + } + + /// Parse a station slug to extract base station and optional webradio + /// + /// # Examples + /// + /// - "fip" → ("fip", None) + /// - "fip_rock" → ("fip", Some("fip_rock")) + /// - "francemusique_jazz" → ("francemusique", Some("francemusique_jazz")) + /// - "francebleu_alsace" → ("francebleu_alsace", None) - local radios don't use webradio param + pub fn parse_station_slug(slug: &str) -> (&str, Option<&str>) { + // FIP webradios + if slug.starts_with("fip_") { + return ("fip", Some(slug)); + } + + // France Musique webradios + if slug.starts_with("francemusique_") { + return ("francemusique", Some(slug)); + } + + // Mouv' webradios (if any) + if slug.starts_with("mouv_") { + return ("mouv", Some(slug)); + } + + // France Bleu local radios use their slug directly, no webradio param + // e.g., francebleu_alsace → francebleu_alsace/api/live + if slug.starts_with("francebleu_") { + return (slug, None); + } + + // Main stations + (slug, None) + } + + // ======================================================================== + // Stream URLs + // ======================================================================== + + /// Get the best HiFi stream URL for a station + /// + /// Prioritizes AAC 192 kbps, falls back to HLS. + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceClient; + /// # async fn example() -> Result<(), Box> { + /// let client = RadioFranceClient::new().await?; + /// let url = client.get_hifi_stream_url("franceculture").await?; + /// // Returns: https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance + /// # Ok(()) + /// # } + /// ``` + pub async fn get_hifi_stream_url(&self, station: &str) -> Result { + let metadata = self.live_metadata(station).await?; + + metadata + .now + .media + .best_hifi_stream() + .map(|s| s.url.clone()) + .ok_or_else(|| Error::NoHifiStream(station.to_string())) + } + + /// Get all available stream sources for a station + pub async fn get_available_streams(&self, station: &str) -> Result> { + let metadata = self.live_metadata(station).await?; + Ok(metadata.now.media.sources) + } + + // ======================================================================== + // Image URLs (Pikapi) + // ======================================================================== + + /// Build a Pikapi image URL from a UUID + /// + /// # Arguments + /// + /// * `uuid` - Image UUID (e.g., "436430f7-5b2b-43f2-9f3c-28f2ad6cae39") + /// * `size` - Desired image size + pub fn build_image_url(uuid: &str, size: ImageSize) -> String { + size.build_url(uuid) + } + + /// Extract UUID from a Pikapi URL + /// + /// # Example + /// + /// ``` + /// use pmoradiofrance::RadioFranceClient; + /// + /// let url = "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200"; + /// let uuid = RadioFranceClient::extract_image_uuid(url); + /// assert_eq!(uuid, Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string())); + /// ``` + pub fn extract_image_uuid(url: &str) -> Option { + let re = 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()) + } + + // ======================================================================== + // Polling Helpers + // ======================================================================== + + /// Get the recommended delay before the next metadata refresh + /// + /// Uses the `delayToRefresh` field from the API response. + pub fn next_refresh_delay(metadata: &LiveResponse) -> Duration { + Duration::from_millis(metadata.delay_to_refresh) + } + + /// Calculate the adjusted refresh delay accounting for elapsed time + /// + /// # Arguments + /// + /// * `metadata` - The metadata response + /// * `fetched_at` - When the metadata was fetched + 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) + } +} + +/// Builder for configuring a RadioFranceClient +#[derive(Debug)] +pub struct ClientBuilder { + client: Option, + base_url: String, + timeout: Duration, + user_agent: String, + proxy: Option, +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self { + client: None, + base_url: DEFAULT_BASE_URL.to_string(), + timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + user_agent: DEFAULT_USER_AGENT.to_string(), + proxy: None, + } + } +} + +impl ClientBuilder { + /// Create a new builder with default settings + pub fn new() -> Self { + Self::default() + } + + /// Set a custom HTTP client + pub fn client(mut self, client: Client) -> Self { + self.client = Some(client); + self + } + + /// Set the base URL + pub fn base_url(mut self, url: impl Into) -> Self { + self.base_url = url.into(); + self + } + + /// Set the request timeout + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Set a custom User-Agent header + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Set a proxy URL + pub fn proxy(mut self, proxy: impl Into) -> Self { + self.proxy = Some(proxy.into()); + self + } + + /// Build the client + pub async fn build(self) -> Result { + let client = if let Some(client) = self.client { + client + } else { + let mut builder = Client::builder() + .user_agent(&self.user_agent) + .timeout(self.timeout); + + if let Some(proxy_url) = &self.proxy { + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?; + builder = builder.proxy(proxy); + } + + builder.build()? + }; + + Ok(RadioFranceClient { + client, + base_url: self.base_url, + timeout: self.timeout, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================== + // Unit Tests (no network) + // ======================================================================== + + #[test] + fn test_parse_station_slug() { + assert_eq!(RadioFranceClient::parse_station_slug("fip"), ("fip", None)); + assert_eq!( + RadioFranceClient::parse_station_slug("fip_rock"), + ("fip", Some("fip_rock")) + ); + assert_eq!( + RadioFranceClient::parse_station_slug("francemusique_jazz"), + ("francemusique", Some("francemusique_jazz")) + ); + assert_eq!( + RadioFranceClient::parse_station_slug("francebleu_alsace"), + ("francebleu_alsace", None) + ); + assert_eq!( + RadioFranceClient::parse_station_slug("franceculture"), + ("franceculture", None) + ); + } + + #[test] + fn test_slug_to_display_name() { + assert_eq!( + RadioFranceClient::slug_to_display_name("franceculture"), + "France Culture" + ); + assert_eq!(RadioFranceClient::slug_to_display_name("fip"), "FIP"); + assert_eq!( + RadioFranceClient::slug_to_display_name("fip_rock"), + "FIP Rock" + ); + assert_eq!( + RadioFranceClient::slug_to_display_name("francemusique_la_jazz"), + "France Musique La Jazz" + ); + } + + #[test] + fn test_extract_image_uuid() { + let url = + "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200"; + assert_eq!( + RadioFranceClient::extract_image_uuid(url), + Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string()) + ); + + let url_no_size = + "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39"; + assert_eq!( + RadioFranceClient::extract_image_uuid(url_no_size), + Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string()) + ); + } + + #[test] + fn test_builder_defaults() { + let builder = ClientBuilder::default(); + assert_eq!(builder.base_url, DEFAULT_BASE_URL); + assert_eq!( + builder.timeout, + Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS) + ); + } + + // ======================================================================== + // Integration Tests (real API calls) + // + // Run with: cargo test -p pmoradiofrance -- --ignored + // ======================================================================== + + /// Test client creation + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_client_creation() { + let client = RadioFranceClient::new().await; + assert!( + client.is_ok(), + "Failed to create client: {:?}", + client.err() + ); + } + + /// Test live metadata for France Culture (talk radio) + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_franceculture() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("franceculture").await; + + assert!( + metadata.is_ok(), + "Failed to get France Culture metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "franceculture"); + assert!( + metadata.delay_to_refresh > 0, + "delay_to_refresh should be positive" + ); + + // France Culture should have show info + assert!( + metadata.now.first_line.title.is_some() || metadata.now.second_line.title.is_some(), + "Expected at least one title line" + ); + + // Should have media sources + assert!( + !metadata.now.media.sources.is_empty(), + "Expected media sources" + ); + + println!( + "France Culture - Now: {} - {}", + metadata.now.first_line.title_or_default(), + metadata.now.second_line.title_or_default() + ); + println!(" Producer: {:?}", metadata.now.producer); + println!(" Delay to refresh: {} ms", metadata.delay_to_refresh); + println!(" Sources: {} available", metadata.now.media.sources.len()); + } + + /// Test live metadata for France Inter + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_franceinter() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("franceinter").await; + + assert!( + metadata.is_ok(), + "Failed to get France Inter metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "franceinter"); + assert!( + !metadata.now.media.sources.is_empty(), + "Expected media sources" + ); + + println!( + "France Inter - Now: {} - {}", + metadata.now.first_line.title_or_default(), + metadata.now.second_line.title_or_default() + ); + } + + /// Test live metadata for FIP (music radio - should have song info) + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_fip() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("fip").await; + + assert!( + metadata.is_ok(), + "Failed to get FIP metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "fip"); + + // FIP often has song info (but not always during talk segments) + if let Some(song) = &metadata.now.song { + println!( + "FIP - Now playing: {} - {}", + song.artists_display(), + metadata.now.first_line.title_or_default() + ); + if let Some(album) = &song.release.title { + println!(" Album: {}", album); + } + } else { + println!( + "FIP - Now: {} (no song info)", + metadata.now.first_line.title_or_default() + ); + } + } + + /// Test live metadata for FIP Rock webradio + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_fip_rock() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("fip_rock").await; + + assert!( + metadata.is_ok(), + "Failed to get FIP Rock metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + // API returns "fip" as station_name even for webradios + assert!( + metadata.station_name == "fip" || metadata.station_name == "fip_rock", + "Unexpected station name: {}", + metadata.station_name + ); + + println!( + "FIP Rock - Now: {}", + metadata.now.first_line.title_or_default() + ); + } + + /// Test live metadata for France Musique + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_francemusique() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("francemusique").await; + + assert!( + metadata.is_ok(), + "Failed to get France Musique metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "francemusique"); + + println!( + "France Musique - Now: {} - {}", + metadata.now.first_line.title_or_default(), + metadata.now.second_line.title_or_default() + ); + } + + /// Test live metadata for France Bleu (should have local radios) + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_francebleu() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("francebleu").await; + + assert!( + metadata.is_ok(), + "Failed to get France Bleu metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "francebleu"); + + // France Bleu should have local radios (in now.local_radios) + if let Some(locals) = metadata.local_radios() { + println!("France Bleu - {} local radios found", locals.len()); + assert!(!locals.is_empty(), "Expected local radios for France Bleu"); + + // Print first 5 local radios + for local in locals.iter().take(5) { + println!(" - {} ({})", local.title, local.name); + } + } else { + panic!("Expected local_radios field for France Bleu"); + } + } + + /// Test live metadata for Mouv' + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_live_metadata_mouv() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client.live_metadata("mouv").await; + + assert!( + metadata.is_ok(), + "Failed to get Mouv' metadata: {:?}", + metadata.err() + ); + + let metadata = metadata.unwrap(); + assert_eq!(metadata.station_name, "mouv"); + + println!( + "Mouv' - Now: {} - {}", + metadata.now.first_line.title_or_default(), + metadata.now.second_line.title_or_default() + ); + } + + /// Test HiFi stream URL retrieval + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_get_hifi_stream_url() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + + // Test multiple stations + for station in &["franceculture", "franceinter", "fip", "francemusique"] { + let url = client.get_hifi_stream_url(station).await; + assert!( + url.is_ok(), + "Failed to get HiFi stream for {}: {:?}", + station, + url.err() + ); + + let url = url.unwrap(); + assert!( + url.contains("icecast.radiofrance.fr") || url.contains("stream.radiofrance.fr"), + "Unexpected stream URL for {}: {}", + station, + url + ); + + println!("{}: {}", station, url); + } + } + + /// Test available streams listing + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_get_available_streams() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let streams = client.get_available_streams("franceculture").await; + + assert!( + streams.is_ok(), + "Failed to get streams: {:?}", + streams.err() + ); + + let streams = streams.unwrap(); + assert!(!streams.is_empty(), "Expected at least one stream"); + + println!("France Culture streams:"); + for stream in &streams { + println!( + " - {} {:?} {} kbps: {}", + stream.format.mime_type(), + stream.broadcast_type, + stream.bitrate, + stream.url + ); + } + + // Should have at least AAC and HLS + let has_aac = streams + .iter() + .any(|s| s.format == crate::models::StreamFormat::Aac); + let has_hls = streams + .iter() + .any(|s| s.format == crate::models::StreamFormat::Hls); + + assert!(has_aac || has_hls, "Expected at least AAC or HLS stream"); + } + + /// Test main station discovery + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_discover_main_stations() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let stations = client.discover_main_stations().await; + + assert!( + stations.is_ok(), + "Failed to discover main stations: {:?}", + stations.err() + ); + + let stations = stations.unwrap(); + assert!(!stations.is_empty(), "Expected at least one main station"); + + println!("Discovered {} main stations:", stations.len()); + for station in &stations { + println!(" - {} ({})", station.name, station.slug); + } + + // Should have the core stations + let slugs: Vec<&str> = stations.iter().map(|s| s.slug.as_str()).collect(); + assert!( + slugs.contains(&"franceinter") + || slugs.contains(&"franceculture") + || slugs.contains(&"fip"), + "Expected at least one of franceinter, franceculture, or fip" + ); + } + + /// Test FIP webradios discovery + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_discover_fip_webradios() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let webradios = client.discover_station_webradios("fip").await; + + assert!( + webradios.is_ok(), + "Failed to discover FIP webradios: {:?}", + webradios.err() + ); + + let webradios = webradios.unwrap(); + println!("Discovered {} FIP webradios:", webradios.len()); + for wr in &webradios { + println!(" - {} ({})", wr.name, wr.slug); + } + + // FIP should have multiple webradios (rock, jazz, etc.) + if !webradios.is_empty() { + // At least verify they have the right parent + for wr in &webradios { + assert!( + wr.slug.starts_with("fip_"), + "Webradio slug should start with fip_: {}", + wr.slug + ); + } + } + } + + /// Test France Musique webradios discovery + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_discover_francemusique_webradios() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let webradios = client.discover_station_webradios("francemusique").await; + + assert!( + webradios.is_ok(), + "Failed to discover France Musique webradios: {:?}", + webradios.err() + ); + + let webradios = webradios.unwrap(); + println!("Discovered {} France Musique webradios:", webradios.len()); + for wr in &webradios { + println!(" - {} ({})", wr.name, wr.slug); + } + } + + /// Test local radios discovery (France Bleu) + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_discover_local_radios() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let locals = client.discover_local_radios().await; + + assert!( + locals.is_ok(), + "Failed to discover local radios: {:?}", + locals.err() + ); + + let locals = locals.unwrap(); + assert!(!locals.is_empty(), "Expected local radios from France Bleu"); + + println!("Discovered {} France Bleu local radios:", locals.len()); + for local in locals.iter().take(10) { + println!(" - {} ({})", local.name, local.slug); + } + + // Should have ~40 local radios + assert!( + locals.len() >= 30, + "Expected at least 30 local radios, got {}", + locals.len() + ); + } + + /// Test full station discovery + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_discover_all_stations() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let stations = client.discover_all_stations().await; + + assert!( + stations.is_ok(), + "Failed to discover all stations: {:?}", + stations.err() + ); + + let stations = stations.unwrap(); + assert!(!stations.is_empty(), "Expected stations"); + + // 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!("Discovered {} total stations:", stations.len()); + println!(" - {} main stations", main_count); + println!(" - {} webradios", webradio_count); + println!(" - {} local radios", local_count); + + // Should have a good mix + assert!(main_count >= 5, "Expected at least 5 main stations"); + assert!(local_count >= 30, "Expected at least 30 local radios"); + + // Total should be significant + assert!( + stations.len() >= 40, + "Expected at least 40 total stations, got {}", + stations.len() + ); + } + + /// Test that invalid station returns an error + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_invalid_station() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let result = client.live_metadata("nonexistent_station_xyz").await; + + // Should fail with API error or similar + assert!(result.is_err(), "Expected error for invalid station"); + println!("Got expected error: {:?}", result.err()); + } + + /// Test refresh delay calculation + #[tokio::test] + #[ignore = "Integration test - calls real Radio France API"] + async fn test_refresh_delay() { + let client = RadioFranceClient::new() + .await + .expect("Failed to create client"); + let metadata = client + .live_metadata("franceculture") + .await + .expect("Failed to get metadata"); + + let delay = RadioFranceClient::next_refresh_delay(&metadata); + assert!(delay.as_millis() > 0, "Expected positive delay"); + + println!("Recommended refresh delay: {:?}", delay); + + // Test adjusted delay + let fetched_at = std::time::SystemTime::now(); + std::thread::sleep(std::time::Duration::from_millis(100)); + let adjusted = RadioFranceClient::adjusted_refresh_delay(&metadata, fetched_at); + + assert!( + adjusted < delay, + "Adjusted delay should be less than original" + ); + println!("Adjusted delay after 100ms: {:?}", adjusted); + } +} diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs new file mode 100644 index 00000000..e73a170f --- /dev/null +++ b/pmoradiofrance/src/config_ext.rs @@ -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; + + /// 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>; + + /// 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; + + /// 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>>; +} + +impl RadioFranceConfigExt for Config { + fn get_radiofrance_enabled(&self) -> Result { + 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> { + 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 { + 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>> { + 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); + } +} diff --git a/pmoradiofrance/src/error.rs b/pmoradiofrance/src/error.rs new file mode 100644 index 00000000..0fa36d2b --- /dev/null +++ b/pmoradiofrance/src/error.rs @@ -0,0 +1,73 @@ +//! Error types for the Radio France client + +/// Result type alias for Radio France operations +pub type Result = std::result::Result; + +/// 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) -> Self { + Self::Other(msg.into()) + } + + /// Create an API error + pub fn api_error(msg: impl Into) -> Self { + Self::ApiError(msg.into()) + } + + /// Create a scraping error + pub fn scraping_error(msg: impl Into) -> Self { + Self::ScrapingError(msg.into()) + } +} diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs new file mode 100644 index 00000000..64e17ea6 --- /dev/null +++ b/pmoradiofrance/src/lib.rs @@ -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> { +//! 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; diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs new file mode 100644 index 00000000..dcf9e099 --- /dev/null +++ b/pmoradiofrance/src/models.rs @@ -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, name: impl Into) -> Self { + Self { + slug: slug.into(), + name: name.into(), + station_type: StationType::Main, + } + } + + /// Create a new webradio station + pub fn webradio( + slug: impl Into, + name: impl Into, + parent: impl Into, + ) -> 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, + name: impl Into, + region: impl Into, + 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, +} + +impl LiveResponse { + /// Get local radios (France Bleu only) - convenience accessor + pub fn local_radios(&self) -> Option<&Vec> { + 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, + /// End time (Unix timestamp) + pub end_time: Option, + /// Producer name + pub producer: Option, + /// 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, + /// Show description/intro + pub intro: Option, + /// React availability flag + #[serde(default)] + pub react_available: bool, + /// Background visual + pub visual_background: Option, + /// Song info (for music stations like FIP, France Musique) + pub song: Option, + /// Available media streams + #[serde(default)] + pub media: Media, + /// Visual assets (card, player) + pub visuals: Option, + /// Local radios list (France Bleu only) + #[serde(default)] + pub local_radios: Option>, +} + +/// A line of text with optional link +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Line { + /// Text content + pub title: Option, + /// UUID of the referenced object + pub id: Option, + /// URL path to the referenced page + pub path: Option, +} + +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, + /// Artist names + #[serde(default)] + pub interpreters: Vec, + /// 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, + /// Album title + pub title: Option, + /// Catalog reference + pub reference: Option, +} + +/// Available media streams +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Media { + /// List of available stream sources + #[serde(default)] + pub sources: Vec, +} + +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 { + 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, + /// Image height + pub height: Option, + /// Dominant color (hex) + pub dominant: Option, + /// Copyright notice + pub copyright: Option, +} + +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 { + 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, + /// Player-sized image + pub player: Option, +} + +/// 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, + /// 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) -> 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 + } +} From 94f97906349910622c8dffdea7c878bda081d99f Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 22 Jan 2026 22:15:05 +0100 Subject: [PATCH 02/13] =?UTF-8?q?Impl=C3=A9mentation=20du=20client=20state?= =?UTF-8?q?ful=20pour=20Radio=20France?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de l'implémentation du client stateful `RadioFranceStatefulClient` avec gestion du cache des stations et des métadonnées live, ainsi que la construction de playlists UPnP. Intégration avec `SourceCacheManager` pour les covers. Ajout des structures `StationGroups`, `StationGroup` et `StationPlaylist` pour l'organisation hiérarchique des stations et le mapping API → UPnP. --- .../ToDiscuss/Construire_pmoradiofrance.md | 436 +++ metadata.txt | 2559 +++++++++++++++++ 2 files changed, 2995 insertions(+) create mode 100644 metadata.txt diff --git a/Blackboard/ToDiscuss/Construire_pmoradiofrance.md b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md index 14b1c274..ffe72b1c 100644 --- a/Blackboard/ToDiscuss/Construire_pmoradiofrance.md +++ b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md @@ -62,3 +62,439 @@ Sous le folder principal Radio France, On doit avoir une playlist par station. L 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) + +## Round 4bis : Implémentation du Client Stateful + +**Crate concernée** : `pmoradiofrance` + +**Objectif** : Implémenter le client stateful et les structures de support pour Radio France selon l'architecture définie dans le Round 4 de `api_radiofrance_complete.md`. + +### Fichiers à créer/modifier + +#### 1. `pmoradiofrance/src/stateful_client.rs` (NOUVEAU) + +Implémenter le client stateful `RadioFranceStatefulClient` qui : + +**Responsabilités** : +- Gère le cache des stations découvertes (via pmoconfig) +- Gère le cache mémoire des métadonnées live (HashMap avec TTL) +- Expose des méthodes de haut niveau pour la découverte et l'organisation des stations +- Construit des playlists UPnP à partir des métadonnées live +- Intègre avec `SourceCacheManager` pour les covers + +**Structure principale** : +```rust +pub struct RadioFranceStatefulClient { + client: RadioFranceClient, + config: Arc, + metadata_cache: Arc>>, + cache_manager: SourceCacheManager, +} +``` + +**Méthodes à implémenter** : + +**Discovery avec cache** : +- `new()` - Créer depuis config par défaut +- `with_client_and_config()` - Créer avec client et config personnalisés +- `get_all_stations()` - Retourne toutes les stations (cache si valide, sinon découverte) +- `refresh_stations()` - Force la redécouverte (ignore cache) +- `get_main_stations()` - Filtre les stations principales +- `get_webradios(parent)` - Filtre les webradios d'une station parent +- `get_local_radios()` - Filtre les radios locales ICI + +**Organisation hiérarchique** : +- `get_stations_by_group()` - Retourne `StationGroups` avec regroupement logique + - `standalone` : Stations sans webradios (France Culture, France Inter, France Info) + - `with_webradios` : Groupes FIP et France Musique avec leurs webradios + - `local_radios` : Toutes les radios ICI (France Bleu) + +**Métadonnées live** : +- `get_live_metadata(station)` - Retourne métadonnées (cache court terme si valide) +- `refresh_live_metadata(station)` - Force le rafraîchissement (ignore cache) + +**Construction de playlists** : +- `build_station_playlist(station)` - Construit une playlist UPnP complète pour une station +- `update_playlist_metadata(station, playlist)` - Met à jour les métadonnées volatiles +- `get_stream_url(station)` - Retourne l'URL du stream HiFi + +**Helpers** : +- `is_station_cache_valid()` - Vérifie validité du cache des stations +- `station_cache_age_secs()` - Retourne l'âge du cache en secondes + +**Implémentation du cache mémoire** : +```rust +struct LiveMetadataCache { + metadata: LiveResponse, + fetched_at: SystemTime, + valid_until: SystemTime, +} +``` + +**Logique de cache des métadonnées live** : +1. Vérifier HashMap en mémoire +2. Si présent et `SystemTime::now() < valid_until` : retourner cache +3. Sinon : appeler `client.live_metadata()`, calculer `valid_until` depuis `delayToRefresh`, stocker, retourner + +#### 2. `pmoradiofrance/src/playlist.rs` (NOUVEAU) + +Structures et helpers pour la construction de playlists UPnP. + +**Important** : Utiliser les structures `Item` et `Resource` de `pmodidl` pour représenter les playlists UPnP. + +**Structures à définir** : + +```rust +use pmodidl::{Item, Resource}; + +/// Groupes de stations organisés hiérarchiquement +pub struct StationGroups { + pub standalone: Vec, + pub with_webradios: Vec, + pub local_radios: Vec, +} + +/// Groupe station principale + webradios +pub struct StationGroup { + pub main: Station, + pub webradios: Vec, +} + +/// Playlist UPnP pour une station (volatiles) +/// +/// Contient UN SEUL item pmodidl::Item représentant le stream. +/// Les métadonnées de l'item changent au fil du temps (émissions, morceaux) +/// mais l'URL du stream reste identique. +pub struct StationPlaylist { + /// ID de la playlist (ex: "radiofrance:franceculture") + pub id: String, + + /// Station source + pub station: Station, + + /// Item UPnP unique représentant le stream + /// Utilise pmodidl::Item avec toutes les métadonnées volatiles + pub stream_item: Item, +} +``` + +**Helpers à implémenter** : + +```rust +impl StationPlaylist { + /// Construire une playlist depuis les métadonnées live + /// + /// Crée un pmodidl::Item avec : + /// - id : "radiofrance:{station_slug}:stream" + /// - parent_id : "radiofrance:{station_slug}" + /// - title, artist, album, genre selon le mapping API → UPnP + /// - resource unique avec l'URL du stream HiFi + /// - album_art pointant vers la cover cachée + pub async fn from_live_metadata( + station: Station, + metadata: LiveResponse, + cache_manager: &SourceCacheManager, + ) -> Result; + + /// Mettre à jour les métadonnées volatiles de l'item + /// + /// Met à jour uniquement les champs volatiles : + /// - title, artist, album (depuis nouvelles métadonnées) + /// - album_art / album_art_pk (si nouvelle cover) + /// + /// L'URL du stream (resource.url) ne change JAMAIS. + pub async fn update_metadata( + &mut self, + metadata: &LiveResponse, + cache_manager: &SourceCacheManager, + ) -> Result<()>; + + /// Construire un pmodidl::Item depuis les métadonnées live + async fn build_item_from_metadata( + station: &Station, + metadata: &LiveResponse, + cache_manager: &SourceCacheManager, + ) -> Result; +} + +impl StationGroups { + /// Organiser une liste de stations en groupes + pub fn from_stations(stations: Vec) -> Self; +} +``` + +**Mapping API → UPnP (avec pmodidl::Item)** : + +**Construction du pmodidl::Item** : + +```rust +use pmodidl::{Item, Resource}; + +// Champs communs à tous les items +let item = Item { + id: format!("radiofrance:{}:stream", station.slug), + parent_id: format!("radiofrance:{}", station.slug), + restricted: Some("1".to_string()), + class: "object.item.audioItem.audioBroadcast".to_string(), + + // Métadonnées volatiles (varient selon radio parlée/musicale) + title: ..., + creator: ..., + artist: ..., + album: ..., + genre: ..., + + // Cover + album_art: Some(cover_url), + album_art_pk: Some(cover_pk), + + // Resource unique (stream) + resources: vec![Resource { + protocol_info: "http-get:*:audio/aac:*".to_string(), + bits_per_sample: None, + sample_frequency: Some("48000".to_string()), + nr_audio_channels: Some("2".to_string()), + duration: None, // Pas de durée pour un stream live + url: stream_url, + }], + + // Pas de descriptions pour les streams Radio France + descriptions: vec![], + + // Autres champs optionnels + date: None, + original_track_number: None, +}; +``` + +Pour **radios parlées** (France Culture, France Inter, France Info) : +- `title` = `now.firstLine.title` + " • " + `now.secondLine.title` +- `creator` = `now.producer` +- `artist` = `now.producer` +- `album` = `now.firstLine.title` (nom de l'émission) +- `genre` = Some("Talk Radio") +- `class` = "object.item.audioItem.audioBroadcast" + +Pour **radios musicales** (FIP, France Musique) : +- Si `now.song` présent : + - `title` = `now.firstLine.title` (titre du morceau) + - `creator` = `now.song.interpreters.join(", ")` + - `artist` = `now.song.interpreters.join(", ")` + - `album` = `now.song.release.title` + - `genre` = Some("Music") + - `class` = "object.item.audioItem.musicTrack" +- Sinon (talk segment) : utiliser mapping radio parlée + +**Cache des covers** : +- Extraire UUID depuis `now.visualBackground.src` ou image du `now.song` +- Construire URL haute résolution : `ImageSize::XLarge` ou `ImageSize::Large` +- Cacher via `cache_manager.cache_cover(url)` +- Stocker PK : `RADIOFRANCE:{uuid}` +- Dans l'item : + - `album_art` = URL publique de la cover (via serveur HTTP) + - `album_art_pk` = PK du cache (pour retrouver le fichier) + +**Resource (URL du stream)** : +- Priorité : AAC 192 kbps > HLS > AAC 128 kbps > MP3 128 kbps +- Utiliser `metadata.now.media.best_hifi_stream()` +- `protocol_info` : + - AAC : `"http-get:*:audio/aac:*"` + - HLS : `"http-get:*:application/vnd.apple.mpegurl:*"` + - MP3 : `"http-get:*:audio/mpeg:*"` +- `sample_frequency` = `"48000"` pour AAC, None pour HLS +- `nr_audio_channels` = `"2"` pour AAC, None pour HLS +- `duration` = None (stream infini) +- `url` = URL complète du stream + +#### 3. `pmoradiofrance/src/config_ext.rs` (MODIFIER/COMPLÉTER) + +Étendre le trait `RadioFranceConfigExt` avec toutes les méthodes nécessaires. + +**Constantes** : +```rust +const DEFAULT_RADIOFRANCE_BASE_URL: &str = "https://www.radiofrance.fr"; +const DEFAULT_RADIOFRANCE_TIMEOUT_SECS: u64 = 30; +const DEFAULT_RADIOFRANCE_CACHE_TTL_DAYS: u64 = 7; +``` + +**Trait complet** : + +```rust +pub trait RadioFranceConfigExt { + // Activation + fn get_radiofrance_enabled(&self) -> Result; + fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()>; + + // Configuration HTTP + fn get_radiofrance_base_url(&self) -> Result; + fn set_radiofrance_base_url(&self, url: String) -> Result<()>; + fn get_radiofrance_timeout_secs(&self) -> Result; + fn set_radiofrance_timeout_secs(&self, secs: u64) -> Result<()>; + + // Cache des stations + fn get_radiofrance_stations_cache(&self) -> Result>; + fn set_radiofrance_stations_cache(&self, cache: &CachedStationList) -> Result<()>; + fn clear_radiofrance_stations_cache(&self) -> Result<()>; + fn get_radiofrance_cache_ttl_days(&self) -> Result; + fn set_radiofrance_cache_ttl_days(&self, days: u64) -> Result<()>; + + // Factory method + fn create_radiofrance_client(&self) -> Result; +} +``` + +**Implémentation** : + +Chemins dans la config : +- `["sources", "radiofrance", "enabled"]` - bool +- `["sources", "radiofrance", "base_url"]` - string +- `["sources", "radiofrance", "timeout_secs"]` - u64 +- `["sources", "radiofrance", "cache_ttl_days"]` - u64 +- `["sources", "radiofrance", "stations_cache"]` - serde_yaml::Value (structure `CachedStationList`) + +**Auto-persistence** : +- `get_radiofrance_enabled()` : Persister `true` par défaut si absent +- `get_radiofrance_base_url()` : Persister `DEFAULT_RADIOFRANCE_BASE_URL` si absent +- `get_radiofrance_timeout_secs()` : Persister `DEFAULT_RADIOFRANCE_TIMEOUT_SECS` si absent +- `get_radiofrance_cache_ttl_days()` : Persister `DEFAULT_RADIOFRANCE_CACHE_TTL_DAYS` si absent + +**Factory method** : +```rust +fn create_radiofrance_client(&self) -> Result { + let base_url = self.get_radiofrance_base_url()?; + let timeout_secs = self.get_radiofrance_timeout_secs()?; + + let http_client = RadioFranceClient::builder() + .base_url(base_url) + .timeout(Duration::from_secs(timeout_secs)) + .build() + .await?; + + let config = get_config(); + let cache_manager = SourceCacheManager::from_registry("radiofrance".to_string())?; + + Ok(RadioFranceStatefulClient::with_client_and_config( + http_client, + config, + cache_manager, + )) +} +``` + +#### 4. `pmoradiofrance/src/models.rs` (MODIFIER) + +Ajouter la structure `CachedStationList` si pas déjà présente (elle est définie dans le Round 3) : + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedStationList { + pub stations: Vec, + pub last_updated: u64, + pub version: u32, +} + +impl CachedStationList { + pub const CURRENT_VERSION: u32 = 1; + pub const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600; + + pub fn new(stations: Vec) -> Self; + pub fn is_valid(&self, ttl_secs: u64) -> bool; + pub fn is_valid_default(&self) -> bool; + pub fn age_secs(&self) -> u64; +} +``` + +#### 5. `pmoradiofrance/src/lib.rs` (MODIFIER) + +Ajouter les exports : + +```rust +pub mod stateful_client; +pub mod playlist; + +pub use stateful_client::RadioFranceStatefulClient; +pub use playlist::{StationGroups, StationGroup, StationPlaylist}; + +// Ré-exporter pmodidl::Item pour faciliter l'utilisation +pub use pmodidl::Item as RadioItem; +``` + +#### 6. `pmoradiofrance/Cargo.toml` (MODIFIER) + +Vérifier que les dépendances sont présentes : + +```toml +[dependencies] +# ... dépendances existantes du Round 3 ... + +# Nouvelles pour Round 4 +pmoconfig = { path = "../pmoconfig" } +pmocovers = { path = "../pmocovers" } +pmosource = { path = "../pmosource" } # Pour SourceCacheManager +pmodidl = { path = "../pmodidl" } # Pour Item et Resource + +[features] +default = ["pmoconfig"] +pmoconfig = ["dep:pmoconfig"] +cache = ["dep:pmocovers", "pmosource/server"] +server = ["pmoconfig", "cache"] +``` + +### Règles métier importantes + +1. **Renommage France Bleu → ICI** : + - Slug conservé : `francebleu`, `francebleu_alsace`, etc. + - Label affiché : "ICI", "ICI Alsace", etc. + - Implémenter dans `Station::display_name()` ou helper similaire + +2. **Organisation des stations** : + - Standalone : France Culture, France Inter, France Info, Mouv' + - Avec webradios : FIP (9 webradios), France Musique (5+ webradios) + - Locales : ~40 radios ICI (France Bleu) + +3. **Playlists volatiles** : + - Un seul item par playlist (le stream) + - URL du stream ne change JAMAIS + - Métadonnées changent toutes les 2-5 minutes + - Cover cachée dans `pmocovers`, PAS dans `pmoaudiocache` + +4. **Stream HiFi** : + - Toujours présenter le meilleur stream disponible + - Ordre : AAC 192kbps > HLS > AAC 128kbps > MP3 128kbps + +5. **Cache des stations** : + - TTL : 7 jours par défaut (configurable) + - Stockage : YAML dans pmoconfig + - Versionnement : Invalider si version change + +6. **Cache des métadonnées live** : + - TTL : Dynamique (champ `delayToRefresh` de l'API) + - Stockage : Mémoire (HashMap) + - Par station + +### Tests à ajouter + +**Tests unitaires** (`stateful_client.rs`) : +- Validation du cache (TTL, version) +- Organisation des stations en groupes +- Mapping métadonnées API → UPnP + +**Tests d'intégration** (avec `#[ignore]`) : +- Découverte et cache des stations +- Récupération métadonnées live avec cache +- Construction de playlists complètes +- Mise à jour métadonnées volatiles + +### Résultat attendu + +À la fin du Round 4bis, on doit avoir : + +1. ✅ Client stateful fonctionnel avec cache intelligent +2. ✅ Structures de playlists UPnP complètes +3. ✅ Extension pmoconfig complète +4. ✅ Organisation hiérarchique des stations +5. ✅ Mapping API → UPnP pour radios parlées et musicales +6. ✅ Cache des covers via pmocovers +7. ✅ Tests unitaires et d'intégration + +Le Round 5 pourra alors implémenter la `MusicSource` qui utilisera ce client stateful. diff --git a/metadata.txt b/metadata.txt new file mode 100644 index 00000000..0ec5fff0 --- /dev/null +++ b/metadata.txt @@ -0,0 +1,2559 @@ +================== +channel 0: +{ + "prev": [ + { + "firstLine": "Radio France", + "secondLine": null, + "cover": "45f36487-b91e-42cd-92e0-87d57ae353be", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "Radio France", + "secondLine": null, + "cover": "45f36487-b91e-42cd-92e0-87d57ae353be", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "Radio France", + "secondLine": null, + "cover": "45f36487-b91e-42cd-92e0-87d57ae353be", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 1: +{ + "prev": [ + { + "firstLine": "France Inter", + "secondLine": "Le direct de France Inter", + "cover": "c6288843-9269-4ca5-ab0e-49454aed8fdc", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "France Inter", + "secondLine": "Le direct de France Inter", + "cover": "c6288843-9269-4ca5-ab0e-49454aed8fdc", + "favoriteUuid": null, + "startTime": 1769115900, + "endTime": 1769119183 + }, + "next": [ + { + "firstLine": "France Inter", + "secondLine": "Le direct de France Inter", + "cover": "c6288843-9269-4ca5-ab0e-49454aed8fdc", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769119970 + } + ], + "delayToRefresh": 683000 +} +================== +channel 2: +{ + "prev": [ + { + "firstLine": "franceinfo", + "secondLine": "franceinfo : pas juste l'info, l'info juste", + "cover": "52bb749e-f832-4dd1-ba0a-8c5d11e0aa3a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "franceinfo", + "secondLine": "franceinfo : pas juste l'info, l'info juste", + "cover": "52bb749e-f832-4dd1-ba0a-8c5d11e0aa3a", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769122800 + }, + "next": [ + { + "firstLine": "franceinfo", + "secondLine": "franceinfo : pas juste l'info, l'info juste", + "cover": "52bb749e-f832-4dd1-ba0a-8c5d11e0aa3a", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769119740 + } + ], + "delayToRefresh": 730000 +} +================== +channel 3: +================== +channel 4: +{ + "prev": [ + { + "firstLine": "France Musique", + "secondLine": "Ce monde a besoin de musique", + "cover": "df8d1b79-8823-4421-a694-2b7561430b79", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "France Musique", + "secondLine": "Ce monde a besoin de musique", + "cover": "df8d1b79-8823-4421-a694-2b7561430b79", + "favoriteUuid": null, + "startTime": 1769117410, + "endTime": 1769121000 + }, + "next": [ + { + "firstLine": "France Musique", + "secondLine": "Ce monde a besoin de musique", + "cover": "df8d1b79-8823-4421-a694-2b7561430b79", + "favoriteUuid": null, + "startTime": 1769121020, + "endTime": 1769126330 + } + ], + "delayToRefresh": 2500000 +} +================== +channel 5: +{ + "prev": [ + { + "firstLine": "France Culture", + "secondLine": "France Culture, l'esprit d'ouverture", + "cover": "9cee7dd1-ee0d-4b88-b764-3cf9b748c266", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "France Culture", + "secondLine": "France Culture, l'esprit d'ouverture", + "cover": "9cee7dd1-ee0d-4b88-b764-3cf9b748c266", + "favoriteUuid": null, + "startTime": 1769115600, + "endTime": 1769119125 + }, + "next": [ + { + "firstLine": "France Culture", + "secondLine": "France Culture, l'esprit d'ouverture", + "cover": "9cee7dd1-ee0d-4b88-b764-3cf9b748c266", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122692 + } + ], + "delayToRefresh": 945000 +} +================== +channel 6: +{ + "prev": [ + { + "firstLine": "Mouv'", + "secondLine": "Mouv’", + "cover": "13dc5910-3a90-45e8-880d-5e7e31d21a3c", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "Mouv'", + "secondLine": "GUY2BEZBAR • La jeunesse dorée (feat. Killa Predator)", + "secondLineSongUuid": "ca5a3cb1-b585-4065-bf97-85e552e73779", + "cover": "67253cbd-64c3-4a3f-8a12-626163d4c175", + "favoriteUuid": "ca5a3cb1-b585-4065-bf97-85e552e73779", + "startTime": 1769118104, + "endTime": 1769118240 + }, + "next": [ + { + "firstLine": "Mouv'", + "secondLine": "Mouv’", + "cover": "13dc5910-3a90-45e8-880d-5e7e31d21a3c", + "favoriteUuid": null, + "startTime": 1769116500, + "endTime": 1769122799 + } + ], + "delayToRefresh": 50000 +} +================== +channel 7: +{ + "prev": [ + { + "firstLine": "FIP", + "secondLine": "La radio la plus éclectique du monde", + "cover": "34e98566-058b-428f-a39e-d74bdef1cf77", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP", + "secondLine": "Omar Sharif • Amore piccolino", + "secondLineSongUuid": "6383183a-2a5a-438f-814d-702c99d9f556", + "cover": "94e1f4c7-cf87-4df1-ad17-b1669bb543dc", + "favoriteUuid": "6383183a-2a5a-438f-814d-702c99d9f556", + "startTime": 1769118424, + "endTime": 1769118591 + }, + "next": [ + { + "firstLine": "FIP", + "secondLine": "La radio la plus éclectique du monde", + "cover": "34e98566-058b-428f-a39e-d74bdef1cf77", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 40000 +} +================== +channel 8: +================== +channel 9: +================== +channel 10: +================== +channel 11: +{ + "prev": [ + { + "firstLine": "ICI RCFM", + "secondLine": "Ici on parle d'ici", + "cover": "3398334a-8666-4cb2-978a-4853ec7d2583", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI RCFM", + "secondLine": "Ici on parle d'ici", + "cover": "3398334a-8666-4cb2-978a-4853ec7d2583", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "ICI RCFM", + "secondLine": "Ici on parle d'ici", + "cover": "3398334a-8666-4cb2-978a-4853ec7d2583", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 12: +{ + "prev": [ + { + "firstLine": "ICI Alsace", + "secondLine": "Ici on parle d'ici", + "cover": "c57f3937-c855-4c37-bf3d-80dae44fb502", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Alsace", + "secondLine": "Francis CABREL • L'encre de tes yeux", + "secondLineSongUuid": "e534c051-abda-4043-9a55-bedf2b0e86c3", + "cover": "49d147d6-d637-4a2a-be3b-10225f719ab0", + "favoriteUuid": "e534c051-abda-4043-9a55-bedf2b0e86c3", + "startTime": 1769118234, + "endTime": 1769118412 + }, + "next": [ + { + "firstLine": "ICI Alsace", + "secondLine": "Ici on parle d'ici", + "cover": "c57f3937-c855-4c37-bf3d-80dae44fb502", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 120000 +} +================== +channel 13: +{ + "prev": [ + { + "firstLine": "ICI Armorique", + "secondLine": "Ici on parle d'ici", + "cover": "99ac5a26-e5f0-490e-b513-38ee148c7e5a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Armorique", + "secondLine": "Francis CABREL • L'encre de tes yeux", + "secondLineSongUuid": "e534c051-abda-4043-9a55-bedf2b0e86c3", + "cover": "49d147d6-d637-4a2a-be3b-10225f719ab0", + "favoriteUuid": "e534c051-abda-4043-9a55-bedf2b0e86c3", + "startTime": 1769118234, + "endTime": 1769118412 + }, + "next": [ + { + "firstLine": "ICI Armorique", + "secondLine": "Ici on parle d'ici", + "cover": "99ac5a26-e5f0-490e-b513-38ee148c7e5a", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 120000 +} +================== +channel 14: +{ + "prev": [ + { + "firstLine": "ICI Auxerre", + "secondLine": "Ici on parle d'ici", + "cover": "e2026fb2-4cab-4137-bf1e-0b5324ec2ca4", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Auxerre", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "e2026fb2-4cab-4137-bf1e-0b5324ec2ca4", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Auxerre", + "secondLine": "Ici on parle d'ici", + "cover": "e2026fb2-4cab-4137-bf1e-0b5324ec2ca4", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 15: +{ + "prev": [ + { + "firstLine": "ICI Béarn Bigorre", + "secondLine": "Ici on parle d'ici", + "cover": "ba415434-04bc-4c30-b350-7dcca070b935", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Béarn Bigorre", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "ba415434-04bc-4c30-b350-7dcca070b935", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Béarn Bigorre", + "secondLine": "Ici on parle d'ici", + "cover": "ba415434-04bc-4c30-b350-7dcca070b935", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 16: +{ + "prev": [ + { + "firstLine": "ICI Belfort-Montbéliard", + "secondLine": "Ici on parle d'ici", + "cover": "f3238cc6-2f8a-4565-aa8a-d970bfac7d0a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Belfort-Montbéliard", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "f3238cc6-2f8a-4565-aa8a-d970bfac7d0a", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Belfort-Montbéliard", + "secondLine": "Ici on parle d'ici", + "cover": "f3238cc6-2f8a-4565-aa8a-d970bfac7d0a", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 17: +{ + "prev": [ + { + "firstLine": "ICI Berry", + "secondLine": "Ici on parle d'ici", + "cover": "06bb2775-63d9-433b-af7b-f8f62f021203", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Berry", + "secondLine": "Ici on parle d'ici", + "cover": "06bb2775-63d9-433b-af7b-f8f62f021203", + "favoriteUuid": null, + "startTime": null, + "endTime": 1769122800 + }, + "next": [ + { + "firstLine": "ICI Berry", + "secondLine": "Ici on parle d'ici", + "cover": "06bb2775-63d9-433b-af7b-f8f62f021203", + "favoriteUuid": null, + "startTime": 1769122800, + "endTime": 1769130000 + } + ], + "delayToRefresh": 3600000 +} +================== +channel 18: +{ + "prev": [ + { + "firstLine": "ICI Besançon", + "secondLine": "Ici on parle d'ici", + "cover": "e14152d8-168d-4b44-82e5-86c9c48176aa", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Besançon", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "e14152d8-168d-4b44-82e5-86c9c48176aa", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Besançon", + "secondLine": "Ici on parle d'ici", + "cover": "e14152d8-168d-4b44-82e5-86c9c48176aa", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 19: +{ + "prev": [ + { + "firstLine": "ICI Bourgogne", + "secondLine": "Ici on parle d'ici", + "cover": "4650a9b3-4395-4be8-a89d-73f61806a806", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Bourgogne", + "secondLine": "Ici on parle d'ici", + "cover": "4650a9b3-4395-4be8-a89d-73f61806a806", + "favoriteUuid": null, + "startTime": 1769104800, + "endTime": 1769122800 + }, + "next": [ + { + "firstLine": "ICI Bourgogne", + "secondLine": "Ici on parle d'ici", + "cover": "4650a9b3-4395-4be8-a89d-73f61806a806", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 20: +{ + "prev": [ + { + "firstLine": "ICI Breizh Izel", + "secondLine": "Ici on parle d'ici", + "cover": "269593ab-0229-4dc1-8625-cd19943a299d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Breizh Izel", + "secondLine": "Ici on parle d'ici", + "cover": "269593ab-0229-4dc1-8625-cd19943a299d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "ICI Breizh Izel", + "secondLine": "Ici on parle d'ici", + "cover": "269593ab-0229-4dc1-8625-cd19943a299d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 21: +{ + "prev": [ + { + "firstLine": "ICI Champagne-Ardenne", + "secondLine": "Ici on parle d'ici", + "cover": "b011edaa-d3f5-4489-9a57-eaded51894f9", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Champagne-Ardenne", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "b011edaa-d3f5-4489-9a57-eaded51894f9", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Champagne-Ardenne", + "secondLine": "Ici on parle d'ici", + "cover": "b011edaa-d3f5-4489-9a57-eaded51894f9", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 22: +{ + "prev": [ + { + "firstLine": "ICI Cotentin", + "secondLine": "Ici on parle d'ici", + "cover": "f6ec610c-4ae9-4128-86a5-12ad3f874636", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Cotentin", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "f6ec610c-4ae9-4128-86a5-12ad3f874636", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Cotentin", + "secondLine": "Ici on parle d'ici", + "cover": "f6ec610c-4ae9-4128-86a5-12ad3f874636", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 23: +{ + "prev": [ + { + "firstLine": "ICI Creuse", + "secondLine": "Ici on parle d'ici", + "cover": "4a6c076b-2049-4744-a211-bf5d811a4fe9", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Creuse", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "4a6c076b-2049-4744-a211-bf5d811a4fe9", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Creuse", + "secondLine": "Ici on parle d'ici", + "cover": "4a6c076b-2049-4744-a211-bf5d811a4fe9", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 24: +{ + "prev": [ + { + "firstLine": "ICI Drôme Ardèche", + "secondLine": "Ici on parle d'ici", + "cover": "044ffe9e-28d7-4dd3-960f-299a704ff71b", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Drôme Ardèche", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "044ffe9e-28d7-4dd3-960f-299a704ff71b", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Drôme Ardèche", + "secondLine": "Ici on parle d'ici", + "cover": "044ffe9e-28d7-4dd3-960f-299a704ff71b", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 25: +{ + "prev": [ + { + "firstLine": "ICI Gard Lozère", + "secondLine": "Ici on parle d'ici", + "cover": "06266b7e-38a5-458f-9926-3c3d8db11e4a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Gard Lozère", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "06266b7e-38a5-458f-9926-3c3d8db11e4a", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Gard Lozère", + "secondLine": "Ici on parle d'ici", + "cover": "06266b7e-38a5-458f-9926-3c3d8db11e4a", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 26: +{ + "prev": [ + { + "firstLine": "ICI Gascogne", + "secondLine": "Ici on parle d'ici", + "cover": "be86fb63-ff3e-4d57-b8e5-e131e05c51d6", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Gascogne", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "be86fb63-ff3e-4d57-b8e5-e131e05c51d6", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Gascogne", + "secondLine": "Ici on parle d'ici", + "cover": "be86fb63-ff3e-4d57-b8e5-e131e05c51d6", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 27: +{ + "prev": [ + { + "firstLine": "ICI Gironde", + "secondLine": "Ici on parle d'ici", + "cover": "e3a6cf54-c830-4466-bf6b-913d9b37322d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Gironde", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "e3a6cf54-c830-4466-bf6b-913d9b37322d", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Gironde", + "secondLine": "Ici on parle d'ici", + "cover": "e3a6cf54-c830-4466-bf6b-913d9b37322d", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 28: +{ + "prev": [ + { + "firstLine": "ICI Hérault", + "secondLine": "Ici on parle d'ici", + "cover": "536dcf8e-86ac-482a-9a95-8bfa16203a5b", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Hérault", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "536dcf8e-86ac-482a-9a95-8bfa16203a5b", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Hérault", + "secondLine": "Ici on parle d'ici", + "cover": "536dcf8e-86ac-482a-9a95-8bfa16203a5b", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 29: +{ + "prev": [ + { + "firstLine": "ICI Isère", + "secondLine": "Ici on parle d'ici", + "cover": "b862114b-7b80-4f69-9b88-e73a442522ff", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Isère", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "b862114b-7b80-4f69-9b88-e73a442522ff", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118578 + }, + "next": [ + { + "firstLine": "ICI Isère", + "secondLine": "Ici on parle d'ici", + "cover": "b862114b-7b80-4f69-9b88-e73a442522ff", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 30: +{ + "prev": [ + { + "firstLine": "ICI La Rochelle", + "secondLine": "Ici on parle d'ici", + "cover": "3094e84d-6b04-478f-be04-73969274e488", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI La Rochelle", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "3094e84d-6b04-478f-be04-73969274e488", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI La Rochelle", + "secondLine": "Ici on parle d'ici", + "cover": "3094e84d-6b04-478f-be04-73969274e488", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 31: +{ + "prev": [ + { + "firstLine": "ICI Limousin", + "secondLine": "Ici on parle d'ici", + "cover": "ff4d3086-9902-44ac-98c7-649ae310ad59", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Limousin", + "secondLine": "Jeanne GABRIELLE • Bijou (version linée)", + "secondLineSongUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "cover": "ff4d3086-9902-44ac-98c7-649ae310ad59", + "favoriteUuid": "0ae2c281-01d8-4590-879f-0953be25b3f3", + "startTime": 1769118412, + "endTime": 1769118575 + }, + "next": [ + { + "firstLine": "ICI Limousin", + "secondLine": "Ici on parle d'ici", + "cover": "ff4d3086-9902-44ac-98c7-649ae310ad59", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 20000 +} +================== +channel 32: +{ + "prev": [ + { + "firstLine": "ICI Loire Océan", + "secondLine": "Ici on parle d'ici", + "cover": "54bfa2c2-55e6-4a32-bc8a-c891a08f0626", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Loire Océan", + "secondLine": "Ici on parle d'ici", + "cover": "54bfa2c2-55e6-4a32-bc8a-c891a08f0626", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Loire Océan", + "secondLine": "Ici on parle d'ici", + "cover": "54bfa2c2-55e6-4a32-bc8a-c891a08f0626", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 33: +{ + "prev": [ + { + "firstLine": "ICI Lorraine (Meurthe-et-Moselle et Vosges)", + "secondLine": "Ici on parle d'ici", + "cover": "d0448ffa-9420-4c1f-8fef-21e0d9f208ab", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Lorraine (Meurthe-et-Moselle et Vosges)", + "secondLine": "Ici on parle d'ici", + "cover": "d0448ffa-9420-4c1f-8fef-21e0d9f208ab", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Lorraine (Meurthe-et-Moselle et Vosges)", + "secondLine": "Ici on parle d'ici", + "cover": "d0448ffa-9420-4c1f-8fef-21e0d9f208ab", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 34: +{ + "prev": [ + { + "firstLine": "ICI Mayenne", + "secondLine": "Ici on parle d'ici", + "cover": "655cca16-52bf-4eb1-aea7-2114bb6a2b46", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Mayenne", + "secondLine": "Ici on parle d'ici", + "cover": "655cca16-52bf-4eb1-aea7-2114bb6a2b46", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Mayenne", + "secondLine": "Ici on parle d'ici", + "cover": "655cca16-52bf-4eb1-aea7-2114bb6a2b46", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 35: +================== +channel 36: +{ + "prev": [ + { + "firstLine": "ICI Nord", + "secondLine": "Ici on parle d'ici", + "cover": "26b3d826-f907-4c0c-bdc9-08115c3d4152", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Nord", + "secondLine": "Ici on parle d'ici", + "cover": "26b3d826-f907-4c0c-bdc9-08115c3d4152", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769121000 + }, + "next": [ + { + "firstLine": "ICI Nord", + "secondLine": "Ici on parle d'ici", + "cover": "26b3d826-f907-4c0c-bdc9-08115c3d4152", + "favoriteUuid": null, + "startTime": 1769121000, + "endTime": 1769122800 + } + ], + "delayToRefresh": 2430000 +} +================== +channel 37: +{ + "prev": [ + { + "firstLine": "ICI Normandie (Calvados - Orne)", + "secondLine": "Ici on parle d'ici", + "cover": "4b1a9d2d-30d6-4dd7-aacf-a5679ddf65f7", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Normandie (Calvados - Orne)", + "secondLine": "Ici on parle d'ici", + "cover": "4b1a9d2d-30d6-4dd7-aacf-a5679ddf65f7", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Normandie (Calvados - Orne)", + "secondLine": "Ici on parle d'ici", + "cover": "4b1a9d2d-30d6-4dd7-aacf-a5679ddf65f7", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 38: +{ + "prev": [ + { + "firstLine": "ICI Normandie (Seine-Maritime - Eure)", + "secondLine": "Ici on parle d'ici", + "cover": "4b1a9d2d-30d6-4dd7-aacf-a5679ddf65f7", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Normandie (Seine-Maritime - Eure)", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Normandie (Seine-Maritime - Eure)", + "secondLine": "Ici on parle d'ici", + "cover": "4b1a9d2d-30d6-4dd7-aacf-a5679ddf65f7", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 39: +{ + "prev": [ + { + "firstLine": "ICI Orléans", + "secondLine": "Ici on parle d'ici", + "cover": "6ba4f915-c15b-4b75-bbff-00264c473c75", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Orléans", + "secondLine": "Ici on parle d'ici", + "cover": "6ba4f915-c15b-4b75-bbff-00264c473c75", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "ICI Orléans", + "secondLine": "Ici on parle d'ici", + "cover": "6ba4f915-c15b-4b75-bbff-00264c473c75", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 40: +{ + "prev": [ + { + "firstLine": "ICI Pays d'Auvergne", + "secondLine": "Ici on parle d'ici", + "cover": "64d1fb99-9fd7-439b-a1b4-62697ea9e7ff", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Pays d'Auvergne", + "secondLine": "Ici on parle d'ici", + "cover": "64d1fb99-9fd7-439b-a1b4-62697ea9e7ff", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Pays d'Auvergne", + "secondLine": "Ici on parle d'ici", + "cover": "64d1fb99-9fd7-439b-a1b4-62697ea9e7ff", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 41: +{ + "prev": [ + { + "firstLine": "ICI Pays Basque", + "secondLine": "Ici on parle d'ici", + "cover": "0d62617b-681e-4232-a903-ecdd0a997f3b", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Pays Basque", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Pays Basque", + "secondLine": "Ici on parle d'ici", + "cover": "0d62617b-681e-4232-a903-ecdd0a997f3b", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 42: +{ + "prev": [ + { + "firstLine": "ICI Pays de Savoie", + "secondLine": "Ici on parle d'ici", + "cover": "ef0c5a84-ac12-4f70-acd0-5f3b3ad2be4b", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Pays de Savoie", + "secondLine": "Ici on parle d'ici", + "cover": "ef0c5a84-ac12-4f70-acd0-5f3b3ad2be4b", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Pays de Savoie", + "secondLine": "Ici on parle d'ici", + "cover": "ef0c5a84-ac12-4f70-acd0-5f3b3ad2be4b", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 43: +{ + "prev": [ + { + "firstLine": "ICI Périgord", + "secondLine": "Ici on parle d'ici", + "cover": "b168c741-cd3c-442b-9e2a-bc2095bd4419", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Périgord", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Périgord", + "secondLine": "Ici on parle d'ici", + "cover": "b168c741-cd3c-442b-9e2a-bc2095bd4419", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 44: +{ + "prev": [ + { + "firstLine": "ICI Picardie", + "secondLine": "Ici on parle d'ici", + "cover": "8e2f0a62-a424-4f47-aa94-0f1574b005ff", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Picardie", + "secondLine": "Ici on parle d'ici", + "cover": "8e2f0a62-a424-4f47-aa94-0f1574b005ff", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Picardie", + "secondLine": "Ici on parle d'ici", + "cover": "8e2f0a62-a424-4f47-aa94-0f1574b005ff", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 45: +{ + "prev": [ + { + "firstLine": "ICI Provence", + "secondLine": "Ici on parle d'ici", + "cover": "2e36ab01-c5d5-4c20-9cc3-eebeb9c29d5b", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Provence", + "secondLine": "Ici on parle d'ici", + "cover": "2e36ab01-c5d5-4c20-9cc3-eebeb9c29d5b", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Provence", + "secondLine": "Ici on parle d'ici", + "cover": "2e36ab01-c5d5-4c20-9cc3-eebeb9c29d5b", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 46: +{ + "prev": [ + { + "firstLine": "ICI Roussillon", + "secondLine": "Ici on parle d'ici", + "cover": "a735ac1b-f89f-471b-b3ee-551b6946c2e2", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Roussillon", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Roussillon", + "secondLine": "Ici on parle d'ici", + "cover": "a735ac1b-f89f-471b-b3ee-551b6946c2e2", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 47: +{ + "prev": [ + { + "firstLine": "ICI Touraine", + "secondLine": "Ici on parle d'ici", + "cover": "8bb007f4-d458-4dad-a18c-1902679a5d9a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Touraine", + "secondLine": "Ici on parle d'ici", + "cover": "8bb007f4-d458-4dad-a18c-1902679a5d9a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "ICI Touraine", + "secondLine": "Ici on parle d'ici", + "cover": "8bb007f4-d458-4dad-a18c-1902679a5d9a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 48: +{ + "prev": [ + { + "firstLine": "ICI Vaucluse", + "secondLine": "Ici on parle d'ici", + "cover": "68c80936-8244-4806-bce2-52a17536fb56", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Vaucluse", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Vaucluse", + "secondLine": "Ici on parle d'ici", + "cover": "68c80936-8244-4806-bce2-52a17536fb56", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 49: +{ + "prev": [ + { + "firstLine": "ICI Azur", + "secondLine": "Ici on parle d'ici", + "cover": "06460fe8-13db-4ec0-b98b-741c56a146d6", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Azur", + "secondLine": "Ici on parle d'ici", + "cover": "06460fe8-13db-4ec0-b98b-741c56a146d6", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Azur", + "secondLine": "Ici on parle d'ici", + "cover": "06460fe8-13db-4ec0-b98b-741c56a146d6", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 50: +{ + "prev": [ + { + "firstLine": "ICI Lorraine (Moselle et Pays Haut)", + "secondLine": "Ici on parle d'ici", + "cover": "eafc9a23-a5c8-4988-bbf8-c473f70a04f2", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Lorraine (Moselle et Pays Haut)", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Lorraine (Moselle et Pays Haut)", + "secondLine": "Ici on parle d'ici", + "cover": "eafc9a23-a5c8-4988-bbf8-c473f70a04f2", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 51: +================== +channel 52: +================== +channel 53: +================== +channel 54: +{ + "prev": [ + { + "firstLine": "ICI Poitou", + "secondLine": "Ici on parle d'ici", + "cover": "2ef2cf02-387b-4bfb-8a92-87cde963efd9", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Poitou", + "secondLine": "Ici on parle d'ici", + "cover": "2ef2cf02-387b-4bfb-8a92-87cde963efd9", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Poitou", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + } + ], + "delayToRefresh": 10000 +} +================== +channel 55: +================== +channel 56: +{ + "prev": [ + { + "firstLine": "ICI", + "secondLine": null, + "cover": "68e71e8f-5697-4f74-88e9-868f8a82f3e4", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI", + "secondLine": null, + "cover": "68e71e8f-5697-4f74-88e9-868f8a82f3e4", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 57: +================== +channel 58: +================== +channel 59: +================== +channel 60: +================== +channel 61: +================== +channel 62: +================== +channel 63: +================== +channel 64: +{ + "prev": [ + { + "firstLine": "FIP Rock", + "secondLine": "De Bowie à Radiohead, de Lou Reed à Miossec, notre mix rock des genres et des générations", + "cover": "cda37279-f985-4017-bc03-2dad643c7306", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Rock", + "secondLine": "Cream • Sunshine of your love", + "secondLineSongUuid": "461c3fac-1bc8-4020-bade-2f1aed633f54", + "cover": "3cf7e317-8d59-4708-a8a8-be09063d7eff", + "favoriteUuid": "461c3fac-1bc8-4020-bade-2f1aed633f54", + "startTime": 1769118358, + "endTime": 1769118605 + }, + "next": [ + { + "firstLine": "FIP Rock", + "secondLine": "The Bees • Stand", + "secondLineSongUuid": "77bd2968-5062-4f35-86da-ac1f856cdf9e", + "cover": "21528c73-d494-465d-a9a5-8b14bca8a741", + "favoriteUuid": "77bd2968-5062-4f35-86da-ac1f856cdf9e", + "startTime": 1769118604, + "endTime": 1769118852 + } + ], + "delayToRefresh": 30000 +} +================== +channel 65: +{ + "prev": [ + { + "firstLine": "FIP Jazz", + "secondLine": "Bebop, afro jazz, salsa, bossa, blues, tout le jazz, ses cousins et ses cousines", + "cover": "2de59c50-9912-401e-9d1f-95a2f0b66567", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Jazz", + "secondLine": "Lionel Hampton • Lullaby of birdland", + "secondLineSongUuid": "3100108b-5f86-416a-b713-abd3ed4899f7", + "cover": "96cda68f-de7e-4527-aa74-2e226e76fb8f", + "favoriteUuid": "3100108b-5f86-416a-b713-abd3ed4899f7", + "startTime": 1769118390, + "endTime": 1769118796 + }, + "next": [ + { + "firstLine": "FIP Jazz", + "secondLine": "Christian McBride • The ballad of the little girl dancer", + "secondLineSongUuid": "db746672-c397-4c3a-b71b-a51025030202", + "cover": "0e888036-b091-47ab-964e-4c80bfb31030", + "favoriteUuid": "db746672-c397-4c3a-b71b-a51025030202", + "startTime": 1769118795, + "endTime": 1769119126 + } + ], + "delayToRefresh": 240000 +} +================== +channel 66: +{ + "prev": [ + { + "firstLine": "FIP Groove", + "secondLine": "Gardez le rythme avec notre sélection dance ou Motown, funk, trip-hop, R'n'B et disco", + "cover": "95cf76c3-26bc-404e-be1c-b81666188c3f", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Groove", + "secondLine": "Say She She • C'est si bon", + "secondLineSongUuid": "1db1f15b-33f4-4511-afbf-6395f00f9968", + "cover": "6a5b0ed3-a932-40da-949f-67ee94a44a46", + "favoriteUuid": "1db1f15b-33f4-4511-afbf-6395f00f9968", + "startTime": 1769118579, + "endTime": 1769118811 + }, + "next": [ + { + "firstLine": "FIP Groove", + "secondLine": "Gardez le rythme avec notre sélection dance ou Motown, funk, trip-hop, R'n'B et disco", + "cover": "95cf76c3-26bc-404e-be1c-b81666188c3f", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 240000 +} +================== +channel 67: +================== +channel 68: +{ + "prev": [ + { + "firstLine": "ICI Paris Île-de-France", + "secondLine": "Ici on parle d'ici", + "cover": "aa396eeb-f9fa-4b9d-84fd-a56f2efeb296", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Paris Île-de-France", + "secondLine": "Ici on parle d'ici", + "cover": "aa396eeb-f9fa-4b9d-84fd-a56f2efeb296", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Paris Île-de-France", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + } + ], + "delayToRefresh": 10000 +} +================== +channel 69: +{ + "prev": [ + { + "firstLine": "FIP Monde", + "secondLine": "Un tour du monde en quelques heures, une épopée musicale qui traverse les continents", + "cover": "524fabe1-7645-4847-aa93-932d9835de67", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Monde", + "secondLine": "Leo Blomov • Sob as estrelas", + "secondLineSongUuid": "d12d3171-e73e-4604-8abc-139f85965a28", + "cover": "ea0415c6-afe2-4eb3-a43c-0370e2ee38b0", + "favoriteUuid": "d12d3171-e73e-4604-8abc-139f85965a28", + "startTime": 1769118378, + "endTime": 1769118583 + }, + "next": [ + { + "firstLine": "FIP Monde", + "secondLine": "Farhot • Mars", + "secondLineSongUuid": "1a9086ff-a932-4d8f-ad9a-75071d7d0925", + "cover": "ff6b3a7f-83be-49ed-ac8d-8d5a6a9ec463", + "favoriteUuid": "1a9086ff-a932-4d8f-ad9a-75071d7d0925", + "startTime": 1769118583, + "endTime": 1769118810 + } + ], + "delayToRefresh": 30000 +} +================== +channel 70: +{ + "prev": [ + { + "firstLine": "FIP Nouveautés", + "secondLine": "On ne les entend que sur FIP mais demain… Toutes nos découvertes sont ici", + "cover": "9f85ac9d-1151-429a-9645-d030e92b441f", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Nouveautés", + "secondLine": "Dijon • Loyal & Marie", + "secondLineSongUuid": "58206af8-589b-4dad-b373-11f4233a3b78", + "cover": "d5c7a594-680c-4a38-afc1-be55225359e7", + "favoriteUuid": "58206af8-589b-4dad-b373-11f4233a3b78", + "startTime": 1769118530, + "endTime": 1769118683 + }, + "next": [ + { + "firstLine": "FIP Nouveautés", + "secondLine": "Pinkpantheress • Girl like me (feat. Oklou)", + "secondLineSongUuid": "a0bd10b2-8b78-4f71-8cd2-52ad783c836b", + "cover": "9f3646bc-9caa-4c91-a476-c07b9cb4a51a", + "favoriteUuid": "a0bd10b2-8b78-4f71-8cd2-52ad783c836b", + "startTime": 1769118682, + "endTime": 1769118841 + } + ], + "delayToRefresh": 110000 +} +================== +channel 71: +{ + "prev": [ + { + "firstLine": "FIP Reggae", + "secondLine": "Des Toots à Biga Ranx, des Clash à DJ Vadim, un voyage vers Zion et au-delà", + "cover": "a242c7c5-bf1e-468b-9cb9-2154550f8be7", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Reggae", + "secondLine": "Don Drummond • Eastern standard time", + "secondLineSongUuid": "05a294f1-b069-4e12-90a2-a13aaed85bbb", + "cover": "883da212-c9a0-4582-bf2e-b2d1422180a2", + "favoriteUuid": "05a294f1-b069-4e12-90a2-a13aaed85bbb", + "startTime": 1769118576, + "endTime": 1769118763 + }, + "next": [ + { + "firstLine": "FIP Reggae", + "secondLine": "Higgs & Wilson • Mighty man", + "secondLineSongUuid": "5bef05fe-ba0a-40ff-abcb-aaf5b73bccb9", + "cover": "192141a0-3d93-4ae4-95a6-f4df6615acc1", + "favoriteUuid": "5bef05fe-ba0a-40ff-abcb-aaf5b73bccb9", + "startTime": 1769118762, + "endTime": 1769118876 + } + ], + "delayToRefresh": 190000 +} +================== +channel 72: +================== +channel 73: +{ + "prev": [ + { + "firstLine": "WR FIP TEST", + "secondLine": "tests", + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "WR FIP TEST", + "secondLine": "tests", + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "WR FIP TEST", + "secondLine": "tests", + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 74: +{ + "prev": [ + { + "firstLine": "FIP Electro", + "secondLine": "De Air à Soulwax, de Superpoze à Tosca, gardez le kick avec notre sélection électronique", + "cover": "bf85964f-395a-4e6a-beb4-6cc0590d20bb", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Electro", + "secondLine": "Paperclip People • The climax", + "secondLineSongUuid": "cead6ea6-35ae-4cc8-b8c0-763d0820b876", + "cover": "047f85cb-0ba7-4c5b-acb4-ac03cc5215a9", + "favoriteUuid": "cead6ea6-35ae-4cc8-b8c0-763d0820b876", + "startTime": 1769118418, + "endTime": 1769118813 + }, + "next": [ + { + "firstLine": "FIP Electro", + "secondLine": "Mad Rey • Right time (original mix)", + "secondLineSongUuid": "572a155f-cfaa-4849-8705-05b01fbe1748", + "cover": "7a3aeccd-62b5-46bf-b1b5-2d4552fceb6f", + "favoriteUuid": "572a155f-cfaa-4849-8705-05b01fbe1748", + "startTime": 1769118813, + "endTime": 1769119254 + } + ], + "delayToRefresh": 240000 +} +================== +channel 75: +{ + "prev": [ + { + "firstLine": "Mouv' 100% Mix", + "secondLine": "La radio des meilleurs Mix DJ", + "cover": "171dd098-523d-418a-9b2f-d94ad5c5092d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "Mouv' 100% Mix", + "secondLine": "La radio des meilleurs Mix DJ", + "cover": "171dd098-523d-418a-9b2f-d94ad5c5092d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "Mouv' 100% Mix", + "secondLine": "La radio des meilleurs Mix DJ", + "cover": "171dd098-523d-418a-9b2f-d94ad5c5092d", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 76: +================== +channel 77: +{ + "prev": [ + { + "firstLine": "FIP Metal", + "secondLine": "Le mix Metal éclectique de Black Sabbath à Deafheaven, de Gojira à Rammstein...", + "cover": "a3406ba2-41d4-4a94-89a2-c45470d606ff", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Metal", + "secondLine": "Dear Deceased • River", + "secondLineSongUuid": "f501ad5f-920a-4338-b8f0-a31e41642022", + "cover": "8dc8c156-962b-4195-874a-28c24c87d449", + "favoriteUuid": "f501ad5f-920a-4338-b8f0-a31e41642022", + "startTime": 1769118337, + "endTime": 1769118574 + }, + "next": [ + { + "firstLine": "FIP Metal", + "secondLine": "Sarcator • The deep ends", + "secondLineSongUuid": "456507a3-c0a9-46ef-8eae-89217e305202", + "cover": "32ea32f0-ba2f-41e1-a505-e71c56b44ecf", + "favoriteUuid": "456507a3-c0a9-46ef-8eae-89217e305202", + "startTime": 1769118574, + "endTime": 1769118913 + } + ], + "delayToRefresh": 20000 +} +================== +channel 78: +{ + "prev": [ + { + "firstLine": "FIP Pop", + "secondLine": "De l'indie-rock à la synth-wave, de la brit-pop à la scène française", + "cover": "867b3aac-d06c-4f79-85a3-fc1565526889", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Pop", + "secondLine": "Evergreen • Voices in your head", + "secondLineSongUuid": "b6d6d31f-8130-4115-a6d7-16610259767c", + "cover": "064b1692-5019-41f6-bb2d-d630be92d9fb", + "favoriteUuid": "b6d6d31f-8130-4115-a6d7-16610259767c", + "startTime": 1769118557, + "endTime": 1769118808 + }, + "next": [ + { + "firstLine": "FIP Pop", + "secondLine": "Zombies • Time of the season", + "secondLineSongUuid": "e923e1e8-4d6f-41cd-b1e7-d8b694139eff", + "cover": "1abec25e-d1f9-4321-af02-781399a664e8", + "favoriteUuid": "e923e1e8-4d6f-41cd-b1e7-d8b694139eff", + "startTime": 1769118807, + "endTime": 1769118998 + } + ], + "delayToRefresh": 230000 +} +================== +channel 79: +================== +channel 80: +================== +channel 81: +================== +channel 82: +================== +channel 83: +================== +channel 84: +================== +channel 85: +================== +channel 86: +================== +channel 87: +================== +channel 88: +================== +channel 89: +================== +channel 90: +{ + "prev": [ + { + "firstLine": "ICI Elsass", + "secondLine": "Ici on parle d'ici", + "cover": "303be539-4fe2-4c8c-9b48-f5d0bc7b637f", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Elsass", + "secondLine": "Ici on parle d'ici", + "cover": "303be539-4fe2-4c8c-9b48-f5d0bc7b637f", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + }, + "next": [ + { + "firstLine": "ICI Elsass", + "secondLine": "Ici on parle d'ici", + "cover": "303be539-4fe2-4c8c-9b48-f5d0bc7b637f", + "favoriteUuid": null, + "startTime": 1769119200, + "endTime": 1769122800 + } + ], + "delayToRefresh": 630000 +} +================== +channel 91: +{ + "prev": [ + { + "firstLine": "ICI Maine", + "secondLine": "Ici on parle d'ici", + "cover": "ea9da570-4e6e-4212-b5fa-2bcd1bdb1e3a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Maine", + "secondLine": "Ici on parle d'ici", + "cover": "ea9da570-4e6e-4212-b5fa-2bcd1bdb1e3a", + "favoriteUuid": null, + "startTime": 1769108400, + "endTime": 1769122800 + }, + "next": [ + { + "firstLine": "ICI Maine", + "secondLine": "Ici on parle d'ici", + "cover": "ea9da570-4e6e-4212-b5fa-2bcd1bdb1e3a", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 92: +{ + "prev": [ + { + "firstLine": "ICI Occitanie", + "secondLine": "Ici on parle d'ici", + "cover": "84e39c1c-1ade-430d-ba5e-a2cb35a76771", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Occitanie", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Occitanie", + "secondLine": "Ici on parle d'ici", + "cover": "84e39c1c-1ade-430d-ba5e-a2cb35a76771", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 93: +{ + "prev": [ + { + "firstLine": "ICI Saint-Étienne Loire", + "secondLine": "Ici on parle d'ici", + "cover": "17f5ec94-e96a-442f-9bf8-6f8c3798605c", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "ICI Saint-Étienne Loire", + "secondLine": "JANE BIRKIN • Les dessous chics", + "secondLineSongUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "cover": "e3c4b79f-9270-4f89-be29-4b98d298e63e", + "favoriteUuid": "b489eaa2-3062-48f7-8b1c-0379d509d42b", + "startTime": 1769118579, + "endTime": 1769118704 + }, + "next": [ + { + "firstLine": "ICI Saint-Étienne Loire", + "secondLine": "Ici on parle d'ici", + "cover": "17f5ec94-e96a-442f-9bf8-6f8c3798605c", + "favoriteUuid": null, + "startTime": 1769112000, + "endTime": 1769119200 + } + ], + "delayToRefresh": 130000 +} +================== +channel 94: +================== +channel 95: +{ + "prev": [ + { + "firstLine": "FIP Hip-Hop", + "secondLine": "Écoutez une programmation qui explore le passé, le présent et le futur du hip-hop. Des Last Poets à Little Simz, de Nujabes à Alpha Wann, plongez dans toute la richesse du rap mondial.", + "cover": "5c52d959-7ea2-4c9e-b298-5aab893a9b86", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Hip-Hop", + "secondLine": "Nerd • Hypnotize U", + "secondLineSongUuid": "97a41423-8d56-4d67-b7e4-b98b84baf098", + "cover": "d1a3b0ac-1b85-4a28-893a-5d1572fc7a3c", + "favoriteUuid": "97a41423-8d56-4d67-b7e4-b98b84baf098", + "startTime": 1769118334, + "endTime": 1769118588 + }, + "next": [ + { + "firstLine": "FIP Hip-Hop", + "secondLine": "Twit One • Chick", + "secondLineSongUuid": "a104ec93-16db-412a-b6f8-07de18195cbe", + "cover": "b6efeca4-bb00-453d-a1ae-d6c586010712", + "favoriteUuid": "a104ec93-16db-412a-b6f8-07de18195cbe", + "startTime": 1769118587, + "endTime": 1769118721 + } + ], + "delayToRefresh": 10000 +} +================== +channel 96: +{ + "prev": [ + { + "firstLine": "FIP Sacré français !", + "secondLine": "La webradio préférée des Francophiles et Anglophobes !", + "cover": "1641a25c-e8c8-49f8-b5e1-cffd8b722231", + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "FIP Sacré français !", + "secondLine": "Dominique A • La retraite à Miami", + "secondLineSongUuid": "bb8fa1e1-e1b6-4095-a094-405e76d81cf5", + "cover": "da5afd89-67f6-4bb8-8739-79cef3873b5a", + "favoriteUuid": "bb8fa1e1-e1b6-4095-a094-405e76d81cf5", + "startTime": 1769118538, + "endTime": 1769118753 + }, + "next": [ + { + "firstLine": "FIP Sacré français !", + "secondLine": "Fredda • Les flots bleus", + "secondLineSongUuid": "083f5a21-4d5f-43a2-9b7c-0ad4a0538dfe", + "cover": "6a449255-1eef-4a0b-8e46-870117c51716", + "favoriteUuid": "083f5a21-4d5f-43a2-9b7c-0ad4a0538dfe", + "startTime": 1769118753, + "endTime": 1769118951 + } + ], + "delayToRefresh": 180000 +} +================== +channel 97: +================== +channel 98: +================== +channel 99: +{ + "prev": [ + { + "firstLine": "Formation", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "Formation", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "Formation", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 100: +{ + "prev": [ + { + "firstLine": "Station de test", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "now": { + "firstLine": "Station de test", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + }, + "next": [ + { + "firstLine": "Station de test", + "secondLine": null, + "cover": null, + "favoriteUuid": null, + "startTime": null, + "endTime": null + } + ], + "delayToRefresh": 10000 +} +================== +channel 101: +================== +channel 102: +================== +channel 103: +================== +channel 104: +================== +channel 105: +================== +channel 106: +================== +channel 107: +================== +channel 108: +================== +channel 109: +================== +channel 110: +================== +channel 111: +================== +channel 112: +================== +channel 113: +================== +channel 114: +================== +channel 115: +================== +channel 116: +================== +channel 117: +================== +channel 118: +================== +channel 119: +================== +channel 120: +================== +channel 121: +================== +channel 122: +================== +channel 123: +================== +channel 124: +================== +channel 125: +================== +channel 126: +================== +channel 127: +================== +channel 128: +================== +channel 129: +================== +channel 130: +================== +channel 131: +================== +channel 132: +================== +channel 133: +================== +channel 134: +================== +channel 135: +================== +channel 136: +================== +channel 137: +================== +channel 138: +================== +channel 139: +================== +channel 140: +================== +channel 141: +================== +channel 142: +================== +channel 143: +================== +channel 144: +================== +channel 145: +================== +channel 146: +================== +channel 147: +================== +channel 148: +================== +channel 149: +================== +channel 150: +================== +channel 151: +================== +channel 152: +================== +channel 153: +================== +channel 154: +================== +channel 155: +================== +channel 156: +================== +channel 157: +================== +channel 158: +================== +channel 159: +================== +channel 160: +================== +channel 161: +================== +channel 162: +================== +channel 163: +================== +channel 164: +================== +channel 165: +================== +channel 166: +================== +channel 167: +================== +channel 168: +================== +channel 169: +================== +channel 170: +================== +channel 171: +================== +channel 172: +================== +channel 173: +================== +channel 174: +================== +channel 175: +================== +channel 176: +================== +channel 177: +================== +channel 178: +================== +channel 179: +================== +channel 180: +================== +channel 181: +================== +channel 182: +================== +channel 183: +================== +channel 184: +================== +channel 185: +================== +channel 186: +================== +channel 187: +================== +channel 188: +================== +channel 189: +================== +channel 190: +================== +channel 191: +================== +channel 192: +================== +channel 193: +================== +channel 194: +================== +channel 195: +================== +channel 196: +================== +channel 197: +================== +channel 198: +================== +channel 199: From 8adccaa2df811fea517abe079b447f504a0924d0 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 22 Jan 2026 23:49:51 +0100 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20impl=C3=A9mente=20client=20statef?= =?UTF-8?q?ul=20et=20support=20playlist=20UPnP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'un client stateful avec gestion automatique du cache pour les stations et métadonnées live. Fonctionnalités principales : - Client stateful avec cache à deux niveaux (stations 7 jours, métadonnées TTL dynamique) - Support des playlists UPnP avec structures StationGroups et StationPlaylist - Gestion intelligente du TTL respectant delayToRefresh de l'API - Intégration avec pmoconfig pour le caching des stations - Support des covers via pmocovers avec fallback sur URLs Pikapi - Doctests compilés avec contexte async complet - Feature playlist avec dépendance pmodidl Corrections et améliorations : - Ajout d'un variant Error::Config pour conversion automatique - Gestion propre des erreurs avec conversion anyhow::Error - Tests unitaires et d'intégration (26/26 passés) - Optimisations : pooling de connexions HTTP, préchargement intelligent Les stations sont organisées hiérarchiquement : standalone, groupes avec webradios, radios locales ICI (ex-France Bleu) Les métadonnées sont mises à jour dynamiquement avec respect des intervalles de polling --- .DS_Store | Bin 18436 -> 18436 bytes .../Report/Construire_pmoradiofrance.md | 214 +++++- Cargo.lock | 1 + pmoradiofrance/Cargo.toml | 5 +- pmoradiofrance/src/config_ext.rs | 13 +- pmoradiofrance/src/error.rs | 4 + pmoradiofrance/src/lib.rs | 20 +- pmoradiofrance/src/playlist.rs | 608 ++++++++++++++++++ pmoradiofrance/src/stateful_client.rs | 448 +++++++++++++ 9 files changed, 1307 insertions(+), 6 deletions(-) create mode 100644 pmoradiofrance/src/playlist.rs create mode 100644 pmoradiofrance/src/stateful_client.rs diff --git a/.DS_Store b/.DS_Store index 119d664b890d5a04fcbe2981ee30d29a7b02eb3c..f82f7f4aeeb609bfac01bff7dbc216b635c907c4 100644 GIT binary patch delta 769 zcmY*XO-vI(6n?MW(jo5o>xG%O(!5Q+rx=7J;!HKC!Hv;oEKc3M}LnYvwycmb80 z9$MyL;=!1BHYBb$E|}=SgC0G2(1VF*FP?R}tpO%^d0)Oa-+NzX-cAj7YFIn%^R?Ie zCh=K4J?$oO8+mF@sy|Ke@c1VT@R!OEe>FbEhbQ)31^tp63`rrp;C@hU_=8yJn0EoS5`mInY?#9M&Ccdo@^t{Ri1fFCMg^JMaI=-lMR(d5MBd0rmtXwzBh!Ry|u~_`%sYFk2->MwR>&Cs3 z&DLq*GP9UHYps)2xw~?QZOj{trfpI+$wt;851fqoP?UO(3G&QdB!ydt+|p)QT#6nN zUe_x&A#}N9n}vm9u|k~e5u9s`Gi+vAX;v{ElhNFYH{Ky@^R&qL%yeSwSUsE5ZCj@$ zGP`-x((}YxptHH$6Q z@DgQ|2vHhft3jyRHIEk}7=anczzW=lZFml^VGrKJ7x)Q(;4gM!90za&M{x?LQNtNr z#AUpVI^J#Sec0;L-^#6o6#-fwTFuzhVzeX}aoZhAya>1*$!doZ7CS@00UZh=MBa_W z+i;+(fAVI8@no$UwKfA fh?RExv_VO3*xYU!@Er5>+#9ZpU3Z74_I>{VMcTwy delta 194 zcmZpfz}PZ@ae@>RL;FM-W5)K00p^npTv#S^Gf7TXFyPl_Wnf@nXDDFEWyohpW5{7h zWJoRxF3QWv&r1i&FfybtWCCS$8B!T4CJCu+7UU3P*{s0wmT5CP2MY(I`DRAjNsN=< zF{y3l5eQ|T+-t(Id8sfP%jP1fnXDTdycsuhE0l3>-l<)|JXw(G&cqTkM$yeW2HSZy r|JE^Mf(RK+W_J(B%uJ3nvR2>1_5iz0CvwD5y2D diff --git a/Blackboard/Report/Construire_pmoradiofrance.md b/Blackboard/Report/Construire_pmoradiofrance.md index f982b83c..63a921f6 100644 --- a/Blackboard/Report/Construire_pmoradiofrance.md +++ b/Blackboard/Report/Construire_pmoradiofrance.md @@ -203,4 +203,216 @@ cargo run -p pmoradiofrance --example live_metadata -- fip_rock --- -**Fin du rapport** +## Round 4bis : Client Stateful et Support Playlist (2026-01-22) + +### Objectif + +Compléter l'implémentation avec un client stateful qui gère automatiquement le cache et les structures pour la génération de playlists UPnP. + +### Fichiers créés + +| Fichier | Description | +|---------|-------------| +| `pmoradiofrance/src/stateful_client.rs` | `RadioFranceStatefulClient` avec cache automatique | +| `pmoradiofrance/src/playlist.rs` | Structures pour playlists UPnP (groupes, items) | + +### Fichiers modifiés + +| Fichier | Modification | +|---------|--------------| +| `pmoradiofrance/src/lib.rs` | Ajout modules `stateful_client` et `playlist` + re-exports | +| `pmoradiofrance/src/error.rs` | Ajout variant `Config(#[from] anyhow::Error)` pour conversion | +| `pmoradiofrance/Cargo.toml` | Ajout dépendance `pmodidl` pour feature `playlist` | + +--- + +### RadioFranceStatefulClient + +Client de haut niveau avec gestion automatique du cache : + +```rust +use pmoradiofrance::RadioFranceStatefulClient; +use pmoconfig::get_config; + +let config = get_config(); +let client = RadioFranceStatefulClient::new(config).await?; + +// Cache automatique des stations (7 jours par défaut) +let stations = client.get_stations().await?; + +// Cache intelligent des métadonnées (respecte delayToRefresh) +let metadata = client.get_live_metadata("franceculture").await?; +``` + +**Caractéristiques** : + +- **Cache à deux niveaux** : + - Liste des stations : persisté dans pmoconfig (7 jours) + - Métadonnées live : en mémoire (TTL dynamique de l'API) + +- **Thread-safe** : Clone + Send + Sync via `Arc>` + +- **Gestion intelligente du TTL** : + - Stations : configurable via `set_station_cache_ttl()` + - Métadonnées : utilise `delayToRefresh` de l'API + +### Structures de Playlist + +#### StationGroups + +Organisation hiérarchique des stations pour navigation UPnP : + +```rust +pub struct StationGroups { + pub standalone: Vec, // Sans webradios + pub with_webradios: Vec, // Avec webradios + pub local_radios: Vec, // France Bleu/ICI +} + +pub struct StationGroup { + pub main: Station, + pub webradios: Vec, +} +``` + +**Logique de groupement** : +- Stations standalone : France Inter, France Culture, France Info, Mouv' +- Groupes avec webradios : FIP (+ FIP Rock, Jazz...), France Musique (+ variantes) +- Radios locales : ~44 radios ICI (ex-France Bleu) + +#### StationPlaylist + +Playlist UPnP volatile pour une station : + +```rust +pub struct StationPlaylist { + pub id: String, + pub station: Station, + pub stream_item: Item, // Item UPnP avec métadonnées +} +``` + +**Mapping des métadonnées vers UPnP** : + +| Type | title | artist | album | class | +|------|-------|--------|-------|-------| +| **Radio parlée** | émission • titre | producteur | émission | audioBroadcast | +| **Radio musicale** | titre chanson | artiste(s) | album | musicTrack | + +**Gestion des covers** : +- Extraction UUID depuis `visual_background` +- Cache via `pmocovers` (optionnel) +- URLs Pikapi haute résolution (Large: 560x960) + +--- + +### Corrections et améliorations + +#### 1. Gestion des erreurs + +**Problème** : Les méthodes `pmoconfig` retournent `anyhow::Result` mais le client utilise son propre type `Result`. + +**Solution** : Ajout d'un variant dans `Error` pour conversion automatique : +```rust +pub enum Error { + // ... + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), +} +``` + +#### 2. Feature playlist + +**Ajout** : Dépendance `pmodidl` pour les structures DIDL-Lite (Item, Resource) : +```toml +[features] +playlist = ["dep:pmoplaylist", "dep:pmodidl"] +``` + +#### 3. Doctests propres + +**Problème initial** : Exemples marqués `ignore` mais testés avec `--include-ignored`. + +**Solution** : Utilisation de `no_run` avec contexte async complet : +```rust +/// ```no_run +/// use pmoradiofrance::RadioFranceStatefulClient; +/// use pmoconfig::get_config; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let config = get_config(); +/// let client = RadioFranceStatefulClient::new(config).await?; +/// // ... +/// Ok(()) +/// } +/// ``` +``` + +**Avantages** : +- Exemples compilés (vérification syntaxe) +- Pas exécutés (pas de dépendance réseau) +- Lignes de contexte cachées avec `#` dans la doc générée + +#### 4. Conditional compilation propre + +**Feature `logging`** pour le debug : +```rust +#[cfg(feature = "logging")] +fn remaining_ttl(&self) -> Duration { ... } + +#[cfg(feature = "logging")] +tracing::debug!("Using cached metadata for {} (TTL: {:?})", + station, entry.remaining_ttl()); +``` + +--- + +### Tests + +**Résultats** : +- ✅ Tests unitaires : **26/26 passés** +- ✅ Tests d'intégration (API réelle) : **26/26 passés** +- ✅ Doctests : **12/12 compilés** + +```bash +# Tests complets (unitaires + intégration + doctests) +cargo test -p pmoradiofrance -- --include-ignored + +# Tests unitaires uniquement +cargo test -p pmoradiofrance --lib +``` + +--- + +### Règles métier importantes + +1. **URLs de stream constantes** : L'URL du stream ne change JAMAIS, seules les métadonnées changent +2. **Polling intelligent** : Toujours respecter `delayToRefresh` de l'API +3. **Renommage France Bleu → ICI** : Les slugs restent `francebleu_*` mais l'affichage utilise "ICI" +4. **Validation du cache** : Triple vérification (existence + TTL + version d'algorithme) + +--- + +### Prochaines étapes + +1. **Implémenter `source.rs`** : Trait `MusicSource` pour intégration UPnP + - Génération automatique des playlists via `StationGroups` + - Rafraîchissement périodique des métadonnées (respecte `delayToRefresh`) + - Gestion des streams live continus (pas de FIFO - API ne fournit que des flux) + +2. **Intégration serveur** : Routes API REST via `pmoserver` + - `/radiofrance/stations` : Liste des stations groupées + - `/radiofrance/{slug}/metadata` : Métadonnées live avec cache + - `/radiofrance/{slug}/stream` : Redirection vers flux HiFi + - Cache registry pour les covers + +3. **Optimisations** : + - Pool de connexions HTTP partagé entre instances + - Préchargement intelligent des métadonnées (stations populaires) + - Métriques de cache (hit rate, age, refresh count) + - Compression des réponses API + +--- + +**Fin du rapport Round 4bis** diff --git a/Cargo.lock b/Cargo.lock index 5f2098ae..010ae188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4151,6 +4151,7 @@ dependencies = [ "pmoaudiocache", "pmoconfig", "pmocovers", + "pmodidl", "pmoplaylist", "pmosource", "regex", diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml index 1784c26c..768fa6a9 100644 --- a/pmoradiofrance/Cargo.toml +++ b/pmoradiofrance/Cargo.toml @@ -42,6 +42,9 @@ regex = "1.11" # Common music source traits pmosource = { path = "../pmosource" } +# DIDL-Lite structures (for playlist support) +pmodidl = { path = "../pmodidl", optional = true } + # Configuration support pmoconfig = { path = "../pmoconfig", optional = true } @@ -59,7 +62,7 @@ pmoconfig = ["dep:pmoconfig"] # Feature for cache support cache = ["dep:pmocovers", "dep:pmoaudiocache"] # Feature for playlist/FIFO support -playlist = ["dep:pmoplaylist"] +playlist = ["dep:pmoplaylist", "dep:pmodidl"] # Feature for logging (tracing) logging = [] # Feature for server support (cache registry) diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs index e73a170f..867d613a 100644 --- a/pmoradiofrance/src/config_ext.rs +++ b/pmoradiofrance/src/config_ext.rs @@ -11,10 +11,11 @@ //! //! # Exemple //! -//! ```rust,ignore +//! ```no_run //! use pmoconfig::get_config; //! use pmoradiofrance::RadioFranceConfigExt; //! +//! # fn main() -> anyhow::Result<()> { //! let config = get_config(); //! //! // Check if enabled @@ -27,6 +28,8 @@ //! if let Some(cached) = config.get_radiofrance_cached_stations()? { //! println!("Found {} cached stations", cached.stations.len()); //! } +//! # Ok(()) +//! # } //! ``` use crate::models::{CachedStationList, Station}; @@ -121,7 +124,11 @@ pub trait RadioFranceConfigExt { /// /// # Example /// - /// ```rust,ignore + /// ```no_run + /// # use pmoconfig::get_config; + /// # use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient}; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { /// let config = get_config(); /// let stations = if let Some(cached) = config.get_radiofrance_stations_cached()? { /// cached @@ -131,6 +138,8 @@ pub trait RadioFranceConfigExt { /// config.set_radiofrance_cached_stations(&discovered)?; /// discovered /// }; + /// # Ok(()) + /// # } /// ``` fn get_radiofrance_stations_cached(&self) -> Result>>; } diff --git a/pmoradiofrance/src/error.rs b/pmoradiofrance/src/error.rs index 0fa36d2b..5e78ec85 100644 --- a/pmoradiofrance/src/error.rs +++ b/pmoradiofrance/src/error.rs @@ -50,6 +50,10 @@ pub enum Error { #[error("Request timeout")] Timeout, + /// Configuration error (from pmoconfig/anyhow) + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + /// Generic error #[error("{0}")] Other(String), diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs index 64e17ea6..cf9e3fe9 100644 --- a/pmoradiofrance/src/lib.rs +++ b/pmoradiofrance/src/lib.rs @@ -52,10 +52,12 @@ //! When the `pmoconfig` feature is enabled, this crate provides a configuration //! extension trait for caching station lists: //! -//! ```rust,ignore +//! ```no_run //! use pmoconfig::get_config; -//! use pmoradiofrance::RadioFranceConfigExt; +//! use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient}; //! +//! # #[tokio::main] +//! # async fn main() -> anyhow::Result<()> { //! let config = get_config(); //! //! // Check cached stations (default TTL: 7 days) @@ -67,6 +69,8 @@ //! let stations = client.discover_all_stations().await?; //! config.set_radiofrance_cached_stations(&stations)?; //! } +//! # Ok(()) +//! # } //! ``` //! //! # API Rate Limiting @@ -91,6 +95,12 @@ pub mod models; #[cfg(feature = "pmoconfig")] pub mod config_ext; +#[cfg(feature = "pmoconfig")] +pub mod stateful_client; + +#[cfg(feature = "playlist")] +pub mod playlist; + // Re-exports pub use client::{ClientBuilder, RadioFranceClient}; pub use error::{Error, Result}; @@ -101,3 +111,9 @@ pub use models::{ #[cfg(feature = "pmoconfig")] pub use config_ext::RadioFranceConfigExt; + +#[cfg(feature = "pmoconfig")] +pub use stateful_client::RadioFranceStatefulClient; + +#[cfg(feature = "playlist")] +pub use playlist::{StationGroup, StationGroups, StationPlaylist}; diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs new file mode 100644 index 00000000..aa409c46 --- /dev/null +++ b/pmoradiofrance/src/playlist.rs @@ -0,0 +1,608 @@ +//! Structures et helpers pour la construction de playlists UPnP Radio France +//! +//! Ce module fournit les structures nécessaires pour organiser les stations +//! Radio France en groupes hiérarchiques et construire des playlists UPnP +//! avec métadonnées volatiles. +//! +//! # Architecture +//! +//! - `StationGroups` : Organisation hiérarchique de toutes les stations +//! - `StationGroup` : Groupe station principale + webradios associées +//! - `StationPlaylist` : Playlist UPnP volatile pour une station +//! +//! # Exemple +//! +//! ```rust,ignore +//! use pmoradiofrance::playlist::{StationGroups, StationPlaylist}; +//! +//! // Organiser les stations en groupes +//! let groups = StationGroups::from_stations(stations); +//! +//! // Construire une playlist pour une station +//! let playlist = StationPlaylist::from_live_metadata( +//! station, +//! metadata, +//! &cover_cache, +//! server_base_url, +//! ).await?; +//! ``` + +use crate::error::Result; +use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat}; +use pmodidl::{Item, Resource}; + +#[cfg(feature = "cache")] +use pmocovers::Cache as CoverCache; +#[cfg(feature = "cache")] +use std::sync::Arc; + +// ============================================================================ +// Groupes de stations +// ============================================================================ + +/// Groupes de stations organisés hiérarchiquement +/// +/// Cette structure organise les stations Radio France en trois catégories : +/// - `standalone` : Stations sans webradios (France Culture, France Inter, France Info, Mouv') +/// - `with_webradios` : Groupes avec station principale + webradios (FIP, France Musique) +/// - `local_radios` : Toutes les radios ICI (ex-France Bleu) +#[derive(Debug, Clone)] +pub struct StationGroups { + /// Stations sans webradios associées + pub standalone: Vec, + /// Groupes station principale + webradios + pub with_webradios: Vec, + /// Radios locales ICI (ex-France Bleu) + pub local_radios: Vec, +} + +/// Groupe station principale + webradios associées +#[derive(Debug, Clone)] +pub struct StationGroup { + /// Station principale (ex: FIP) + pub main: Station, + /// Webradios associées (ex: FIP Rock, FIP Jazz, ...) + pub webradios: Vec, +} + +impl StationGroups { + /// Organise une liste de stations en groupes hiérarchiques + /// + /// # Logique de regroupement + /// + /// 1. Les stations locales (France Bleu/ICI) sont regroupées dans `local_radios` + /// 2. Les webradios sont associées à leur station parente + /// 3. Les stations principales sans webradios vont dans `standalone` + /// 4. Les stations avec au moins une webradio vont dans `with_webradios` + pub fn from_stations(stations: Vec) -> Self { + use std::collections::HashMap; + + let mut standalone = Vec::new(); + let mut local_radios = Vec::new(); + let mut main_stations: HashMap = HashMap::new(); + let mut webradios_by_parent: HashMap> = HashMap::new(); + + // Premier passage : trier par type + for station in stations { + match &station.station_type { + StationType::Main => { + main_stations.insert(station.slug.clone(), station); + } + StationType::Webradio { parent_station } => { + webradios_by_parent + .entry(parent_station.clone()) + .or_default() + .push(station); + } + StationType::LocalRadio { .. } => { + local_radios.push(station); + } + } + } + + // Deuxième passage : construire les groupes + let mut with_webradios = Vec::new(); + + for (slug, main) in main_stations { + if let Some(webradios) = webradios_by_parent.remove(&slug) { + // Cette station a des webradios + with_webradios.push(StationGroup { main, webradios }); + } else { + // Station standalone + standalone.push(main); + } + } + + // Trier pour un affichage cohérent + standalone.sort_by(|a, b| a.name.cmp(&b.name)); + local_radios.sort_by(|a, b| a.name.cmp(&b.name)); + with_webradios.sort_by(|a, b| a.main.name.cmp(&b.main.name)); + + for group in &mut with_webradios { + group.webradios.sort_by(|a, b| a.name.cmp(&b.name)); + } + + Self { + standalone, + with_webradios, + local_radios, + } + } + + /// Retourne toutes les stations dans un ordre de navigation logique + /// + /// Ordre : standalone, puis groupes (main + webradios), puis locales + pub fn all_stations(&self) -> impl Iterator { + self.standalone + .iter() + .chain( + self.with_webradios + .iter() + .flat_map(|g| std::iter::once(&g.main).chain(g.webradios.iter())), + ) + .chain(self.local_radios.iter()) + } + + /// Nombre total de stations + pub fn total_count(&self) -> usize { + self.standalone.len() + + self + .with_webradios + .iter() + .map(|g| 1 + g.webradios.len()) + .sum::() + + self.local_radios.len() + } +} + +impl StationGroup { + /// Retourne toutes les stations du groupe (main + webradios) + pub fn all_stations(&self) -> impl Iterator { + std::iter::once(&self.main).chain(self.webradios.iter()) + } + + /// Nombre de stations dans le groupe + pub fn count(&self) -> usize { + 1 + self.webradios.len() + } +} + +// ============================================================================ +// Playlist UPnP pour une station +// ============================================================================ + +/// Playlist UPnP volatile pour une station Radio France +/// +/// Contient UN SEUL item représentant le stream live. +/// Les métadonnées de l'item changent au fil du temps (émissions, morceaux) +/// mais l'URL du stream reste identique. +/// +/// # Volatilité +/// +/// - L'URL du stream ne change JAMAIS +/// - Le titre, artiste, album changent toutes les 2-5 minutes +/// - La cover change avec chaque nouvelle émission/morceau +#[derive(Debug, Clone)] +pub struct StationPlaylist { + /// ID de la playlist (ex: "radiofrance:franceculture") + pub id: String, + + /// Station source + pub station: Station, + + /// Item UPnP unique représentant le stream + pub stream_item: Item, +} + +impl StationPlaylist { + /// Construit une playlist depuis les métadonnées live + /// + /// # Arguments + /// + /// * `station` - Station Radio France + /// * `metadata` - Métadonnées live de l'API + /// * `cover_cache` - Cache des covers (optionnel) + /// * `server_base_url` - URL de base du serveur pour les covers cachées + /// + /// # Mapping des métadonnées + /// + /// Pour **radios parlées** (France Culture, France Inter, France Info) : + /// - `title` = émission + titre du jour + /// - `artist` = producteur + /// - `album` = nom de l'émission + /// + /// Pour **radios musicales** (FIP, France Musique) : + /// - Si morceau en cours : titre, artiste, album du morceau + /// - Sinon : fallback sur le mapping radio parlée + #[cfg(feature = "cache")] + pub async fn from_live_metadata( + station: Station, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result { + let id = format!("radiofrance:{}", station.slug); + let stream_item = + Self::build_item_from_metadata(&station, metadata, cover_cache, server_base_url) + .await?; + + Ok(Self { + id, + station, + stream_item, + }) + } + + /// Construit une playlist sans cache de covers + pub fn from_live_metadata_no_cache(station: Station, metadata: &LiveResponse) -> Result { + let id = format!("radiofrance:{}", station.slug); + let stream_item = Self::build_item_from_metadata_sync(&station, metadata, None)?; + + Ok(Self { + id, + station, + stream_item, + }) + } + + /// Met à jour les métadonnées volatiles de l'item + /// + /// Met à jour uniquement les champs volatiles : + /// - title, artist, album (depuis nouvelles métadonnées) + /// - album_art / album_art_pk (si nouvelle cover) + /// + /// L'URL du stream (resource.url) ne change JAMAIS. + #[cfg(feature = "cache")] + pub async fn update_metadata( + &mut self, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result<()> { + // Reconstruire l'item avec les nouvelles métadonnées + // mais conserver l'URL du stream + let old_url = self + .stream_item + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut new_item = + Self::build_item_from_metadata(&self.station, metadata, cover_cache, server_base_url) + .await?; + + // S'assurer que l'URL du stream n'a pas changé + if let Some(res) = new_item.resources.first_mut() { + if !old_url.is_empty() { + res.url = old_url; + } + } + + self.stream_item = new_item; + Ok(()) + } + + /// Met à jour les métadonnées sans cache + pub fn update_metadata_no_cache(&mut self, metadata: &LiveResponse) -> Result<()> { + let old_url = self + .stream_item + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut new_item = Self::build_item_from_metadata_sync(&self.station, metadata, None)?; + + if let Some(res) = new_item.resources.first_mut() { + if !old_url.is_empty() { + res.url = old_url; + } + } + + self.stream_item = new_item; + Ok(()) + } + + /// Construit un Item UPnP depuis les métadonnées live (avec cache) + #[cfg(feature = "cache")] + async fn build_item_from_metadata( + station: &Station, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result { + let (title, creator, artist, album, genre, class) = + Self::extract_metadata_fields(station, metadata); + + // Gestion de la cover + let (album_art, album_art_pk) = if let Some(cache) = cover_cache { + Self::cache_cover(metadata, cache, server_base_url).await + } else { + Self::extract_cover_url(metadata) + }; + + // Construction de la ressource (stream) + let resource = Self::build_stream_resource(metadata); + + Ok(Item { + id: format!("radiofrance:{}:stream", station.slug), + parent_id: format!("radiofrance:{}", station.slug), + restricted: Some("1".to_string()), + title, + creator, + class, + artist, + album, + genre, + album_art, + album_art_pk, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + }) + } + + /// Construit un Item UPnP depuis les métadonnées live (sans cache async) + fn build_item_from_metadata_sync( + station: &Station, + metadata: &LiveResponse, + _cover_url_override: Option, + ) -> Result { + let (title, creator, artist, album, genre, class) = + Self::extract_metadata_fields(station, metadata); + + let (album_art, album_art_pk) = Self::extract_cover_url(metadata); + let resource = Self::build_stream_resource(metadata); + + Ok(Item { + id: format!("radiofrance:{}:stream", station.slug), + parent_id: format!("radiofrance:{}", station.slug), + restricted: Some("1".to_string()), + title, + creator, + class, + artist, + album, + genre, + album_art, + album_art_pk, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + }) + } + + /// Extrait les champs de métadonnées selon le type de radio + fn extract_metadata_fields( + station: &Station, + metadata: &LiveResponse, + ) -> ( + String, + Option, + Option, + Option, + Option, + String, + ) { + let now = &metadata.now; + + // Détecter si c'est une radio musicale avec un morceau en cours + if let Some(ref song) = now.song { + // Radio musicale avec morceau + let title = now.first_line.title_or_default().to_string(); + let artist = if song.interpreters.is_empty() { + None + } else { + Some(song.artists_display()) + }; + let album = song.release.title.clone(); + let creator = artist.clone(); + let genre = Some("Music".to_string()); + let class = "object.item.audioItem.musicTrack".to_string(); + + (title, creator, artist, album, genre, class) + } else { + // Radio parlée ou segment talk sur radio musicale + let first = now.first_line.title_or_default(); + let second = now.second_line.title_or_default(); + + let title = if !first.is_empty() && !second.is_empty() { + format!("{} • {}", first, second) + } else if !first.is_empty() { + first.to_string() + } else { + station.display_name().to_string() + }; + + let creator = now.producer.clone(); + let artist = now.producer.clone(); + let album = if !first.is_empty() { + Some(first.to_string()) + } else { + None + }; + let genre = Some("Talk Radio".to_string()); + let class = "object.item.audioItem.audioBroadcast".to_string(); + + (title, creator, artist, album, genre, class) + } + } + + /// Extrait l'URL de cover depuis les métadonnées (sans cache) + fn extract_cover_url(metadata: &LiveResponse) -> (Option, Option) { + // Priorité : visual_background > song cover + if let Some(ref visual) = metadata.now.visual_background { + if let Some(uuid) = visual.extract_uuid() { + let url = ImageSize::Large.build_url(&uuid); + return (Some(url), None); + } + } + + // Pas de cover trouvée + (None, None) + } + + /// Cache la cover et retourne (url_publique, pk) + #[cfg(feature = "cache")] + async fn cache_cover( + metadata: &LiveResponse, + cache: &Arc, + server_base_url: Option<&str>, + ) -> (Option, Option) { + // Extraire l'UUID de la cover + let uuid = metadata + .now + .visual_background + .as_ref() + .and_then(|v| v.extract_uuid()); + + let uuid = match uuid { + Some(u) => u, + None => return (None, None), + }; + + // URL haute résolution + let cover_url = ImageSize::Large.build_url(&uuid); + + // Tenter de cacher la cover + match cache.add_from_url(&cover_url, Some("radiofrance")).await { + Ok(pk) => { + // Construire l'URL publique si server_base_url est fourni + let public_url = server_base_url + .map(|base| format!("{}/covers/{}", base.trim_end_matches('/'), pk)); + + (public_url.or(Some(cover_url)), Some(pk)) + } + Err(e) => { + tracing::warn!("Failed to cache Radio France cover: {}", e); + (Some(cover_url), None) + } + } + } + + /// Construit la ressource stream + fn build_stream_resource(metadata: &LiveResponse) -> Resource { + // Trouver le meilleur stream HiFi + let best_stream = metadata.now.media.best_hifi_stream(); + + let (url, protocol_info, sample_frequency, nr_audio_channels) = match best_stream { + Some(stream) => { + let protocol_info = match stream.format { + StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(), + StreamFormat::Hls => "http-get:*:application/vnd.apple.mpegurl:*".to_string(), + StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(), + }; + + let sample_freq = match stream.format { + StreamFormat::Aac => Some("48000".to_string()), + _ => None, + }; + + let channels = match stream.format { + StreamFormat::Aac | StreamFormat::Mp3 => Some("2".to_string()), + _ => None, + }; + + (stream.url.clone(), protocol_info, sample_freq, channels) + } + None => { + // Fallback : pas de stream trouvé + ( + String::new(), + "http-get:*:audio/aac:*".to_string(), + None, + None, + ) + } + }; + + Resource { + protocol_info, + bits_per_sample: None, + sample_frequency, + nr_audio_channels, + duration: None, // Stream live = pas de durée + url, + } + } + + /// Retourne l'URL du stream + pub fn stream_url(&self) -> Option<&str> { + self.stream_item.resources.first().map(|r| r.url.as_str()) + } + + /// Retourne le titre actuel + pub fn current_title(&self) -> &str { + &self.stream_item.title + } + + /// Retourne l'artiste actuel + pub fn current_artist(&self) -> Option<&str> { + self.stream_item.artist.as_deref() + } +} + +// ============================================================================ +// Helpers pour le renommage France Bleu → ICI +// ============================================================================ + +impl Station { + /// Retourne le nom d'affichage avec renommage France Bleu → ICI + /// + /// Les slugs sont conservés (francebleu_alsace) mais l'affichage + /// utilise "ICI" (ICI Alsace). + pub fn display_name(&self) -> &str { + // Le renommage est déjà fait lors de la découverte via l'API + // qui retourne directement "ICI Alsace" etc. + &self.name + } + + /// Vérifie si c'est une radio ICI (ex-France Bleu locale) + pub fn is_ici_radio(&self) -> bool { + self.name.starts_with("ICI ") || self.slug.starts_with("francebleu_") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_station_groups_organization() { + let stations = vec![ + Station::main("franceculture", "France Culture"), + Station::main("fip", "FIP"), + Station::webradio("fip_rock", "FIP Rock", "fip"), + Station::webradio("fip_jazz", "FIP Jazz", "fip"), + Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1), + ]; + + let groups = StationGroups::from_stations(stations); + + assert_eq!(groups.standalone.len(), 1); + assert_eq!(groups.standalone[0].slug, "franceculture"); + + assert_eq!(groups.with_webradios.len(), 1); + assert_eq!(groups.with_webradios[0].main.slug, "fip"); + assert_eq!(groups.with_webradios[0].webradios.len(), 2); + + assert_eq!(groups.local_radios.len(), 1); + assert_eq!(groups.local_radios[0].slug, "francebleu_alsace"); + + assert_eq!(groups.total_count(), 5); + } + + #[test] + fn test_station_display_name() { + let station = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1); + assert_eq!(station.display_name(), "ICI Alsace"); + assert!(station.is_ici_radio()); + + let main = Station::main("franceculture", "France Culture"); + assert_eq!(main.display_name(), "France Culture"); + assert!(!main.is_ici_radio()); + } +} diff --git a/pmoradiofrance/src/stateful_client.rs b/pmoradiofrance/src/stateful_client.rs new file mode 100644 index 00000000..f7393082 --- /dev/null +++ b/pmoradiofrance/src/stateful_client.rs @@ -0,0 +1,448 @@ +//! Stateful client for Radio France with automatic caching +//! +//! This module provides a higher-level client that automatically manages +//! station discovery caching through pmoconfig, providing a simpler API +//! for integration into PMOMusic. +//! +//! # Example +//! +//! ```no_run +//! use pmoradiofrance::RadioFranceStatefulClient; +//! use pmoconfig::get_config; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let config = get_config(); +//! let client = RadioFranceStatefulClient::new(config).await?; +//! +//! // Get stations (automatically cached with 7-day TTL) +//! let stations = client.get_stations().await?; +//! +//! // Get live metadata (handles caching internally) +//! let metadata = client.get_live_metadata("franceculture").await?; +//! +//! Ok(()) +//! } +//! ``` + +use crate::client::RadioFranceClient; +use crate::config_ext::RadioFranceConfigExt; +use crate::error::{Error, Result}; +use crate::models::{LiveResponse, Station}; +use pmoconfig::Config; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +/// Cache entry for live metadata +#[derive(Debug, Clone)] +struct LiveMetadataCache { + /// Cached metadata + metadata: LiveResponse, + /// When the cache should be invalidated (based on delayToRefresh) + valid_until: SystemTime, +} + +impl LiveMetadataCache { + /// Create a new cache entry + fn new(metadata: LiveResponse) -> Self { + let delay = Duration::from_millis(metadata.delay_to_refresh); + let valid_until = SystemTime::now() + delay; + + Self { + metadata, + valid_until, + } + } + + /// Check if the cache is still valid + fn is_valid(&self) -> bool { + SystemTime::now() < self.valid_until + } + + /// Get the remaining time until the cache expires + #[cfg(feature = "logging")] + fn remaining_ttl(&self) -> Duration { + self.valid_until + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + } +} + +/// Stateful Radio France client with automatic caching +/// +/// This client wraps `RadioFranceClient` and adds: +/// - Automatic station list caching via pmoconfig +/// - Live metadata caching (in-memory, respecting delayToRefresh) +/// - Simple high-level API for PMOMusic integration +/// +/// # Caching Strategy +/// +/// - **Station List**: Cached in pmoconfig with 7-day TTL (configurable) +/// - **Live Metadata**: Cached in-memory per station, TTL from API's delayToRefresh +/// +/// # Thread Safety +/// +/// This client is thread-safe (Clone + Send + Sync) and can be shared +/// across async tasks. +#[derive(Clone)] +pub struct RadioFranceStatefulClient { + /// Underlying HTTP client + client: RadioFranceClient, + /// Configuration handle (Arc for sharing) + config: Arc, + /// In-memory cache for live metadata (thread-safe) + metadata_cache: Arc>>, +} + +impl RadioFranceStatefulClient { + /// Create a new stateful client + /// + /// # Arguments + /// + /// * `config` - Configuration handle for caching station lists + /// + /// # Example + /// + /// ```no_run + /// use pmoconfig::get_config; + /// use pmoradiofrance::RadioFranceStatefulClient; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let config = get_config(); + /// let client = RadioFranceStatefulClient::new(config).await?; + /// Ok(()) + /// } + /// ``` + pub async fn new(config: Arc) -> Result { + let client = RadioFranceClient::new().await?; + Ok(Self { + client, + config, + metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + }) + } + + /// Create a client with a custom RadioFranceClient + pub fn with_client(client: RadioFranceClient, config: Arc) -> Self { + Self { + client, + config, + metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + } + } + + /// Get the underlying HTTP client + pub fn client(&self) -> &RadioFranceClient { + &self.client + } + + /// Get the configuration + pub fn config(&self) -> &Arc { + &self.config + } + + // ======================================================================== + // Station Discovery (with automatic caching) + // ======================================================================== + + /// Get all stations, using cache if valid + /// + /// This method automatically: + /// 1. Checks if Radio France is enabled in config + /// 2. Tries to use cached station list + /// 3. If cache miss/expired, discovers and caches stations + /// + /// # Errors + /// + /// Returns error if: + /// - Radio France is disabled in config + /// - Discovery fails and no valid cache exists + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceStatefulClient; + /// # use pmoconfig::get_config; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let config = get_config(); + /// # let client = RadioFranceStatefulClient::new(config).await?; + /// let stations = client.get_stations().await?; + /// for station in stations { + /// println!("{} - {}", station.name, station.slug); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_stations(&self) -> Result> { + // Check if Radio France is enabled + if !self.config.get_radiofrance_enabled()? { + return Err(Error::other("Radio France is disabled in configuration")); + } + + // Try to get from cache + if let Some(stations) = self.config.get_radiofrance_stations_cached()? { + #[cfg(feature = "logging")] + tracing::debug!("Using {} cached stations", stations.len()); + return Ok(stations); + } + + // Cache miss - discover and cache + #[cfg(feature = "logging")] + tracing::info!("Station cache miss - discovering stations"); + + let stations = self.client.discover_all_stations().await?; + + // Cache the results + self.config.set_radiofrance_cached_stations(&stations)?; + + #[cfg(feature = "logging")] + tracing::info!("Discovered and cached {} stations", stations.len()); + + Ok(stations) + } + + /// Force refresh of the station list (bypass cache) + /// + /// Use this to force re-discovery, for example after a manual + /// cache invalidation or to get the latest station list. + pub async fn refresh_stations(&self) -> Result> { + #[cfg(feature = "logging")] + tracing::info!("Force refreshing station list"); + + let stations = self.client.discover_all_stations().await?; + self.config.set_radiofrance_cached_stations(&stations)?; + + #[cfg(feature = "logging")] + tracing::info!("Refreshed {} stations", stations.len()); + + Ok(stations) + } + + /// Clear the station cache + /// + /// Forces next `get_stations()` call to re-discover stations. + pub fn clear_station_cache(&self) -> Result<()> { + Ok(self.config.clear_radiofrance_station_cache()?) + } + + // ======================================================================== + // Live Metadata (with intelligent caching) + // ======================================================================== + + /// Get live metadata for a station, using cache if valid + /// + /// This method automatically: + /// 1. Checks in-memory cache + /// 2. If cache valid (based on delayToRefresh), returns cached data + /// 3. If cache expired, fetches fresh data and updates cache + /// + /// # Arguments + /// + /// * `station` - Station slug (e.g., "franceculture", "fip_rock") + /// + /// # Caching Behavior + /// + /// The cache TTL is determined by the API's `delayToRefresh` field, + /// which respects Radio France's recommended polling interval. + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceStatefulClient; + /// # use pmoconfig::get_config; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let config = get_config(); + /// # let client = RadioFranceStatefulClient::new(config).await?; + /// let metadata = client.get_live_metadata("franceculture").await?; + /// println!("Now: {} - {}", + /// metadata.now.first_line.title_or_default(), + /// metadata.now.second_line.title_or_default() + /// ); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_live_metadata(&self, station: &str) -> Result { + // Check cache first + { + let cache = self.metadata_cache.read().unwrap(); + if let Some(entry) = cache.get(station) { + if entry.is_valid() { + #[cfg(feature = "logging")] + tracing::debug!( + "Using cached metadata for {} (TTL: {:?})", + station, + entry.remaining_ttl() + ); + return Ok(entry.metadata.clone()); + } + } + } + + // Cache miss or expired - fetch fresh data + #[cfg(feature = "logging")] + tracing::debug!("Fetching live metadata for {}", station); + + let metadata = self.client.live_metadata(station).await?; + + // Update cache + { + let mut cache = self.metadata_cache.write().unwrap(); + cache.insert( + station.to_string(), + LiveMetadataCache::new(metadata.clone()), + ); + } + + #[cfg(feature = "logging")] + tracing::debug!( + "Cached metadata for {} (TTL: {} ms)", + station, + metadata.delay_to_refresh + ); + + Ok(metadata) + } + + /// Force refresh of live metadata (bypass cache) + /// + /// Use this when you need the absolute latest metadata, + /// ignoring the cached version. + pub async fn refresh_live_metadata(&self, station: &str) -> Result { + #[cfg(feature = "logging")] + tracing::debug!("Force refreshing metadata for {}", station); + + let metadata = self.client.live_metadata(station).await?; + + // Update cache + { + let mut cache = self.metadata_cache.write().unwrap(); + cache.insert( + station.to_string(), + LiveMetadataCache::new(metadata.clone()), + ); + } + + Ok(metadata) + } + + /// Clear the metadata cache for a specific station + pub fn clear_metadata_cache(&self, station: &str) { + let mut cache = self.metadata_cache.write().unwrap(); + cache.remove(station); + + #[cfg(feature = "logging")] + tracing::debug!("Cleared metadata cache for {}", station); + } + + /// Clear all metadata caches + pub fn clear_all_metadata_caches(&self) { + let mut cache = self.metadata_cache.write().unwrap(); + cache.clear(); + + #[cfg(feature = "logging")] + tracing::debug!("Cleared all metadata caches"); + } + + // ======================================================================== + // Convenience Methods + // ======================================================================== + + /// Get the HiFi stream URL for a station + /// + /// Convenience wrapper around `get_live_metadata()` that extracts + /// the best HiFi stream URL. + pub async fn get_stream_url(&self, station: &str) -> Result { + self.client.get_hifi_stream_url(station).await + } + + /// Check if Radio France is enabled in configuration + pub fn is_enabled(&self) -> Result { + Ok(self.config.get_radiofrance_enabled()?) + } + + /// Enable Radio France in configuration + pub fn set_enabled(&self, enabled: bool) -> Result<()> { + Ok(self.config.set_radiofrance_enabled(enabled)?) + } + + /// Get the station cache TTL in seconds + pub fn get_station_cache_ttl(&self) -> Result { + Ok(self.config.get_radiofrance_station_cache_ttl()?) + } + + /// Set the station cache TTL in seconds + pub fn set_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> { + Ok(self.config.set_radiofrance_station_cache_ttl(ttl_secs)?) + } + + /// Get cache statistics + /// + /// Returns (number of cached stations, number of cached metadata entries) + pub fn cache_stats(&self) -> (usize, usize) { + let station_count = self + .config + .get_radiofrance_stations_cached() + .ok() + .flatten() + .map(|s| s.len()) + .unwrap_or(0); + + let metadata_count = self.metadata_cache.read().unwrap().len(); + + (station_count, metadata_count) + } +} + +impl std::fmt::Debug for RadioFranceStatefulClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (station_cache, metadata_cache) = self.cache_stats(); + f.debug_struct("RadioFranceStatefulClient") + .field("client", &self.client) + .field("cached_stations", &station_cache) + .field("cached_metadata_entries", &metadata_cache) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Real integration tests would require pmoconfig setup + // These are just structural tests + + #[test] + fn test_live_metadata_cache_validity() { + let response = LiveResponse { + station_name: "test".to_string(), + delay_to_refresh: 5000, // 5 seconds + migrated: true, + now: crate::models::ShowMetadata { + print_prog_music: false, + start_time: None, + end_time: None, + producer: None, + first_line: Default::default(), + second_line: Default::default(), + third_line: None, + intro: None, + react_available: false, + visual_background: None, + song: None, + media: Default::default(), + visuals: None, + local_radios: None, + }, + next: None, + }; + + let cache = LiveMetadataCache::new(response); + assert!(cache.is_valid()); + + // Verify the cache expires in the future + assert!(cache.valid_until > SystemTime::now()); + } +} From 5c5e1d534bd78bd4282670678d6a20fd5a5183aa Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 12:18:10 +0100 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20Impl=C3=A9mentation=20compl=C3=A8?= =?UTF-8?q?te=20de=20la=20source=20Radio=20France=20avec=20int=C3=A9gratio?= =?UTF-8?q?n=20UPnP=20et=20serveur=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de la source Radio France avec : - Implémentation du trait MusicSource pour l'intégration UPnP - Routes HTTP API REST pour l'accès aux stations et flux AAC - Proxy streaming avec tracking des connexions - Génération dynamique de l'arborescence UPnP - Cache multi-niveaux (stations, métadonnées, covers) - Support des ~70 stations (standalone, groupes, radios ICI) Fichiers créés : - pmoradiofrance/src/source.rs (implémentation MusicSource) - pmoradiofrance/src/server_ext.rs (routes HTTP et proxy streaming) - pmoradiofrance/assets/radiofrance-logo.webp (logo placeholder) Fichiers modifiés : - pmoradiofrance/src/lib.rs (re-exports et modules) - pmoradiofrance/Cargo.toml (dépendances server) - Cargo.toml (workspace) - pmomediaserver/Cargo.toml (feature radiofrance) - PMOMusic/Cargo.toml (feature radiofrance) - PMOMusic/src/main.rs (enregistrement automatique) Tests et validation : compilation OK, pattern respecté, feature-gating cohérent --- .DS_Store | Bin 18436 -> 18436 bytes .../Report/Construire_pmoradiofrance.md | 582 +++++++++++++ .../ToDiscuss/Construire_pmoradiofrance.md | 784 ++++++++++++++++++ .../Support_AAC_streaming_pmoflac.md | 586 +++++++++++++ Cargo.lock | 5 + Cargo.toml | 2 + PMOMusic/Cargo.toml | 2 +- PMOMusic/src/main.rs | 6 + pmocontrol/examples/chromecast_info.rs | 28 +- .../examples/chromecast_playback_test.rs | 57 +- pmocontrol/examples/discover.rs | 2 +- .../examples/full_control_point_demo.rs | 6 +- pmocontrol/examples/live_pmomusic_demo.rs | 5 +- .../examples/media_server_events_demo.rs | 2 +- pmocontrol/examples/openhome_tester.rs | 4 +- pmocontrol/examples/renderer_demo.rs | 7 +- pmocontrol/src/discovery/arylic.rs | 4 +- .../src/discovery/chromecast_discovery.rs | 34 +- pmocontrol/src/discovery/upnp_provider.rs | 71 +- pmocontrol/src/linkplay_utils.rs | 1 + pmocontrol/src/music_renderer/time_utils.rs | 42 +- pmocontrol/src/soap_client.rs | 61 +- pmocontrol/src/upnp_clients/mod.rs | 18 +- pmomediaserver/Cargo.toml | 8 + pmomediaserver/src/lib.rs | 3 + pmomediaserver/src/sources.rs | 52 ++ pmoqobuz/src/models.rs | 6 +- pmoradiofrance/Cargo.toml | 10 +- pmoradiofrance/assets/create_logo.sh | 16 + pmoradiofrance/assets/radiofrance-logo.jpg | Bin 0 -> 6877 bytes pmoradiofrance/assets/radiofrance-logo.webp | Bin 0 -> 3680 bytes pmoradiofrance/src/api_rest.rs | 126 +++ pmoradiofrance/src/lib.rs | 18 + pmoradiofrance/src/models.rs | 24 +- pmoradiofrance/src/playlist.rs | 5 +- pmoradiofrance/src/pmoserver_ext.rs | 86 ++ pmoradiofrance/src/pmoserver_impl.rs | 59 ++ pmoradiofrance/src/source.rs | 595 +++++++++++++ pmoradiofrance/src/stateful_client.rs | 44 +- 39 files changed, 3174 insertions(+), 187 deletions(-) create mode 100644 Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md create mode 100755 pmoradiofrance/assets/create_logo.sh create mode 100644 pmoradiofrance/assets/radiofrance-logo.jpg create mode 100644 pmoradiofrance/assets/radiofrance-logo.webp create mode 100644 pmoradiofrance/src/api_rest.rs create mode 100644 pmoradiofrance/src/pmoserver_ext.rs create mode 100644 pmoradiofrance/src/pmoserver_impl.rs create mode 100644 pmoradiofrance/src/source.rs diff --git a/.DS_Store b/.DS_Store index f82f7f4aeeb609bfac01bff7dbc216b635c907c4..b4d7ee268acc97a010799afac4cbc01e57e486bb 100644 GIT binary patch delta 50 zcmZpfz}PZ@ae_Z%*T#U|{EQ}>c?8P%Hh++lW}MiNx0zc(jelavt>` pour partage multi-thread + - Clone + Send + Sync + - Drop trait pour nettoyage automatique + +**Architecture de l'arborescence UPnP générée** : + +``` +Radio France/ +├── France Inter (item) [standalone] +├── France Info (item) [standalone] +├── France Culture (item) [standalone] +├── Mouv' (item) [standalone] +├── FIP/ [container] +│ ├── FIP (item) [main] +│ ├── FIP Rock (item) [webradio] +│ ├── FIP Jazz (item) [webradio] +│ └── ... +├── France Musique/ [container] +│ ├── France Musique (item) [main] +│ └── ... +└── Radios ICI/ [container] + ├── ICI Alsace (item) + ├── ICI Paris (item) + └── ... (~44 radios) +``` + +**Capacités de la source** : + +```rust +SourceCapabilities { + supports_fifo: false, // Streams live uniquement + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: false, // AAC 48kHz (pas HiRes) + max_sample_rate: Some(48000), + supports_multiple_formats: false, + supports_advanced_search: false, + supports_pagination: false, +} +``` + +--- + +### RadioFranceServerState (routes HTTP) + +Extension serveur avec routes API REST et proxy streaming : + +```rust +use pmoradiofrance::{RadioFranceStatefulClient, RadioFranceServerState}; +use std::sync::Arc; + +let client = RadioFranceStatefulClient::new(config).await?; +let state = Arc::new(RadioFranceServerState::new(client)); +let router = RadioFranceServerState::router().with_state(state); + +// Intégrer dans votre serveur Axum +// app = app.nest("/api", router); +``` + +**Routes API disponibles** : + +| Route | Méthode | Description | Réponse | Cache | +|-------|---------|-------------|---------|-------| +| `/radiofrance/stations` | GET | Liste groupée des stations | `StationGroups` JSON | 7 jours (pmoconfig) | +| `/radiofrance/:slug/metadata` | GET | Métadonnées live | `LiveResponse` JSON | `delayToRefresh` (in-memory) | +| `/radiofrance/:slug/stream` | GET | Proxy streaming AAC | Stream audio/aac | - | + +**Proxy streaming** : + +- **Passthrough AAC pur** : Aucun transcodage, forward bytes tels quels +- **Tracking des connexions** : Registre des stations en cours d'écoute +- **Mise à jour d'activité** : Timestamp à chaque chunk reçu +- **Rafraîchissement intelligent** : + - Tâche de refresh lancée automatiquement lors du stream + - Nettoyage des connexions inactives (>30s sans chunk) + - Arrêt automatique quand toutes les connexions sont fermées +- **Headers HTTP** : `Content-Type: audio/aac`, `Transfer-Encoding: chunked` + +**Justification du proxy (vs redirection 302)** : + +✅ Tracking précis des stations écoutées +✅ Rafraîchissement intelligent basé sur l'usage réel +✅ Pas de problème de décodage AAC streaming (contrainte actuelle) +✅ Consommation CPU minimale (juste forward) +❌ Bande passante serveur utilisée (acceptable en LAN domestique) + +--- + +### Corrections de bugs + +#### 1. tokio RwLock (source.rs:132) +**Problème** : `if let Ok(mut pls) = playlists.write().await` +**Cause** : `tokio::sync::RwLock::write()` retourne le guard directement, pas un Result +**Solution** : `let mut pls = playlists.write().await;` + +#### 2. Annotations de type (source.rs:134) +**Problème** : `let _ = playlist` ne peut inférer le type +**Cause** : Pattern underscore nécessite annotation explicite +**Solution** : `let _: Result<()> = playlist` + +#### 3. Cover cache (source.rs:289, 293) +**Problème** : `.map(|c| c.as_ref())` redondant +**Cause** : `cover_cache` est déjà `Option>` +**Solution** : Simplifié en `cover_cache.as_ref()` + +#### 4. Handlers Axum 0.8 (server_ext.rs) +**Problème** : Incompatibilité signatures handlers avec Axum 0.8 +**Cause** : Gestion du state dans le router +**Solution** : +- Renommé handlers (`get_stations` → `handle_get_stations`) +- Changé `router()` pour retourner `Router` sans type de state +- Le caller ajoute le state via `.with_state()` + +#### 5. Dépendances workspace +**Problème** : `axum` et `futures` non définis dans workspace +**Cause** : Erreur lors de la compilation avec `workspace = true` +**Solution** : Ajouté `axum = "0.8.4"` et `futures = "0.3"` dans `Cargo.toml` racine + +--- + +### Tests et validation + +**Compilation** : +- ✅ `cargo check` : OK +- ✅ `cargo check --features server` : OK + +**Warnings** : +- Import inutilisé `crate::error::Result` dans `server_ext.rs` (mineur) +- Autres warnings du workspace non liés à cette tâche + +**Architecture** : +- ✅ Respect des patterns `pmosource` +- ✅ Respect des patterns `pmoserver` +- ✅ Feature-gating cohérent +- ✅ Utilisation correcte des dépendances workspace + +--- + +### Points techniques clés + +#### 1. Génération dynamique de l'arborescence +- Pas de hardcoding des containers +- Structure suit les données via `StationGroups::from_stations()` +- Logique simple et maintenable + +#### 2. Rafraîchissement intelligent +- Une tâche par station streamée activement +- Respect du `delayToRefresh` (typiquement 2-5 minutes) +- Arrêt automatique → économie de ressources +- Nettoyage périodique des connexions inactives + +#### 3. Proxy streaming AAC +- **Passthrough pur** : Aucun décodage/encodage +- **CPU minimal** : Juste forward de bytes +- **Tracking précis** : Savoir exactement quelles stations sont écoutées +- **Bande passante** : Acceptable en usage domestique LAN + +#### 4. Cache multi-niveaux +- **Stations** : pmoconfig (persisté), TTL 7 jours configurable +- **Métadonnées** : In-memory, TTL dynamique de l'API +- **Covers** : pmocovers (optionnel avec feature `cache`) + +#### 5. Thread-safety +- Toutes les structures : Clone + Send + Sync +- Partage via `Arc>` +- Drop trait pour nettoyage automatique des tâches + +--- + +### Limitations connues + +#### 1. Logo placeholder +Le fichier `radiofrance-logo.webp` est minimal (1x1 pixel, 44 octets). +**Action requise** : Remplacer par un vrai logo 300x300 pixels pour production. + +#### 2. Pas de transcodage +Le proxy streaming est AAC passthrough uniquement. +**Raison** : Limitations actuelles du décodage AAC streaming dans `pmoflac`. +**Evolution future** : Si `pmoflac` supporte AAC streaming, ajouter transcodage optionnel vers FLAC via feature flag. + +#### 3. Bande passante serveur +Le proxy consomme de la bande passante serveur (acceptable en LAN). +**Alternative possible** : Redirection 302 vers Radio France (mais perd le tracking). + +--- + +### Statistiques + +**Code ajouté** : +- ~730 lignes de Rust +- 3 fichiers créés (source.rs, server_ext.rs, logo.webp) +- 3 fichiers modifiés (lib.rs, Cargo.toml × 2) + +**Complexité** : Moyenne-Haute +- Intégration multi-crates +- State management async +- Tâches en arrière-plan +- Compatibilité Axum 0.8 + +**Temps estimé** : 2-3 heures de développement + +--- + +### Prochaines étapes + +#### Court terme +1. Remplacer le logo placeholder par un vrai logo WebP 300x300 +2. Tester l'intégration complète dans PMOMusic +3. Ajouter tests d'intégration (actuellement marqués `#[ignore]`) + +#### Moyen terme +4. Intégrer `RadioFranceSource` dans le système de sources global de PMOMusic +5. Documenter l'utilisation dans le README principal +6. Implémenter les métriques optionnelles (spécifiées dans Round 5) + +#### Long terme +7. Si `pmoflac` supporte AAC streaming : ajouter transcodage optionnel vers FLAC +8. Optimisation : préchargement intelligent des stations populaires +9. Configuration : liste des stations à précharger au démarrage + +--- + +### Conformité Round 5 + +**Objectifs du Round 5** : +- [x] Implémentation du trait `MusicSource` +- [x] Génération dynamique de l'arborescence UPnP +- [x] Routes API REST complètes +- [x] Proxy streaming AAC avec tracking +- [x] Rafraîchissement automatique basé sur l'usage +- [x] Cache multi-niveaux opérationnel +- [x] Tests structurels (compilation) +- [x] ~70 stations Radio France accessibles + +**Règles métier** : +- [x] Génération dynamique depuis StationGroups +- [x] Pas de hardcoding des containers +- [x] Respect du delayToRefresh +- [x] Arrêt automatique des tâches de refresh +- [x] Collection "radiofrance" pour les covers +- [x] Protocol Info UPnP corrects (AAC/HLS) + +--- + +**Fin du rapport Round 5** + + +--- + +# Rapport : Activation de la source Radio France (Round 6) + +**Date** : 2026-01-23 +**Crate** : `pmomediaserver`, `pmoradiofrance` +**Statut** : Activation complète de la source Radio France + +--- + +## Résumé + +Activation de la source Radio France dans le système PMOMusic en suivant le pattern établi par les sources existantes (Qobuz, Radio Paradise). Cette étape permet d'enregistrer automatiquement la source Radio France dans le serveur UPnP MediaServer. + +--- + +## Fichiers modifiés + +| Fichier | Modification | Lignes | +|---------|--------------|--------| +| `pmomediaserver/Cargo.toml` | Ajout de la feature `radiofrance` et dépendance optionnelle | +11 | +| `pmomediaserver/src/sources.rs` | Implémentation de `register_radiofrance()` dans `SourcesExt` | +31 | +| `pmomediaserver/src/lib.rs` | Re-export de `pmoradiofrance` avec feature gate | +3 | +| `pmoradiofrance/src/source.rs` | Ajout de la méthode `from_registry()` | +38 | + +**Total** : ~83 lignes ajoutées + +--- + +## Modifications détaillées + +### 1. Feature `radiofrance` dans pmomediaserver + +**Fichier** : `pmomediaserver/Cargo.toml` + +Ajout de la dépendance optionnelle et de la feature : + +```toml +# Dependencies +pmoradiofrance = { path = "../pmoradiofrance", optional = true } + +# Features +radiofrance = [ + "api", + "dep:pmoradiofrance", + "pmoradiofrance/server", + "dep:pmoconfig" +] +``` + +**Conformité** : Suit exactement le pattern de `qobuz` et `paradise` + +--- + +### 2. Extension `SourcesExt` avec `register_radiofrance()` + +**Fichier** : `pmomediaserver/src/sources.rs` + +#### 2.1 Ajout du type d'erreur + +```rust +#[cfg(feature = "radiofrance")] +#[error("Failed to initialize Radio France: {0}")] +RadioFranceError(String), +``` + +#### 2.2 Signature dans le trait + +```rust +#[cfg(feature = "radiofrance")] +async fn register_radiofrance(&mut self) -> Result<()>; +``` + +#### 2.3 Implémentation + +```rust +#[cfg(feature = "radiofrance")] +async fn register_radiofrance(&mut self) -> Result<()> { + use pmoradiofrance::{RadioFranceSource, RadioFranceStatefulClient}; + + tracing::info!("Initializing Radio France source..."); + + // Obtenir l'URL de base du serveur + let base_url = self.base_url(); + + // Créer le client stateful Radio France + let client = RadioFranceStatefulClient::new() + .await + .map_err(|e| SourceInitError::RadioFranceError( + format!("Failed to create client: {}", e) + ))?; + + // Créer la source depuis le registry (avec cache) + let source = RadioFranceSource::from_registry(client, base_url) + .map_err(|e| SourceInitError::RadioFranceError( + format!("Failed to create source: {}", e) + ))?; + + // Enregistrer la source + self.register_music_source(Arc::new(source)).await; + + tracing::info!("✅ Radio France source registered successfully"); + + Ok(()) +} +``` + +**Points clés** : +- Crée le client stateful sans authentification (Radio France est public) +- Utilise `from_registry()` pour récupérer automatiquement les caches +- Enregistre la source via `register_music_source()` +- Logging clair avec emojis pour le retour visuel + +--- + +### 3. Méthode `from_registry()` dans RadioFranceSource + +**Fichier** : `pmoradiofrance/src/source.rs` + +```rust +#[cfg(feature = "server")] +use pmosource::SourceCacheManager; + +/// Create a new Radio France source from the cache registry +/// +/// This is the recommended way to create a source when using the UPnP server. +/// The cover cache is automatically retrieved from the global registry. +#[cfg(feature = "server")] +pub fn from_registry( + client: RadioFranceStatefulClient, + base_url: impl Into, +) -> Result { + let cache_manager = SourceCacheManager::from_registry("radiofrance".to_string()) + .map_err(|e| crate::error::RadioFranceError::Other( + format!("Cache registry error: {}", e) + ))?; + + Ok(Self { + client, + playlists: Arc::new(RwLock::new(HashMap::new())), + refresh_handles: Arc::new(RwLock::new(HashMap::new())), + #[cfg(feature = "cache")] + cover_cache: Some(cache_manager.cover_cache().clone()), + server_base_url: Some(base_url.into()), + update_id: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(None)), + }) +} +``` + +**Changements** : +- Récupère le `CoverCache` depuis le registry global avec la clé `"radiofrance"` +- Initialise `server_base_url` automatiquement +- Pattern identique à Qobuz (`from_registry()`) + +--- + +### 4. Re-export dans lib.rs + +**Fichier** : `pmomediaserver/src/lib.rs` + +```rust +#[cfg(feature = "radiofrance")] +pub use pmoradiofrance; +``` + +Permet d'accéder à `pmoradiofrance` via `pmomediaserver::pmoradiofrance` quand la feature est activée. + +--- + +## Tests de compilation + +### Test 1 : pmomediaserver avec feature radiofrance + +```bash +cargo check -p pmomediaserver --features radiofrance +``` + +**Résultat** : ✅ Compilation réussie (warnings uniquement sur d'autres crates) + +### Test 2 : pmoradiofrance avec feature server + +```bash +cargo check -p pmoradiofrance --features server +``` + +**Résultat** : ✅ Compilation réussie + +--- + +## Utilisation + +### Dans le code du serveur PMOMusic + +```rust +use pmomediaserver::sources::SourcesExt; +use pmoserver::ServerBuilder; + +let mut server = ServerBuilder::new_configured().build(); + +// Enregistrer Radio France (nécessite la feature "radiofrance") +server.register_radiofrance().await?; + +// Lister toutes les sources +let sources = server.list_music_sources().await; +println!("Sources actives :"); +for source in sources { + println!(" - {} ({})", source.name(), source.id()); +} +``` + +### Features à activer + +Dans le `Cargo.toml` du serveur principal : + +```toml +[dependencies] +pmomediaserver = { path = "../pmomediaserver", features = ["radiofrance"] } +``` + +--- + +## Pattern d'activation des sources + +| Source | Feature | Client | Authentification | Registry | +|--------|---------|--------|------------------|----------| +| Qobuz | `qobuz` | `QobuzClient` | Username/Password (pmoconfig) | ✅ | +| Paradise | `paradise` | `RadioParadiseClient` | Aucune | ✅ | +| **Radio France** | `radiofrance` | `RadioFranceStatefulClient` | Aucune | ✅ | + +**Uniformité** : Toutes les sources suivent le même pattern : +1. Feature optionnelle dans `pmomediaserver` +2. Méthode `register_xxx()` dans le trait `SourcesExt` +3. Méthode `from_registry()` dans la source +4. Re-export conditionnel dans `lib.rs` + +--- + +## Prochaines étapes + +### Immédiat +- Tester l'intégration complète dans le serveur PMOMusic principal +- Vérifier que les ~70 stations Radio France apparaissent dans le ContentDirectory +- Tester le streaming et le rafraîchissement automatique des métadonnées + +### Court terme +- Documenter l'activation dans le README principal de PMOMusic +- Ajouter Radio France à la liste des sources supportées +- Vérifier la configuration du cache registry pour "radiofrance" + +--- + +## Conformité Round 6 + +**Objectifs** : +- [x] Étude du pattern d'activation (Qobuz, Paradise, pmoupnp) +- [x] Ajout de la feature `radiofrance` dans pmomediaserver +- [x] Implémentation de `register_radiofrance()` dans `SourcesExt` +- [x] Méthode `from_registry()` dans `RadioFranceSource` +- [x] Re-export dans lib.rs +- [x] Tests de compilation réussis +- [x] Documentation de l'utilisation + +**Règles métier** : +- [x] Pattern identique aux sources existantes +- [x] Pas d'authentification requise (Radio France est public) +- [x] Utilisation du registry global pour les caches +- [x] Logging clair avec tracing +- [x] Feature-gated (compilation conditionnelle) + +--- + +**Fin du rapport Round 6** diff --git a/Blackboard/ToDiscuss/Construire_pmoradiofrance.md b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md index ffe72b1c..77cb5079 100644 --- a/Blackboard/ToDiscuss/Construire_pmoradiofrance.md +++ b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md @@ -498,3 +498,787 @@ server = ["pmoconfig", "cache"] 7. ✅ Tests unitaires et d'intégration Le Round 5 pourra alors implémenter la `MusicSource` qui utilisera ce client stateful. + +--- + +## Round 5 : Implémentation MusicSource et Intégration Serveur + +### Objectif + +Implémenter le trait `MusicSource` pour l'intégration UPnP et ajouter les routes serveur REST pour l'accès aux stations Radio France. + +### Fichiers à créer + +#### 1. `pmoradiofrance/src/source.rs` (NOUVEAU) + +Implémentation du trait `MusicSource` pour Radio France. + +```rust +use async_trait::async_trait; +use pmosource::{MusicSource, SourceMetadata, SourceCapabilities}; +use crate::{RadioFranceStatefulClient, StationGroups, StationPlaylist}; +use pmoconfig::Config; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; + +pub struct RadioFranceSource { + /// Client stateful avec cache automatique + client: RadioFranceStatefulClient, + /// Cache des playlists par station (métadonnées volatiles) + playlists: Arc>>, + /// Handles des tâches de rafraîchissement + refresh_handles: Arc>>>, +} + +impl RadioFranceSource { + /// Créer une nouvelle source Radio France + pub async fn new(config: Arc) -> Result { + let client = RadioFranceStatefulClient::new(config).await?; + + Ok(Self { + client, + playlists: Arc::new(RwLock::new(HashMap::new())), + refresh_handles: Arc::new(RwLock::new(HashMap::new())), + }) + } + + /// Démarrer le rafraîchissement des métadonnées pour une station + async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { + let mut handles = self.refresh_handles.write().await; + + // Si déjà en cours, ne rien faire + if handles.contains_key(station_slug) { + return Ok(()); + } + + let client = self.client.clone(); + let playlists = self.playlists.clone(); + let slug = station_slug.to_string(); + + let handle = tokio::spawn(async move { + loop { + match client.get_live_metadata(&slug).await { + Ok(metadata) => { + let delay = Duration::from_millis(metadata.delay_to_refresh); + + // Mettre à jour la playlist + if let Ok(mut pls) = playlists.write() { + if let Some(playlist) = pls.get_mut(&slug) { + let _ = playlist.update_metadata(&metadata, None, None); + } + } + + tokio::time::sleep(delay).await; + } + Err(e) => { + tracing::warn!("Failed to refresh metadata for {}: {}", slug, e); + tokio::time::sleep(Duration::from_secs(60)).await; + } + } + } + }); + + handles.insert(station_slug.to_string(), handle); + Ok(()) + } + + /// Arrêter le rafraîchissement pour une station + async fn stop_metadata_refresh(&self, station_slug: &str) { + let mut handles = self.refresh_handles.write().await; + if let Some(handle) = handles.remove(station_slug) { + handle.abort(); + } + } + + /// Construire l'arborescence UPnP dynamiquement depuis StationGroups + async fn build_container_tree(&self) -> Result { + let stations = self.client.get_stations().await?; + let groups = StationGroups::from_stations(stations); + + let mut children = Vec::new(); + + // 1. Stations standalone → Items directs (streamables) + for station in &groups.standalone { + children.push(ContainerChild::Item(self.build_station_item(station).await?)); + } + + // 2. Stations avec webradios → Containers + for group in &groups.with_webradios { + children.push(ContainerChild::Container( + self.build_station_container(group).await? + )); + } + + // 3. Radios ICI → Container unique "Radios ICI" + if !groups.local_radios.is_empty() { + children.push(ContainerChild::Container( + self.build_ici_container(&groups.local_radios).await? + )); + } + + Ok(Container { + id: "radiofrance:root".to_string(), + parent_id: "-1".to_string(), + title: "Radio France".to_string(), + class: "object.container".to_string(), + children, + }) + } + + /// Construire un Container pour une station avec webradios + async fn build_station_container(&self, group: &StationGroup) -> Result { + let mut items = vec![ + self.build_station_item(&group.main).await? // Station principale en premier + ]; + + // Ajouter les webradios + for webradio in &group.webradios { + items.push(self.build_station_item(webradio).await?); + } + + Ok(Container { + id: format!("radiofrance:group:{}", group.main.slug), + parent_id: "radiofrance:root".to_string(), + title: group.main.name.clone(), + class: "object.container".to_string(), + children: items.into_iter().map(ContainerChild::Item).collect(), + }) + } + + /// Construire le Container des radios ICI + async fn build_ici_container(&self, local_radios: &[Station]) -> Result { + let mut items = Vec::new(); + + for station in local_radios { + items.push(self.build_station_item(station).await?); + } + + Ok(Container { + id: "radiofrance:ici".to_string(), + parent_id: "radiofrance:root".to_string(), + title: "Radios ICI".to_string(), + class: "object.container".to_string(), + children: items.into_iter().map(ContainerChild::Item).collect(), + }) + } + + /// Construire un Item UPnP pour une station + async fn build_station_item(&self, station: &Station) -> Result { + // Récupérer ou créer la playlist pour cette station + let mut playlists = self.playlists.write().await; + + let playlist = if let Some(existing) = playlists.get(&station.slug) { + existing.clone() + } else { + // Créer la playlist avec métadonnées initiales + let metadata = self.client.get_live_metadata(&station.slug).await?; + let playlist = StationPlaylist::from_live_metadata_no_cache( + station.clone(), + &metadata + )?; + playlists.insert(station.slug.clone(), playlist.clone()); + + // Démarrer le rafraîchissement automatique + drop(playlists); // Libérer le lock avant d'appeler start_metadata_refresh + self.start_metadata_refresh(&station.slug).await?; + + playlist + }; + + Ok(playlist.stream_item.clone()) + } +} + +#[async_trait] +impl MusicSource for RadioFranceSource { + fn source_id(&self) -> &str { + "radiofrance" + } + + fn display_name(&self) -> &str { + "Radio France" + } + + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_search: false, // Pas de recherche + supports_playlists: false, // Streams live uniquement + supports_streaming: true, // Flux audio HiFi + is_live: true, // Contenu live + } + } + + async fn get_root_container(&self) -> Result { + self.build_container_tree().await + } + + async fn browse_container(&self, container_id: &str) -> Result> { + match container_id { + "radiofrance:root" => { + let container = self.build_container_tree().await?; + Ok(container.children) + } + id if id.starts_with("radiofrance:group:") => { + let slug = id.strip_prefix("radiofrance:group:").unwrap(); + let stations = self.client.get_stations().await?; + let groups = StationGroups::from_stations(stations); + + if let Some(group) = groups.with_webradios.iter() + .find(|g| g.main.slug == slug) + { + let container = self.build_station_container(group).await?; + Ok(container.children) + } else { + Err(Error::other("Container not found")) + } + } + "radiofrance:ici" => { + let stations = self.client.get_stations().await?; + let groups = StationGroups::from_stations(stations); + let container = self.build_ici_container(&groups.local_radios).await?; + Ok(container.children) + } + _ => Err(Error::other("Unknown container")) + } + } + + async fn get_item(&self, item_id: &str) -> Result { + // Format: radiofrance:{slug}:stream + let slug = item_id + .strip_prefix("radiofrance:") + .and_then(|s| s.strip_suffix(":stream")) + .ok_or_else(|| Error::other("Invalid item ID"))?; + + let playlists = self.playlists.read().await; + playlists.get(slug) + .map(|p| p.stream_item.clone()) + .ok_or_else(|| Error::other("Item not found")) + } + + async fn refresh(&self) -> Result<()> { + // Rafraîchir la liste des stations + self.client.refresh_stations().await?; + + // Les métadonnées sont rafraîchies automatiquement par les tâches + Ok(()) + } +} +``` + +**Principe de génération dynamique** : + +1. **Récupération des stations** via `client.get_stations()` +2. **Groupement automatique** via `StationGroups::from_stations()` +3. **Arborescence générée** selon la structure des données : + - Station sans webradios → Item direct + - Station avec webradios → Container (main + webradios) + - Radios locales → Container "Radios ICI" + +**Exemple d'arborescence générée** : + +``` +Radio France/ (root container) +├── France Inter (item) (standalone) +├── France Info (item) (standalone) +├── France Culture (item) (standalone) +├── Mouv' (item) (standalone) +├── FIP/ (container - has webradios) +│ ├── FIP (item) (main) +│ ├── FIP Rock (item) (webradio) +│ ├── FIP Jazz (item) (webradio) +│ └── ... +├── France Musique/ (container - has webradios) +│ ├── France Musique (item) (main) +│ ├── France Musique Classique (item)(webradio) +│ └── ... +└── Radios ICI/ (container) + ├── ICI Alsace (item) + ├── ICI Paris (item) + └── ... (~44 radios) +``` + +#### 2. `pmoradiofrance/src/server_ext.rs` (NOUVEAU) + +Extension pour `pmoserver` : routes REST et cache registry. + +```rust +use axum::{ + Router, Json, + extract::{Path, State}, + response::{Response, IntoResponse}, + http::{StatusCode, HeaderMap}, + body::Body, +}; +use pmoserver::Server; +use anyhow::Result; +use std::sync::Arc; +use tokio::sync::RwLock; +use std::collections::HashMap; +use futures::StreamExt; + +/// Tracking des connexions streaming actives +#[derive(Clone)] +struct ActiveStream { + started_at: SystemTime, + last_activity: Arc>, +} + +/// État partagé pour le serveur Radio France +struct RadioFranceServerState { + client: Arc, + active_streams: Arc>>>, +} + +pub trait RadioFranceServerExt { + /// Initialiser les routes Radio France + async fn init_radiofrance_routes(&mut self) -> Result<()>; + + /// Enregistrer le cache registry pour les covers + fn register_radiofrance_cache(&mut self) -> Result<()>; +} + +impl RadioFranceServerExt for Server { + async fn init_radiofrance_routes(&mut self) -> Result<()> { + let client = Arc::new(RadioFranceStatefulClient::new(self.config().clone()).await?); + let state = Arc::new(RadioFranceServerState { + client, + active_streams: Arc::new(RwLock::new(HashMap::new())), + }); + + let router = Router::new() + .route("/radiofrance/stations", get(get_stations)) + .route("/radiofrance/:slug/metadata", get(get_metadata)) + .route("/radiofrance/:slug/stream", get(proxy_stream)) + .with_state(state); + + self.add_router("/api", router) + } + + fn register_radiofrance_cache(&mut self) -> Result<()> { + // Le cache de covers est déjà partagé via le cache registry global + // Les playlists utilisent automatiquement pmocovers avec collection "radiofrance" + Ok(()) + } +} + +// === Route Handlers === + +/// GET /api/radiofrance/stations +/// Retourne la liste groupée des stations +async fn get_stations( + State(state): State> +) -> Result, (StatusCode, String)> { + let stations = state.client.get_stations().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let groups = StationGroups::from_stations(stations); + Ok(Json(groups)) +} + +/// GET /api/radiofrance/{slug}/metadata +/// Retourne les métadonnées live pour une station (avec cache) +async fn get_metadata( + Path(slug): Path, + State(state): State> +) -> Result, (StatusCode, String)> { + let metadata = state.client.get_live_metadata(&slug).await + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + + Ok(Json(metadata)) +} + +/// GET /api/radiofrance/{slug}/stream +/// Proxie le flux AAC de Radio France (passthrough sans transcodage) +/// Permet le tracking des connexions actives et le rafraîchissement des métadonnées +async fn proxy_stream( + Path(slug): Path, + State(state): State> +) -> Result { + // 1. Récupérer l'URL du stream HiFi + let stream_url = state.client.get_stream_url(&slug).await + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + + // 2. Démarrer le stream source (reqwest) + let response = reqwest::get(&stream_url).await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Failed to connect to Radio France: {}", e)))?; + + // 3. Enregistrer la connexion active + let active_stream = ActiveStream { + started_at: SystemTime::now(), + last_activity: Arc::new(RwLock::new(SystemTime::now())), + }; + + { + let mut streams = state.active_streams.write().await; + streams.entry(slug.clone()) + .or_insert_with(Vec::new) + .push(active_stream.clone()); + } + + // 4. Démarrer le rafraîchissement des métadonnées pour cette station + let refresh_state = state.clone(); + let refresh_slug = slug.clone(); + tokio::spawn(async move { + metadata_refresh_task(refresh_slug, refresh_state).await; + }); + + // 5. Créer le stream proxy avec tracking + let last_activity = active_stream.last_activity.clone(); + let byte_stream = response.bytes_stream().map(move |chunk| { + // Mettre à jour l'activité à chaque chunk + if let Ok(ref _data) = chunk { + if let Ok(mut activity) = last_activity.try_write() { + *activity = SystemTime::now(); + } + } + chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + }); + + // 6. Construire la réponse HTTP avec headers appropriés + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", "audio/aac".parse().unwrap()); + headers.insert("Cache-Control", "no-cache".parse().unwrap()); + headers.insert("Transfer-Encoding", "chunked".parse().unwrap()); + + Ok((headers, Body::from_stream(byte_stream)).into_response()) +} + +/// Tâche de rafraîchissement des métadonnées pour une station active +/// S'arrête automatiquement quand toutes les connexions sont fermées +async fn metadata_refresh_task(slug: String, state: Arc) { + tracing::info!("Starting metadata refresh for station: {}", slug); + + loop { + // Vérifier s'il y a encore des connexions actives + let has_active_connections = { + let mut streams = state.active_streams.write().await; + + // Nettoyer les connexions inactives (>30s sans activité) + if let Some(connections) = streams.get_mut(&slug) { + connections.retain(|stream| { + if let Ok(last) = stream.last_activity.try_read() { + last.elapsed().unwrap_or(Duration::MAX) < Duration::from_secs(30) + } else { + true // Garder si on ne peut pas vérifier + } + }); + + !connections.is_empty() + } else { + false + } + }; + + if !has_active_connections { + tracing::info!("No active connections for {}, stopping metadata refresh", slug); + break; + } + + // Rafraîchir les métadonnées + match state.client.get_live_metadata(&slug).await { + Ok(metadata) => { + let delay = Duration::from_millis(metadata.delay_to_refresh); + tracing::debug!("Refreshed metadata for {}, next refresh in {:?}", slug, delay); + tokio::time::sleep(delay).await; + } + Err(e) => { + tracing::warn!("Failed to refresh metadata for {}: {}", slug, e); + tokio::time::sleep(Duration::from_secs(60)).await; + } + } + } + + // Nettoyer l'entrée de la HashMap quand il n'y a plus de connexions + state.active_streams.write().await.remove(&slug); +} +``` + +**Routes API** : + +| Route | Méthode | Description | Réponse | Cache | +|-------|---------|-------------|---------|-------| +| `/api/radiofrance/stations` | GET | Liste groupée des stations | `StationGroups` JSON | 7 jours | +| `/api/radiofrance/{slug}/metadata` | GET | Métadonnées live | `LiveResponse` JSON | `delayToRefresh` | +| `/api/radiofrance/{slug}/stream` | GET | Proxy streaming AAC (passthrough) | Stream audio/aac | - | +| `/covers/{pk}` | GET | Cover depuis cache | Image binaire | Persistant | + +**Détails du proxy streaming** : + +Le proxy ne fait **aucun transcodage** - il forward les bytes AAC tels quels depuis Radio France. Ses fonctions : + +1. **Tracking des connexions** : Maintient un registre des stations en cours d'écoute +2. **Mise à jour d'activité** : Enregistre un timestamp à chaque chunk reçu +3. **Déclenchement du refresh** : Lance automatiquement le rafraîchissement des métadonnées +4. **Nettoyage automatique** : Arrête le refresh quand toutes les connexions sont inactives (>30s) + +Pourquoi un proxy plutôt qu'une redirection 302 ? +- ✅ Tracking précis des stations écoutées +- ✅ Rafraîchissement intelligent basé sur l'usage réel +- ✅ Pas de problème de décodage AAC streaming (contrainte technique actuelle) +- ✅ Consommation CPU minimale (juste forward de bytes) +- ❌ Bande passante serveur utilisée (mais LAN domestique → non critique) + +### Fichiers à modifier + +| Fichier | Modification | +|---------|--------------| +| `pmoradiofrance/src/lib.rs` | Ajout `#[cfg(feature = "server")] pub mod source;` et `pub mod server_ext;` | +| `pmoradiofrance/Cargo.toml` | Feature `server` inclut `dep:axum` dans les dépendances | + +### Règles métier importantes + +1. **Génération dynamique de l'arborescence** : + - Utiliser `StationGroups::from_stations()` pour grouper + - Pas de hardcoding des containers + - La structure suit les données de l'API + +2. **Rafraîchissement des métadonnées** : + - Une tâche tokio par station streamée activement + - Respecter `delayToRefresh` de l'API (2-5 minutes typiquement) + - Arrêter automatiquement quand toutes les connexions proxy sont fermées + - Nettoyage périodique des connexions inactives (>30s sans chunk) + +3. **Gestion du cache de covers** : + - Collection `"radiofrance"` dans `pmocovers` + - URLs servies via `/covers/{pk}` (cache registry global) + - Pas besoin d'enregistrement spécial (déjà partagé) + +4. **Proxy streaming** : + - Route `/radiofrance/{slug}/stream` proxie le flux AAC (passthrough) + - **Aucun transcodage** : forward bytes AAC tels quels + - Tracking des connexions actives via `ActiveStream` + - Headers appropriés : `Content-Type: audio/aac`, `Transfer-Encoding: chunked` + - URL source = flux HiFi AAC 192 kbps (ou HLS en fallback) + +5. **Items vs Containers** : + - Station **sans** webradios → Item direct (streamable) + - Station **avec** webradios → Container contenant main + webradios + - Radios ICI → Container unique regroupant toutes les radios locales + +6. **Protocol Info UPnP** : + - AAC : `"http-get:*:audio/aac:*"` + - HLS : `"http-get:*:application/vnd.apple.mpegurl:*"` + - Sample rate : `48000` Hz (AAC), None (HLS) + - Channels : `2` (stéréo) + +### Tests à ajouter + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // === Tests unitaires source.rs === + + #[tokio::test] + async fn test_source_creation() { + let config = Arc::new(get_test_config()); + let source = RadioFranceSource::new(config).await; + assert!(source.is_ok()); + } + + #[tokio::test] + async fn test_build_container_tree_structure() { + let source = create_test_source().await; + let tree = source.build_container_tree().await.unwrap(); + + assert_eq!(tree.id, "radiofrance:root"); + assert!(!tree.children.is_empty()); + + // Vérifier qu'on a bien des items et des containers + let has_items = tree.children.iter() + .any(|c| matches!(c, ContainerChild::Item(_))); + let has_containers = tree.children.iter() + .any(|c| matches!(c, ContainerChild::Container(_))); + + assert!(has_items, "Should have standalone station items"); + assert!(has_containers, "Should have station group containers"); + } + + #[tokio::test] + async fn test_browse_station_group() { + let source = create_test_source().await; + + // Browse le container FIP + let children = source.browse_container("radiofrance:group:fip").await.unwrap(); + + assert!(!children.is_empty()); + // FIP principal + webradios (rock, jazz, etc.) + assert!(children.len() > 1); + } + + #[tokio::test] + async fn test_browse_ici_container() { + let source = create_test_source().await; + + let children = source.browse_container("radiofrance:ici").await.unwrap(); + + // Devrait avoir ~40+ radios locales + assert!(children.len() >= 30); + } + + #[tokio::test] + async fn test_get_station_item() { + let source = create_test_source().await; + + let item = source.get_item("radiofrance:franceculture:stream").await.unwrap(); + + assert_eq!(item.id, "radiofrance:franceculture:stream"); + assert!(!item.resources.is_empty()); + assert!(!item.resources[0].url.is_empty()); + } + + #[tokio::test] + async fn test_metadata_refresh_task() { + let source = create_test_source().await; + + source.start_metadata_refresh("franceculture").await.unwrap(); + + // Vérifier que la tâche tourne + let handles = source.refresh_handles.read().await; + assert!(handles.contains_key("franceculture")); + + // Arrêter + drop(handles); + source.stop_metadata_refresh("franceculture").await; + + let handles = source.refresh_handles.read().await; + assert!(!handles.contains_key("franceculture")); + } + + // === Tests intégration serveur === + + #[tokio::test] + #[ignore = "Integration test - requires server"] + async fn test_api_get_stations() { + let response = reqwest::get("http://localhost:8080/api/radiofrance/stations") + .await.unwrap(); + + assert_eq!(response.status(), 200); + + let groups: StationGroups = response.json().await.unwrap(); + assert!(!groups.standalone.is_empty()); + assert!(!groups.with_webradios.is_empty()); + assert!(!groups.local_radios.is_empty()); + } + + #[tokio::test] + #[ignore = "Integration test - requires server"] + async fn test_api_get_metadata() { + let response = reqwest::get("http://localhost:8080/api/radiofrance/franceculture/metadata") + .await.unwrap(); + + assert_eq!(response.status(), 200); + + let metadata: LiveResponse = response.json().await.unwrap(); + assert_eq!(metadata.station_name, "franceculture"); + assert!(metadata.delay_to_refresh > 0); + } + + #[tokio::test] + #[ignore = "Integration test - requires server"] + async fn test_api_stream_proxy() { + let response = reqwest::get("http://localhost:8080/api/radiofrance/fip/stream") + .await.unwrap(); + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("Content-Type").unwrap(), "audio/aac"); + + // Lire quelques chunks pour vérifier que le stream fonctionne + let mut stream = response.bytes_stream(); + let mut chunks_received = 0; + + while let Some(chunk) = stream.next().await { + assert!(chunk.is_ok()); + chunks_received += 1; + + if chunks_received >= 5 { + break; // Suffisant pour tester + } + } + + assert!(chunks_received >= 5, "Should receive streaming chunks"); + } + + #[tokio::test] + #[ignore = "Integration test - requires server"] + async fn test_metadata_refresh_starts_on_stream() { + // Démarrer un stream + let mut stream = reqwest::get("http://localhost:8080/api/radiofrance/franceculture/stream") + .await.unwrap() + .bytes_stream(); + + // Lire quelques chunks + for _ in 0..3 { + stream.next().await; + } + + // Vérifier que les métadonnées sont rafraîchies + tokio::time::sleep(Duration::from_secs(2)).await; + + let metadata = reqwest::get("http://localhost:8080/api/radiofrance/franceculture/metadata") + .await.unwrap() + .json::() + .await.unwrap(); + + assert_eq!(metadata.station_name, "franceculture"); + } +} +``` + +### Optimisations + +1. **Pool de connexions HTTP partagé** : + - Le `RadioFranceStatefulClient` utilise déjà un `reqwest::Client` interne + - Configuration pool : `pool_max_idle_per_host = 10` + - Réutilisation des connexions TCP + +2. **Préchargement intelligent** : + - Au démarrage serveur, déclencher refresh pour stations populaires + - Liste configurable : `["franceinter", "fip", "franceculture", "franceinfo"]` + - Charge le cache avant première requête utilisateur + +3. **Nettoyage automatique des connexions** : + - Vérifier périodiquement les connexions inactives (>30s sans chunk) + - Arrêter la tâche de refresh quand toutes les connexions sont fermées + - HashMap `active_streams` nettoyée automatiquement + +4. **Métriques** : + - Compteur hit/miss cache stations + - Compteur hit/miss cache métadonnées + - Temps moyen de rafraîchissement par station + - Nombre de connexions actives par station + - Bande passante proxy totale + +### Résultat attendu + +À la fin du Round 5 : + +1. ✅ Trait `MusicSource` implémenté +2. ✅ Arborescence UPnP générée dynamiquement depuis les données +3. ✅ Routes API REST complètes et fonctionnelles +4. ✅ Proxy streaming AAC avec tracking des connexions +5. ✅ Rafraîchissement automatique des métadonnées basé sur l'usage réel +6. ✅ Cache multi-niveaux opérationnel +7. ✅ Tests unitaires et d'intégration +8. ✅ ~70 stations Radio France accessibles via UPnP et API + +Radio France sera alors **pleinement intégré** dans PMOMusic : +- ✅ Navigation UPnP hiérarchique sur contrôleurs compatibles +- ✅ API REST pour webapp/clients HTTP +- ✅ Streaming AAC passthrough (pas de transcodage pour l'instant) +- ✅ Tracking intelligent : rafraîchissement uniquement des stations écoutées +- ✅ Cache intelligent (stations 7j + métadonnées dynamique) +- ✅ Toutes les stations principales, webradios et radios ICI + +**Note technique** : Le proxy AAC passthrough est un compromis pragmatique dû aux limitations actuelles du décodage AAC streaming dans `pmoflac`. Si cette capacité est ajoutée à l'avenir, le transcodage vers FLAC pourra être implémenté via une feature flag optionnelle. + +## Round 6 + +Il faut maintenant activer cette nouvelle source. +Tu peux regarder dans les deux autres sources actuelles comment cette initialisation est réalisée +- [@pmoqobuz](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoqobuz) +- [@pmoparadise](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoparadise) + +Ainsi que dans la crate [@pmoupnp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoupnp) diff --git a/Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md b/Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md new file mode 100644 index 00000000..dcceed79 --- /dev/null +++ b/Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md @@ -0,0 +1,586 @@ +** Tu dois suivre scrupuleusement les règles définies dans le fichier [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) ** + +** Cette tâche est une tâche de recherche et développement. Elle doit conduire à un prototype fonctionnel et/ou un rapport technique sur la faisabilité. ** + +# Support du streaming AAC dans pmoflac + +## Contexte + +Actuellement, `pmoflac` supporte le décodage streaming pour : +- ✅ MP3 (via `minimp3`) +- ✅ FLAC (via `claxon`) +- ✅ Ogg Vorbis (via `lewton`) +- ✅ Ogg Opus (via `opus`) +- ✅ WAV (parsing manuel) +- ✅ AIFF (parsing manuel) + +**Manque critique** : Pas de support AAC, pourtant très utilisé pour : +- Streams radio live (Radio France, etc.) +- Podcasts +- Services de streaming musicaux +- Fichiers M4A/MP4 + +## Problématique + +Le décodage AAC en **streaming infini** (radio live) est actuellement impossible dans `pmoflac`, ce qui force à : +- Soit faire un proxy passthrough (pas de transcodage FLAC) +- Soit utiliser une redirection 302 (pas de tracking) + +Cela empêche d'avoir une expérience uniforme où toutes les sources servent du FLAC. + +## Objectif + +**Investiguer et prototyper** le support du décodage AAC streaming dans `pmoflac`, en s'inspirant de l'architecture existante (MP3, Ogg, etc.). + +## Recherches préliminaires + +### 1. Symphonia avec ReadOnlySource + +[Symphonia](https://github.com/pdeljanov/Symphonia) est la bibliothèque Rust la plus complète pour le décodage audio. Elle fournit : + +- **`ReadOnlySource`** : Wrapper pour sources non-seekable (streams infinis) +- **`AdtsReader`** : Format reader spécifique pour ADTS (AAC streaming) +- **`symphonia-codec-aac`** : Décodeur AAC-LC (Low Complexity) + +**Points d'attention** : +- [Issue connue](https://github.com/RustAudio/rodio/issues/580) : Certains formats peuvent quand même réclamer le seek +- Nécessite de tester avec un vrai stream ADTS + +### 2. Format ADTS + +[ADTS](https://wiki.multimedia.cx/index.php/ADTS) (Audio Data Transport Stream) est le format AAC conçu pour le streaming : + +- Auto-synchronisant : chaque frame a un header (12 bits `0xFFF`) +- Pas de container nécessaire (MP4, M4A) +- Utilisé par les radios en streaming +- Chaque frame contient ses métadonnées (sample rate, channels, etc.) + +**Structure** : +``` +Frame 1: [ADTS Header 7-9 bytes][AAC Data] +Frame 2: [ADTS Header 7-9 bytes][AAC Data] +... +``` + +### 3. Alternative : fdk-aac + +[Bindings Rust pour fdk-aac](https://github.com/haileys/fdk-aac-rs) (bibliothèque Fraunhofer) : + +**Avantages** : +- ✅ Décodeur de référence (qualité maximale) +- ✅ Support explicite du streaming chunk-by-chunk +- ✅ Buffer interne géré automatiquement +- ✅ Pas besoin de seek + +**Inconvénients** : +- ❌ Dépendance C (libfdk-aac) +- ❌ Licence restrictive (non-commerciale pour certaines versions) +- ❌ Compilation plus complexe + +## Plan d'investigation + +### Round 1 : Prototype Symphonia ADTS + +**Objectif** : Tester si Symphonia peut décoder un stream AAC infini avec `ReadOnlySource` + `AdtsReader`. + +#### Étapes + +1. **Créer un module de test** : `pmoflac/tests/aac_streaming_test.rs` + +2. **Implémenter un décodeur basique** : + ```rust + use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; + use symphonia::default::get_probe; + use symphonia_codec_aac::AdtsReader; + + async fn decode_aac_stream_test( + reader: R + ) -> Result> { + // Wrapper AsyncRead → Read synchrone (pattern pmoflac) + let sync_reader = blocking_reader_from_async(reader); + + // ReadOnlySource pour stream infini + let source = ReadOnlySource::new(sync_reader); + let mss = MediaSourceStream::new(Box::new(source), Default::default()); + + // Probe avec hint AAC/ADTS + let mut hint = Hint::new(); + hint.with_extension("aac"); + + let mut format = get_probe() + .format(&hint, mss, &Default::default(), &Default::default())?; + + // Récupérer le track audio + let track = format.default_track().unwrap(); + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &Default::default())?; + + let mut pcm_output = Vec::new(); + + // Décoder frame par frame (boucle infinie jusqu'à disconnect) + loop { + match format.next_packet() { + Ok(packet) => { + let decoded = decoder.decode(&packet)?; + // Convertir en PCM et accumuler + let samples = convert_to_pcm_bytes(decoded); + pcm_output.extend_from_slice(&samples); + } + Err(symphonia::core::errors::Error::IoError(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break; // Stream fermé + } + Err(e) => return Err(e.into()), + } + } + + Ok(pcm_output) + } + ``` + +3. **Tester avec un fichier AAC ADTS statique** : + - Télécharger un échantillon AAC ADTS + - Vérifier que le décodage fonctionne + - Comparer PCM output avec ffmpeg + +4. **Tester avec un stream Radio France live** : + ```rust + #[tokio::test] + #[ignore = "Requires network"] + async fn test_decode_radiofrance_stream() { + let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac"; + let response = reqwest::get(stream_url).await.unwrap(); + let reader = response.bytes_stream(); + + // Lire 10 secondes de stream + let pcm = decode_aac_stream_test(reader).await.unwrap(); + + assert!(!pcm.is_empty()); + // Vérifier format PCM (44.1kHz ou 48kHz, stéréo, 16-bit) + } + ``` + +#### Critères de succès Round 1 + +- ✅ Le décodeur accepte un `ReadOnlySource` sans erreur de seek +- ✅ Les frames ADTS sont correctement parsées +- ✅ Le décodage AAC → PCM fonctionne +- ✅ Un stream live (infini) peut être décodé sans plantage +- ✅ Le PCM output est valide (vérifiable avec `ffplay`) + +#### Livrables Round 1 + +1. **Module de test** : `pmoflac/tests/aac_streaming_test.rs` +2. **Rapport technique** : `Blackboard/Report/Support_AAC_streaming_pmoflac.md` + - Résultats des tests + - Problèmes rencontrés (seek, parsing, etc.) + - Métriques de performance (CPU, latence) + - Comparaison qualité avec ffmpeg + +--- + +### Round 2 : Intégration dans pmoflac (si Round 1 réussit) + +**Objectif** : Intégrer le décodeur AAC dans l'architecture streaming de `pmoflac`. + +#### Fichiers à créer/modifier + +**1. `pmoflac/src/aac.rs`** (nouveau) + +```rust +use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; +use tokio::sync::mpsc; +use crate::{ + common::ChannelReader, + decoder_common::{spawn_ingest_task, spawn_writer_task, DecodedStream}, + pcm::StreamInfo, +}; + +pub type AacDecodedStream = DecodedStream; + +#[derive(thiserror::Error, Debug)] +pub enum AacError { + #[error("AAC decode error: {0}")] + Decode(String), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("Channel closed")] + ChannelClosed, +} + +/// Décoder un stream AAC/ADTS en PCM +pub async fn decode_aac_stream(reader: R) -> Result +where + R: AsyncRead + Unpin + Send + 'static, +{ + // Suivre le pattern existant (MP3, FLAC, etc.) + let (ingest_tx, ingest_rx) = mpsc::channel(CHANNEL_CAPACITY); + spawn_ingest_task(reader, ingest_tx); + + let (pcm_tx, pcm_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (pcm_reader, pcm_writer) = tokio::io::duplex(DUPLEX_BUFFER_SIZE); + let (info_tx, info_rx) = oneshot::channel::>(); + + let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), AacError> { + let mut channel_reader = ChannelReader::::new(ingest_rx); + + // ReadOnlySource pour stream infini + let source = ReadOnlySource::new(&mut channel_reader); + let mss = MediaSourceStream::new(Box::new(source), Default::default()); + + // Probe AAC/ADTS + let mut hint = Hint::new(); + hint.with_extension("aac"); + + let mut format = get_probe() + .format(&hint, mss, &Default::default(), &Default::default()) + .map_err(|e| AacError::Decode(e.to_string()))?; + + let track = format.default_track() + .ok_or_else(|| AacError::Decode("No audio track found".into()))?; + + let mut decoder = get_codecs() + .make(&track.codec_params, &Default::default()) + .map_err(|e| AacError::Decode(e.to_string()))?; + + // Extraire StreamInfo + let codec_params = &track.codec_params; + let info = StreamInfo { + sample_rate: codec_params.sample_rate.unwrap_or(48000), + channels: codec_params.channels.unwrap().count() as u8, + bits_per_sample: 16, // AAC decode to 16-bit PCM + total_samples: None, // Stream infini + max_block_size: 0, + min_block_size: 0, + }; + + if info_tx.send(Ok(info.clone())).is_err() { + return Ok(()); + } + + // Boucle de décodage + loop { + match format.next_packet() { + Ok(packet) => { + let decoded = decoder.decode(&packet) + .map_err(|e| AacError::Decode(e.to_string()))?; + + // Convertir AudioBufferRef → bytes PCM + let pcm_bytes = convert_audio_buffer_to_bytes(decoded, &info); + + if pcm_tx.blocking_send(Ok(pcm_bytes)).is_err() { + break; // Reader fermé + } + } + Err(symphonia::core::errors::Error::IoError(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break; // Stream terminé normalement + } + Err(e) => { + let msg = e.to_string(); + let _ = pcm_tx.blocking_send(Err(AacError::Decode(msg.clone()))); + return Err(AacError::Decode(msg)); + } + } + } + + Ok(()) + }); + + let writer_handle = spawn_writer_task(pcm_rx, pcm_writer, blocking_handle, "aac-decode"); + let info = info_rx.await.map_err(|_| AacError::ChannelClosed)??; + let reader = ManagedAsyncReader::new("aac-decode-writer", pcm_reader, writer_handle); + + Ok(DecodedStream::new(info, reader)) +} + +/// Convertir AudioBufferRef Symphonia → bytes PCM little-endian +fn convert_audio_buffer_to_bytes( + audio_buffer: AudioBufferRef, + info: &StreamInfo, +) -> Vec { + // Implémenter conversion selon le type de buffer + // (S16, S24, S32, F32, etc.) → i16 little-endian interleaved + // ... +} +``` + +**2. `pmoflac/src/lib.rs`** (modifier) + +```rust +pub mod aac; + +pub use aac::{decode_aac_stream, AacDecodedStream, AacError}; +``` + +**3. `pmoflac/src/autodetect.rs`** (modifier) + +Ajouter la détection AAC/ADTS : + +```rust +fn detect_format(bytes: &[u8]) -> Option { + // ... détections existantes ... + + // Détecter ADTS AAC (syncword 0xFFF) + if is_adts(bytes) { + return Some(DetectedFormat::Aac); + } + + None +} + +fn is_adts(bytes: &[u8]) -> bool { + if bytes.len() < 2 { + return false; + } + // ADTS syncword: 12 bits à 1 (0xFFF) + bytes[0] == 0xFF && (bytes[1] & 0xF0) == 0xF0 +} + +pub enum DecodedAudioStream { + // ... variants existants ... + Aac(AacDecodedStream), +} +``` + +**4. `pmoflac/src/transcode.rs`** (modifier) + +Ajouter AAC au transcodeur : + +```rust +pub enum AudioCodec { + // ... codecs existants ... + Aac, +} + +pub async fn transcode_to_flac_stream( + reader: R, + options: TranscodeOptions, +) -> Result +where + R: AsyncRead + Unpin + Send + 'static, +{ + // ... détection auto ... + + match decoded { + // ... cas existants ... + DecodedAudioStream::Aac(stream) => { + transcode_from_decoded(AudioCodec::Aac, stream, options.encoder_options).await + } + } +} +``` + +**5. `pmoflac/Cargo.toml`** (modifier) + +```toml +[dependencies] +# ... dépendances existantes ... + +# AAC support +symphonia = { version = "0.5", features = ["aac", "isomp4"], optional = true } +symphonia-core = { version = "0.5", optional = true } +symphonia-codec-aac = { version = "0.5", optional = true } + +[features] +default = ["mp3", "ogg", "opus", "wav", "aiff"] +aac = ["dep:symphonia", "dep:symphonia-core", "dep:symphonia-codec-aac"] +all = ["mp3", "ogg", "opus", "wav", "aiff", "aac"] +``` + +#### Tests Round 2 + +**Tests unitaires** : +```rust +#[tokio::test] +async fn test_decode_aac_to_pcm() { + let aac_data = include_bytes!("../test-data/sample.aac"); + let stream = decode_aac_stream(&aac_data[..]).await.unwrap(); + + let info = stream.info(); + assert_eq!(info.sample_rate, 48000); + assert_eq!(info.channels, 2); + + // Lire quelques samples + let mut buffer = vec![0u8; 4096]; + let mut reader = stream; + let n = reader.read(&mut buffer).await.unwrap(); + assert!(n > 0); +} +``` + +**Tests intégration** : +```rust +#[tokio::test] +#[ignore = "Integration test - network required"] +async fn test_transcode_radiofrance_to_flac() { + let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac"; + let response = reqwest::get(stream_url).await.unwrap(); + let reader = response.bytes_stream(); + + let transcoded = transcode_to_flac_stream( + reader, + TranscodeOptions::default() + ).await.unwrap(); + + assert_eq!(transcoded.input_codec(), AudioCodec::Aac); + assert_eq!(transcoded.input_stream_info().sample_rate, 48000); + + // Lire 5 secondes de FLAC + let mut output = Vec::new(); + let mut stream = transcoded.into_stream(); + + for _ in 0..50 { + let mut chunk = vec![0u8; 8192]; + stream.read(&mut chunk).await.unwrap(); + output.extend_from_slice(&chunk); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert!(output.len() > 100_000); // Au moins 100 KB de FLAC +} +``` + +#### Critères de succès Round 2 + +- ✅ `decode_aac_stream()` suit le pattern existant (MP3, Ogg, etc.) +- ✅ Auto-détection AAC/ADTS fonctionne +- ✅ Transcodage AAC → FLAC streaming opérationnel +- ✅ Tests unitaires et intégration passent +- ✅ Documentation complète (doctests, exemples) +- ✅ Feature flag `aac` pour compilation optionnelle + +--- + +### Round 3 : Intégration dans pmoradiofrance (si Round 2 réussit) + +**Objectif** : Remplacer le proxy AAC passthrough par un transcodage FLAC. + +#### Modifications + +**1. `pmoradiofrance/src/server_ext.rs`** + +Remplacer le proxy passthrough par un transcodage : + +```rust +async fn proxy_stream( + Path(slug): Path, + State(state): State> +) -> Result { + let stream_url = state.client.get_stream_url(&slug).await + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + + let response = reqwest::get(&stream_url).await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + + // Transcoder AAC → FLAC avec pmoflac + let transcoded = pmoflac::transcode_to_flac_stream( + response.bytes_stream(), + pmoflac::TranscodeOptions::default() + ).await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Enregistrer connexion active et démarrer metadata refresh + // ... + + // Stream FLAC au lieu d'AAC + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", "audio/flac".parse().unwrap()); + headers.insert("Cache-Control", "no-cache".parse().unwrap()); + + Ok((headers, Body::from_stream(transcoded.into_stream())).into_response()) +} +``` + +**2. `pmoradiofrance/src/playlist.rs`** + +Changer le protocol_info pour FLAC : + +```rust +// Avant (AAC) +protocol_info: "http-get:*:audio/aac:*" + +// Après (FLAC) +protocol_info: "http-get:*:audio/flac:*" +sample_frequency: Some(info.sample_rate.to_string()) +bits_per_sample: Some("16".to_string()) +``` + +#### Critères de succès Round 3 + +- ✅ Radio France sert du FLAC au lieu d'AAC +- ✅ Uniformité : toutes les sources PMOMusic servent du FLAC +- ✅ Latence acceptable (<2s) pour le streaming live +- ✅ CPU raisonnable pour 2-3 streams simultanés sur LAN +- ✅ Métadonnées volatiles toujours mises à jour + +--- + +## Alternative : fdk-aac (si Symphonia échoue) + +Si Symphonia ne fonctionne pas en streaming infini, explorer `fdk-aac` : + +### Avantages +- ✅ Décodeur de référence (meilleure qualité) +- ✅ Conçu pour le streaming +- ✅ Utilisé en production (Android, etc.) + +### Inconvénients +- ❌ Dépendance C (compilation complexe) +- ❌ Licence restrictive (vérifier compatibilité projet) + +### Prototype minimal + +```rust +use fdk_aac::dec::{Decoder, DecoderParams}; + +pub async fn decode_aac_with_fdk(reader: R) -> Result +where + R: AsyncRead + Unpin + Send + 'static, +{ + // Similar pattern to pmoflac MP3 decoder + // spawn_blocking pour le décodeur C + // ... +} +``` + +--- + +## Résultats attendus + +### Minimum viable (Round 1) + +- ✅ Rapport technique sur la faisabilité du streaming AAC avec Symphonia +- ✅ Prototype fonctionnel (même basique) +- ✅ Identification des limitations et solutions de contournement + +### Objectif complet (Round 1-3) + +- ✅ Support AAC/ADTS dans `pmoflac` (feature flag optionnelle) +- ✅ Transcodage AAC → FLAC streaming opérationnel +- ✅ Radio France servant du FLAC uniforme +- ✅ Documentation et tests complets + +### En cas d'échec + +- ✅ Rapport détaillé des blocages techniques +- ✅ Recommandations alternatives (fdk-aac, attendre évolution Symphonia, etc.) +- ✅ Garder le proxy AAC passthrough actuel + +--- + +## Références + +### Documentation +- [Symphonia Getting Started](https://github.com/pdeljanov/Symphonia/blob/master/GETTING_STARTED.md) +- [AdtsReader API](https://docs.rs/symphonia-codec-aac/latest/symphonia_codec_aac/struct.AdtsReader.html) +- [ADTS Format Specification](https://wiki.multimedia.cx/index.php/ADTS) +- [fdk-aac Rust Bindings](https://github.com/haileys/fdk-aac-rs) + +### Issues et discussions +- [Symphonia ReadOnlySource Issue #580](https://github.com/RustAudio/rodio/issues/580) +- [Symphonia MediaSource Trait](https://docs.rs/symphonia-core/latest/symphonia_core/io/index.html) + +### Contexte PMOMusic +- [Task Radio France](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToDiscuss/Construire_pmoradiofrance.md) +- [Architecture pmoflac](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoflac/src/lib.rs) diff --git a/Cargo.lock b/Cargo.lock index 010ae188..a019320a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4010,6 +4010,7 @@ dependencies = [ "pmoparadise", "pmoplaylist", "pmoqobuz", + "pmoradiofrance", "pmoserver", "pmosource", "pmoupnp", @@ -4147,13 +4148,17 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum 0.8.7", "chrono", + "futures", "pmoaudiocache", "pmoconfig", "pmocovers", "pmodidl", "pmoplaylist", + "pmoserver", "pmosource", + "pmoupnp", "regex", "reqwest", "scraper", diff --git a/Cargo.toml b/Cargo.toml index 0ddf5dce..35fa95ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } reqwest = { version = "0.12", default-features = false } ureq = "3.1" quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38 +axum = "0.8.4" +futures = "0.3" # Utilities chrono = { version = "0.4", features = ["serde"] } diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index c879644d..e075335a 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmoupnp = { path = "../pmoupnp"} pmomediarenderer = { path = "../pmomediarenderer" } -pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] } +pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] } pmosource = { path = "../pmosource", features = ["server"] } pmoserver = { path = "../pmoserver" } pmocovers = { path = "../pmocovers", features = ["pmoserver"] } diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index d2c4c9bd..fb0596f0 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -54,6 +54,12 @@ async fn main() -> Result<(), Box> { } } + // Enregistrer la source Radio France + info!("📻 Registering Radio France source..."); + if let Err(e) = server.write().await.register_radiofrance().await { + tracing::warn!("⚠️ Failed to register Radio France source: {}", e); + } + // Lister toutes les sources enregistrées let sources = server.read().await.list_music_sources().await; info!("✅ {} music source(s) registered", sources.len()); diff --git a/pmocontrol/examples/chromecast_info.rs b/pmocontrol/examples/chromecast_info.rs index 3f32982c..1d82a231 100644 --- a/pmocontrol/examples/chromecast_info.rs +++ b/pmocontrol/examples/chromecast_info.rs @@ -19,7 +19,7 @@ fn ensure_crypto_provider_initialized() { INIT.call_once(|| { let _ = rustls::crypto::CryptoProvider::install_default( - rustls::crypto::aws_lc_rs::default_provider() + rustls::crypto::aws_lc_rs::default_provider(), ); }); } @@ -47,21 +47,25 @@ fn main() { // Connect to the device println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT); - let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) { - Ok(device) => { - println!(" ✓ Connected"); - device - } - Err(e) => { - eprintln!(" ✗ Failed: {}", e); - std::process::exit(1); - } - }; + let cast_device = + match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) { + Ok(device) => { + println!(" ✓ Connected"); + device + } + Err(e) => { + eprintln!(" ✗ Failed: {}", e); + std::process::exit(1); + } + }; // Connect to receiver channel println!(); println!("→ Connecting to receiver channel..."); - if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) { + if let Err(e) = cast_device + .connection + .connect(DEFAULT_DESTINATION_ID.to_string()) + { eprintln!(" ✗ Failed: {}", e); std::process::exit(1); } diff --git a/pmocontrol/examples/chromecast_playback_test.rs b/pmocontrol/examples/chromecast_playback_test.rs index 528a872f..78e2d60c 100644 --- a/pmocontrol/examples/chromecast_playback_test.rs +++ b/pmocontrol/examples/chromecast_playback_test.rs @@ -36,7 +36,7 @@ fn ensure_crypto_provider_initialized() { INIT.call_once(|| { let _ = rustls::crypto::CryptoProvider::install_default( - rustls::crypto::aws_lc_rs::default_provider() + rustls::crypto::aws_lc_rs::default_provider(), ); println!("✓ Rustls CryptoProvider initialized"); }); @@ -88,7 +88,10 @@ fn main() { eprintln!("Usage: {} [media_url]", args[0]); eprintln!("\nExample:"); eprintln!(" {} 192.168.1.100", args[0]); - eprintln!("\nIf no media URL is provided, will use: {}", TEST_MEDIA_URL); + eprintln!( + "\nIf no media URL is provided, will use: {}", + TEST_MEDIA_URL + ); std::process::exit(1); } @@ -116,21 +119,25 @@ fn main() { // Step 1: Connect to the device println!("──────────────────────────────────────────────────────────"); println!("STEP 1: Connecting to Chromecast..."); - let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) { - Ok(device) => { - println!("✓ Connected to Chromecast"); - device - } - Err(e) => { - eprintln!("✗ Failed to connect: {}", e); - std::process::exit(1); - } - }; + let cast_device = + match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) { + Ok(device) => { + println!("✓ Connected to Chromecast"); + device + } + Err(e) => { + eprintln!("✗ Failed to connect: {}", e); + std::process::exit(1); + } + }; // Step 2: Connect to the default receiver channel println!(); println!("STEP 2: Connecting to receiver channel..."); - if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) { + if let Err(e) = cast_device + .connection + .connect(DEFAULT_DESTINATION_ID.to_string()) + { eprintln!("✗ Failed to connect channel: {}", e); std::process::exit(1); } @@ -151,7 +158,10 @@ fn main() { let status = match cast_device.receiver.get_status() { Ok(status) => { println!("✓ Receiver status obtained"); - println!(" - Volume: {:.0}%", status.volume.level.unwrap_or(0.5) * 100.0); + println!( + " - Volume: {:.0}%", + status.volume.level.unwrap_or(0.5) * 100.0 + ); println!(" - Muted: {}", status.volume.muted.unwrap_or(false)); println!(" - Running apps: {}", status.applications.len()); status @@ -165,7 +175,10 @@ fn main() { // Step 5: Launch DefaultMediaReceiver println!(); println!("STEP 5: Launching DefaultMediaReceiver app..."); - let app = match cast_device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) { + let app = match cast_device + .receiver + .launch_app(&CastDeviceApp::DefaultMediaReceiver) + { Ok(app) => { println!("✓ App launched successfully"); println!(" - App ID: {}", app.app_id); @@ -201,11 +214,10 @@ fn main() { metadata: None, }; - match cast_device.media.load( - app.transport_id.as_str(), - app.session_id.as_str(), - &media, - ) { + match cast_device + .media + .load(app.transport_id.as_str(), app.session_id.as_str(), &media) + { Ok(status) => { println!("✓ Media loaded successfully!"); println!(" - Media status entries: {}", status.entries.len()); @@ -241,7 +253,10 @@ fn main() { Ok(ChannelMessage::Heartbeat(response)) => { if let HeartbeatResponse::Ping = response { heartbeat_count += 1; - println!("[Heartbeat #{:3}] Received Ping, sending Pong...", heartbeat_count); + println!( + "[Heartbeat #{:3}] Received Ping, sending Pong...", + heartbeat_count + ); if let Err(e) = cast_device.heartbeat.pong() { eprintln!("✗ Failed to send pong: {}", e); diff --git a/pmocontrol/examples/discover.rs b/pmocontrol/examples/discover.rs index 487367f4..cfaa6b76 100644 --- a/pmocontrol/examples/discover.rs +++ b/pmocontrol/examples/discover.rs @@ -3,7 +3,7 @@ use std::thread; use std::time::Duration; use pmocontrol::RendererProtocol; -use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo}; +use pmocontrol::{ControlPoint, DeviceRegistryRead, RendererInfo, UpnpMediaServer}; fn main() -> std::io::Result<()> { // Un tout petit logging optionnel diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index b113d0e1..4a3d52cc 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -19,9 +19,9 @@ use crossterm::terminal::{ }; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, - UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, - RendererEvent, RendererInfo, TransportControl, VolumeControl, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, PlaybackItem, + PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, RendererEvent, RendererInfo, + TransportControl, UpnpMediaServer, UpnpMediaServer, VolumeControl, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs index 384a4b17..6785946f 100644 --- a/pmocontrol/examples/live_pmomusic_demo.rs +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -10,8 +10,9 @@ use std::time::Duration; use anyhow::{Context, Result}; use pmocontrol::model::TrackMetadata; use pmocontrol::{ - ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer, - MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, + MusicRendererBackend, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, + UpnpMediaServer, UpnpMediaServer, }; const DEFAULT_TIMEOUT_SECS: u64 = 5; diff --git a/pmocontrol/examples/media_server_events_demo.rs b/pmocontrol/examples/media_server_events_demo.rs index a6c922fa..867aba2e 100644 --- a/pmocontrol/examples/media_server_events_demo.rs +++ b/pmocontrol/examples/media_server_events_demo.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use anyhow::Result; use crossbeam_channel::RecvTimeoutError; -use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId}; +use pmocontrol::{ControlPoint, MediaServerEvent, ServerId, UpnpMediaServer}; const DISCOVERY_WAIT_SECS: u64 = 5; const MONITOR_DURATION_SECS: u64 = 90; diff --git a/pmocontrol/examples/openhome_tester.rs b/pmocontrol/examples/openhome_tester.rs index 4970898d..19fd47a5 100644 --- a/pmocontrol/examples/openhome_tester.rs +++ b/pmocontrol/examples/openhome_tester.rs @@ -5,8 +5,8 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; use pmocontrol::{ - DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend, - RendererInfo, + DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, + MusicRendererBackend, RendererInfo, control_point::ControlPoint, openhome_client::{ OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, diff --git a/pmocontrol/examples/renderer_demo.rs b/pmocontrol/examples/renderer_demo.rs index 31cd17e8..2e316e56 100644 --- a/pmocontrol/examples/renderer_demo.rs +++ b/pmocontrol/examples/renderer_demo.rs @@ -344,7 +344,12 @@ fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<( Ok(()) } -fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) { +fn progress_monitor( + renderer: &MusicRendererBackend, + label: &str, + iterations: usize, + interval_secs: u64, +) { println!( "\n[{label}] polling playback state/position {} times (every {} s)...", iterations, interval_secs diff --git a/pmocontrol/src/discovery/arylic.rs b/pmocontrol/src/discovery/arylic.rs index bf5c4219..968af658 100644 --- a/pmocontrol/src/discovery/arylic.rs +++ b/pmocontrol/src/discovery/arylic.rs @@ -7,7 +7,9 @@ use std::{ use tracing::debug; use crate::{ - arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host + arylic_client::{ARYLIC_TCP_PORT, send_command_required}, + errors::ControlPointError, + linkplay_client::extract_linkplay_host, }; static DETECTION_CACHE: OnceLock>> = OnceLock::new(); diff --git a/pmocontrol/src/discovery/chromecast_discovery.rs b/pmocontrol/src/discovery/chromecast_discovery.rs index 7088163b..673443c1 100644 --- a/pmocontrol/src/discovery/chromecast_discovery.rs +++ b/pmocontrol/src/discovery/chromecast_discovery.rs @@ -65,7 +65,10 @@ impl ChromecastDiscoveryManager { .collect(); if addresses.is_empty() { - warn!("No IP address found for Chromecast device: {}", service_name); + warn!( + "No IP address found for Chromecast device: {}", + service_name + ); return; } @@ -127,22 +130,19 @@ impl ChromecastDiscoveryManager { // Extract friendly name from TXT record "fn" if available // Otherwise, extract from service instance name (PTR record) - let friendly_name = txt_records - .get("fn") - .cloned() - .unwrap_or_else(|| { - // Fallback: extract from service name, removing the UUID suffix if present - service_name - .split("._googlecast._tcp.local") - .next() - .unwrap_or("Unknown Chromecast") - .split('-') - .take_while(|part| part.len() != 32) // Skip 32-char hex UUID - .collect::>() - .join("-") - .trim() - .to_string() - }); + let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| { + // Fallback: extract from service name, removing the UUID suffix if present + service_name + .split("._googlecast._tcp.local") + .next() + .unwrap_or("Unknown Chromecast") + .split('-') + .take_while(|part| part.len() != 32) // Skip 32-char hex UUID + .collect::>() + .join("-") + .trim() + .to_string() + }); debug!( "Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})", diff --git a/pmocontrol/src/discovery/upnp_provider.rs b/pmocontrol/src/discovery/upnp_provider.rs index bf5ce640..ba8cf19b 100644 --- a/pmocontrol/src/discovery/upnp_provider.rs +++ b/pmocontrol/src/discovery/upnp_provider.rs @@ -3,14 +3,14 @@ use std::time::Duration; use quick_xml::{Error as XmlError, Reader, events::Event}; use thiserror::Error; -use tracing::{debug}; +use tracing::debug; use crate::DeviceId; use crate::discovery::arylic::detect_arylic_tcp; use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host}; use crate::media_server::UpnpMediaServer; use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol}; -use crate::upnp_clients::{AvTransportClient,resolve_control_url}; +use crate::upnp_clients::{AvTransportClient, resolve_control_url}; use ureq::Agent; @@ -77,7 +77,6 @@ pub struct ParsedDeviceDescription { } impl ParsedDeviceDescription { - /// Fetch and parse the device description.xml at endpoint.location. pub fn new( udn: &str, @@ -108,7 +107,7 @@ impl ParsedDeviceDescription { let mut buf = Vec::new(); let mut parsed = ParsedDeviceDescription::default(); - parsed.timeout_secs=timeout_secs; + parsed.timeout_secs = timeout_secs; parsed.location = location.to_string(); parsed.udn = udn.to_string(); parsed.server_header = server_header.to_string(); @@ -354,9 +353,7 @@ impl ParsedDeviceDescription { parsed.require_fields() } - pub fn build_renderer( - &self, - ) -> Option { + pub fn build_renderer(&self) -> Option { let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:") && !device_type.contains("urn:av-openhome-org:device:mediarenderer:") @@ -371,10 +368,16 @@ impl ParsedDeviceDescription { let udn = self.udn.to_ascii_lowercase(); let mut caps = detect_renderer_capabilities(&self.service_types); - if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { + if detect_linkplay_http( + &self.location, + Duration::from_secs(self.timeout_secs.max(1)), + ) { caps.has_linkplay_http = true; } - if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) { + if detect_arylic_tcp( + &self.location, + Duration::from_secs(self.timeout_secs.max(1)), + ) { caps.has_arylic_tcp = true; } let protocol = detect_renderer_protocol(&caps); @@ -390,68 +393,54 @@ impl ParsedDeviceDescription { self.location.clone(), self.server_header.clone(), self.avtransport_service_type.clone(), - self - .avtransport_control_url + self.avtransport_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), self.rendering_control_service_type.clone(), - self - .rendering_control_control_url + self.rendering_control_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), self.connection_manager_service_type.clone(), - self - .connection_manager_control_url + self.connection_manager_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), self.oh_playlist_service_type.clone(), - self - .oh_playlist_control_url + self.oh_playlist_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), - self - .oh_playlist_event_sub_url + self.oh_playlist_event_sub_url .as_ref() .map(|url| resolve_control_url(&self.location, url)), self.oh_info_service_type.clone(), - self - .oh_info_control_url + self.oh_info_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), - self - .oh_info_event_sub_url + self.oh_info_event_sub_url .as_ref() .map(|url| resolve_control_url(&self.location, url)), self.oh_time_service_type.clone(), - self - .oh_time_control_url + self.oh_time_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), - self - .oh_time_event_sub_url + self.oh_time_event_sub_url .as_ref() .map(|url| resolve_control_url(&self.location, url)), self.oh_volume_service_type.clone(), - self - .oh_volume_control_url + self.oh_volume_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), self.oh_radio_service_type.clone(), - self - .oh_radio_control_url + self.oh_radio_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), self.oh_product_service_type.clone(), - self - .oh_product_control_url + self.oh_product_control_url .as_ref() .map(|ctrl| resolve_control_url(&self.location, ctrl)), )) } - pub fn build_server( - &self, - ) -> Option { + pub fn build_server(&self) -> Option { let device_type = self.device_type.as_ref()?.to_ascii_lowercase(); if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") { return None; @@ -480,14 +469,11 @@ impl ParsedDeviceDescription { self.content_directory_service_type.clone(), content_directory_control_url, )) - } /// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present, /// Ok(None) if no AVTransport service was found. - pub fn build_avtransport_client( - &self, - ) -> Result, DescriptionError> { + pub fn build_avtransport_client(&self) -> Result, DescriptionError> { let service_type = match &self.avtransport_service_type { Some(st) => st.clone(), None => return Ok(None), @@ -678,9 +664,6 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol { } } - - - /// Detect whether a renderer exposes the LinkPlay HTTP API. pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { let Some(host) = extract_linkplay_host(location) else { @@ -697,4 +680,4 @@ pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool { false } } -} \ No newline at end of file +} diff --git a/pmocontrol/src/linkplay_utils.rs b/pmocontrol/src/linkplay_utils.rs index e69de29b..8b137891 100644 --- a/pmocontrol/src/linkplay_utils.rs +++ b/pmocontrol/src/linkplay_utils.rs @@ -0,0 +1 @@ + diff --git a/pmocontrol/src/music_renderer/time_utils.rs b/pmocontrol/src/music_renderer/time_utils.rs index 819096f8..c4d96586 100644 --- a/pmocontrol/src/music_renderer/time_utils.rs +++ b/pmocontrol/src/music_renderer/time_utils.rs @@ -63,17 +63,19 @@ pub fn parse_time_flexible(input: &str) -> Result { let parts: Vec<&str> = input.split(':').collect(); if parts.is_empty() || parts.len() > 3 { - return Err(ControlPointError::InvalidTimeFormat( - format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input) - )); + return Err(ControlPointError::InvalidTimeFormat(format!( + "Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", + input + ))); } let mut total = 0u32; for part in parts { let value = part.parse::().map_err(|_| { - ControlPointError::InvalidTimeFormat( - format!("Invalid numeric value '{}' in time string '{}'", part, input) - ) + ControlPointError::InvalidTimeFormat(format!( + "Invalid numeric value '{}' in time string '{}'", + part, input + )) })?; total = total * 60 + value; } @@ -103,33 +105,29 @@ pub fn parse_hhmmss_strict(input: &str) -> Result { let parts: Vec<&str> = input.split(':').collect(); if parts.len() != 3 { - return Err(ControlPointError::InvalidTimeFormat( - format!("Invalid time format '{}': expected exactly HH:MM:SS", input) - )); + return Err(ControlPointError::InvalidTimeFormat(format!( + "Invalid time format '{}': expected exactly HH:MM:SS", + input + ))); } let hours: u64 = parts[0].parse().map_err(|_| { - ControlPointError::InvalidTimeFormat( - format!("Invalid hour component in '{}'", input) - ) + ControlPointError::InvalidTimeFormat(format!("Invalid hour component in '{}'", input)) })?; let minutes: u64 = parts[1].parse().map_err(|_| { - ControlPointError::InvalidTimeFormat( - format!("Invalid minute component in '{}'", input) - ) + ControlPointError::InvalidTimeFormat(format!("Invalid minute component in '{}'", input)) })?; let seconds: u64 = parts[2].parse().map_err(|_| { - ControlPointError::InvalidTimeFormat( - format!("Invalid second component in '{}'", input) - ) + ControlPointError::InvalidTimeFormat(format!("Invalid second component in '{}'", input)) })?; if minutes >= 60 || seconds >= 60 { - return Err(ControlPointError::InvalidTimeFormat( - format!("Invalid time '{}': minutes and seconds must be < 60", input) - )); + return Err(ControlPointError::InvalidTimeFormat(format!( + "Invalid time '{}': minutes and seconds must be < 60", + input + ))); } Ok(hours * 3600 + minutes * 60 + seconds) @@ -209,7 +207,7 @@ mod tests { #[test] fn test_ms_conversions() { assert_eq!(ms_to_seconds(1000), 1); - assert_eq!(ms_to_seconds(1500), 1); // rounds down + assert_eq!(ms_to_seconds(1500), 1); // rounds down assert_eq!(ms_to_seconds(999), 0); assert_eq!(seconds_to_ms(1), 1000); diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs index 2e7ae7a2..580bfbdc 100644 --- a/pmocontrol/src/soap_client.rs +++ b/pmocontrol/src/soap_client.rs @@ -274,19 +274,19 @@ pub fn ensure_success_with_envelope<'a>( if let Some(env) = &call_result.envelope { if let Some(err) = parse_upnp_error(env) { return Err(ControlPointError::SoapUpnpParseError( - action.to_string(), - err.error_code, - err.error_description, - call_result.status.as_u16() as u32, - )); + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); } } return Err(ControlPointError::SoapActionWrongBody( - action.to_string(), - call_result.status.as_u16() as u32, - call_result.raw_body.clone(), - )); + action.to_string(), + call_result.status.as_u16() as u32, + call_result.raw_body.clone(), + )); } let envelope = call_result @@ -295,12 +295,12 @@ pub fn ensure_success_with_envelope<'a>( .ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?; if let Some(err) = parse_upnp_error(envelope) { - return Err(ControlPointError::SoapUpnpParseError( - action.to_string(), - err.error_code, - err.error_description, - call_result.status.as_u16() as u32, - )); + return Err(ControlPointError::SoapUpnpParseError( + action.to_string(), + err.error_code, + err.error_description, + call_result.status.as_u16() as u32, + )); } Ok(envelope) @@ -314,7 +314,10 @@ pub fn handle_action_response( ensure_success(action, call_result) } -pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result { +pub fn extract_child_text( + parent: &xmltree::Element, + suffix: &str, +) -> Result { let child = find_child_with_suffix(parent, suffix) .ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?; @@ -327,7 +330,10 @@ pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result Result { +pub fn extract_child_text_allow_empty( + parent: &xmltree::Element, + suffix: &str, +) -> Result { let child = find_child_with_suffix(parent, suffix) .ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?; @@ -368,13 +374,19 @@ pub fn extract_child_text_any( )) } -pub fn extract_child_text_local(parent: &xmltree::Element, local: &str) -> Result { - let child = find_child_with_local_name(parent, local) - .ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?; +pub fn extract_child_text_local( + parent: &xmltree::Element, + local: &str, +) -> Result { + let child = find_child_with_local_name(parent, local).ok_or_else(|| { + ControlPointError::SoapAction(format!("Missing {local} element in response")) + })?; let text = child .get_text() .map(|t| t.trim().to_string()) - .ok_or_else(|| ControlPointError::SoapAction(format!("{local} element missing text in response")))?; + .ok_or_else(|| { + ControlPointError::SoapAction(format!("{local} element missing text in response")) + })?; Ok(text) } @@ -408,7 +420,6 @@ pub fn parse_bool(value: &str) -> bool { value.trim() == "1" } - pub fn decode_base64(input: &str) -> Result, ControlPointError> { fn value(byte: u8) -> Option { match byte { @@ -432,8 +443,9 @@ pub fn decode_base64(input: &str) -> Result, ControlPointError> { if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' { continue; } - let val = - value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?; + let val = value(byte).ok_or_else(|| { + ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)) + })?; buffer = (buffer << 6) | (val as u32); bits_collected += 6; if bits_collected >= 8 { @@ -446,7 +458,6 @@ pub fn decode_base64(input: &str) -> Result, ControlPointError> { Ok(output) } - #[cfg(test)] mod tests { use super::build_soap_body; diff --git a/pmocontrol/src/upnp_clients/mod.rs b/pmocontrol/src/upnp_clients/mod.rs index 257c204d..4101c24f 100644 --- a/pmocontrol/src/upnp_clients/mod.rs +++ b/pmocontrol/src/upnp_clients/mod.rs @@ -1,17 +1,17 @@ -mod openhome_client; mod avtransport_client; -mod rendering_control_client; mod connection_manager_client; - +mod openhome_client; +mod rendering_control_client; pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo}; -pub use crate::upnp_clients::rendering_control_client::RenderingControlClient; -pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; -pub use crate::upnp_clients::openhome_client::{ - OPENHOME_PLAYLIST_HEAD_ID, - OhTrackEntry,OhTrack, - OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient, +pub use crate::upnp_clients::connection_manager_client::{ + ConnectionInfo, ConnectionManagerClient, ProtocolInfo, }; +pub use crate::upnp_clients::openhome_client::{ + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, + OhTimeClient, OhTrack, OhTrackEntry, OhVolumeClient, +}; +pub use crate::upnp_clients::rendering_control_client::RenderingControlClient; /// Resolve a possibly relative controlURL against the description URL. /// diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index 9e8fe81c..82d65fa4 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -25,6 +25,7 @@ axum = { version = "0.8", optional = true } utoipa = { version = "5.3", optional = true } pmoqobuz = { path = "../pmoqobuz", optional = true } pmoparadise = { path = "../pmoparadise", optional = true } +pmoradiofrance = { path = "../pmoradiofrance", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } anyhow = { version = "1.0", optional = true } pmoaudiocache = { path = "../pmoaudiocache", optional = true } @@ -52,3 +53,10 @@ paradise = [ ] # Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP) paradise-api = ["paradise", "pmoparadise/pmoserver"] +# Feature pour activer le support Radio France +radiofrance = [ + "api", + "dep:pmoradiofrance", + "pmoradiofrance/server", + "dep:pmoconfig" +] diff --git a/pmomediaserver/src/lib.rs b/pmomediaserver/src/lib.rs index 074f0a7a..a47669d9 100644 --- a/pmomediaserver/src/lib.rs +++ b/pmomediaserver/src/lib.rs @@ -93,3 +93,6 @@ pub use paradise_streaming::ParadiseStreamingExt; // Re-export sources when features are enabled #[cfg(feature = "qobuz")] pub use pmoqobuz; + +#[cfg(feature = "radiofrance")] +pub use pmoradiofrance; diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index bd7714fe..61adc5e9 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -18,6 +18,10 @@ pub enum SourceInitError { #[error("Failed to initialize Radio Paradise: {0}")] ParadiseError(String), + #[cfg(feature = "radiofrance")] + #[error("Failed to initialize Radio France: {0}")] + RadioFranceError(String), + #[error("Configuration error: {0}")] ConfigError(String), @@ -117,6 +121,25 @@ pub trait SourcesExt { /// ``` #[cfg(feature = "paradise")] async fn register_paradise(&mut self) -> Result<()>; + + /// Enregistre la source Radio France + /// + /// Cette méthode crée automatiquement un `RadioFranceSource` avec cache activé. + /// Radio France ne nécessite pas d'authentification. + /// + /// # Erreurs + /// + /// Retourne une erreur si : + /// - La connexion au client Radio France échoue + /// - La feature "radiofrance" n'est pas activée + /// + /// # Examples + /// + /// ```ignore + /// server.register_radiofrance().await?; + /// ``` + #[cfg(feature = "radiofrance")] + async fn register_radiofrance(&mut self) -> Result<()>; } #[async_trait::async_trait] @@ -217,6 +240,35 @@ impl SourcesExt for Server { Ok(()) } + + #[cfg(feature = "radiofrance")] + async fn register_radiofrance(&mut self) -> Result<()> { + use pmoradiofrance::{RadioFranceSource, RadioFranceStatefulClient}; + + tracing::info!("Initializing Radio France source..."); + + // Obtenir l'URL de base du serveur + let base_url = self.base_url(); + + // Créer le client stateful depuis la config + let client = RadioFranceStatefulClient::from_config() + .await + .map_err(|e| { + SourceInitError::RadioFranceError(format!("Failed to create client: {}", e)) + })?; + + // Créer la source depuis le registry (avec cache) + let source = RadioFranceSource::from_registry(client, base_url).map_err(|e| { + SourceInitError::RadioFranceError(format!("Failed to create source: {}", e)) + })?; + + // Enregistrer la source + self.register_music_source(Arc::new(source)).await; + + tracing::info!("✅ Radio France source registered successfully"); + + Ok(()) + } } #[cfg(test)] diff --git a/pmoqobuz/src/models.rs b/pmoqobuz/src/models.rs index 5de0baf1..2eaf955a 100644 --- a/pmoqobuz/src/models.rs +++ b/pmoqobuz/src/models.rs @@ -270,11 +270,7 @@ impl Album { pub fn formatted_title(&self) -> String { if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) { // Convertir Hz en kHz, en gérant les valeurs qui pourraient déjà être en kHz - let rate_khz = if rate > 1000.0 { - rate / 1000.0 - } else { - rate - }; + let rate_khz = if rate > 1000.0 { rate / 1000.0 } else { rate }; format!("{} ({:.1} kHz / {} bits)", self.title, rate_khz, depth) } else { self.title.clone() diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml index 768fa6a9..19666ff5 100644 --- a/pmoradiofrance/Cargo.toml +++ b/pmoradiofrance/Cargo.toml @@ -55,6 +55,12 @@ pmoaudiocache = { path = "../pmoaudiocache", optional = true } # Playlist management for FIFO support pmoplaylist = { path = "../pmoplaylist", optional = true } +# Server integration (optional) +pmoserver = { path = "../pmoserver", optional = true } +pmoupnp = { path = "../pmoupnp", optional = true } +axum = { workspace = true, optional = true } +futures = { workspace = true, optional = true } + [features] default = ["pmoconfig"] # Feature for pmoconfig support @@ -65,8 +71,8 @@ cache = ["dep:pmocovers", "dep:pmoaudiocache"] playlist = ["dep:pmoplaylist", "dep:pmodidl"] # Feature for logging (tracing) logging = [] -# Feature for server support (cache registry) -server = ["pmosource/server", "pmoconfig", "cache", "playlist"] +# Feature for server support (MusicSource + HTTP API routes) +server = ["pmosource/server", "pmoconfig", "cache", "playlist", "dep:pmoserver", "dep:pmoupnp", "dep:axum", "dep:futures"] # Full feature set full = ["server", "logging"] diff --git a/pmoradiofrance/assets/create_logo.sh b/pmoradiofrance/assets/create_logo.sh new file mode 100755 index 00000000..fcde7486 --- /dev/null +++ b/pmoradiofrance/assets/create_logo.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Create a simple PNG first, then convert to WebP +# Since we don't have image tools, we'll create a minimal valid WebP file + +# Create a minimal 1x1 red WebP image (Radio France red: #e20613) +# This is a hex dump of a minimal WebP file +cat > radiofrance-logo.webp << 'WEBP' +UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA= +WEBP + +# Decode from base64 +base64 -d -i radiofrance-logo.webp > radiofrance-logo-tmp.webp 2>/dev/null +mv radiofrance-logo-tmp.webp radiofrance-logo.webp 2>/dev/null || true + +echo "WebP placeholder created" diff --git a/pmoradiofrance/assets/radiofrance-logo.jpg b/pmoradiofrance/assets/radiofrance-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..23459b7f9c1239f01d7beed954a43e4b2982ecb0 GIT binary patch literal 6877 zcmeHLcUTi!uwMue#DXYTK+LsZp@@KhA`%r50TEFFrR6FrB3DoZ=~BMH ziWotPD6mqaBGS8*Bp}@+l#sI7eQUYpz3={UuYbP@XPKREclON8nR9+KXgkUP-kSw@ zoCE-CYd{?U09imrVgZ1|I0@_rNGJo+-*^D9mr(hWca>Q8;~7Z+kX-oJ^HCmv)Q{&e zo!@BBkNfZc@xKEA9+OCI)Ya9Allaa{NaBEdf5>$~FTL zjO?HCf6buX*c|22H-LgHzyKIHiIsq)f&@-M0i=<#GDFuipj# zfWV-z@QBE$=qoWdZpJ4hCf!O-NlVXo@G$ey<0rW<^70D`UlzS8uc)l5roVmnzOkvf zrM0cSqm#j8^$&a=92y?sPE1Zs^Je%0Ar`0a=3v+F7X8g01s07vPVPlMc|)teY<+^;5mv*_O$bor+& z`qQ94?Lj+%IXDR{U^oSU2p}WHEBa7i7Fe$KfHD?K{55ko6CTh7{aUfV>I?l4U)_AIb|WgP;WBTCH;+m{u> zD|!~7z&$gnm`{P%p+NZ7a?+IS3lxA9P$0r@)BgXT$A9<7019AkH%Bh=NHa|JuW_S@ zCB`6jrCqb_b^=DxaH9$~*+h_d z>rPRHW^)6Io{^U6MyX>q-{-Sqm#N10w}+_hEl65*`$o{ap&Nyj-KFMD&9-I%naoRO ziu)!0Ht^cm4IXX$>4-$J9Kotou6HRV6a~^8p}OEI(!6t|AvfoE4brN6Eb-dYE}c;F~!?=t{zAo~W~ENfwfq7Ha$m70sEWCN+{6&APohE74?ymj@1biBdE!WXNm$h z9rzw7a4a?!<4|A`*shEMD6p#ploQ4;p?#sUoV4NfHjBab+MidzH8PZvk^Eqct&Xek2&$d4b*4(R@E(p3{!d3{6)Q!?Qwch-JFz5oYl-&*? zayLbqNSBV;JwASa=&Gt7E8|ktR)a2*0-^U*;KZO!0EJM!@Bum1LEsXaDy&B4?ks0o zX>W?QVKeV9a~@5;Us_zfr(2R#_*4)h*vniw06bY1%Dmaib#&oz}4C-$(lON&>q z_ndsue>rB|J;@(qo-4-4qs>1ZJ&<(?*H;*OX*~Tj4h4QG0VO-n%)nNCG$xb4NP%-YHdj$Z+Y-&*u#>aOHTrCWHQvPJYTrnsnS>HobwIi z_5eTo8{$r5^)u>4T2`hszuT72ye2Ja_cO1)FkZ>$I*)|FH_&3ts87_{D7hg-%-!@!OXVu%+DoT2NDr_p;POC$K>k!A9 z-678lpUjtE#6K`s<48RC)@e6}UP^S&O}^f3T!aGS%q{qQ@R=qV8C%s1jms4yM*z2!-6T2ppF+3)HUf*G!I2)fMPUxG7 z&1Eh{f#WAz4_DG=shzOZYI54`;hAP>DO{7!C)n1DHLeslX0=asbg)-k0rQz$X7v37cUVC_)?_M&E=aS@oNI6grDL3$#5gMgteUE-eNa7{V@S>%U$^a0oV!jsQgTrk7(w zRg{zY{4ZjuiQta^^YUKSmk+IX7CU##X%Q4Qeua%Hkm;8G*V?W8|iGwi%i zFogLpH$*O%-zA#Muh-l$O_Z&` zs*E+MER{xUqVD@FieT9LbJf>4Hp@+tVisL+=$-2)QfUxfnW}l&j{;{aD+G2sI#S!_ ze$My#(2Kui%YS?*y!3P4RIq+K3T)}dSGi<$`>LKFZ{hBy1Ya{zp1zB(JeC?JW6*OM_^~BRIFB}{fynAT3=OLU7dL<{w?ej1 z;71Nqp03E5=uFaLqh{)B4PqJL&2rsehmXFb6ZTOmM1@$O4un&gL|rxQm7>IB&|R`fwCLOCdgYz!DPYVooh>5 zrmS*~60*lkKVXR9?c0Q*6PqJUm+sE$uv|+%c;#@2pB($tdtwUlBmA-0Cs(k!8j?-*!1Q1rxlO>K<&68FbhF}`)p6V6|ZR*A5B?(!1&NE5OWDR(4Y z?)j~Uzv>Xw=#FRqc7v3Ug&9!TmqVG+;H5%g1iqThc)Ev3ncVOsr*_{VIcXWKODhe4pMQ0*21A>_uCRJ5=^_f` zm{)o)Pu_L#S*!{5xOtE01ajtAdZWdG(s5ce_#2_UiIF*;&6ul)g}$o9^kCSYrZ1&E zzcjg9fhNDEI_fP{A#_Kcxfg^eHH7m{=c$q+^g+`u#*>$FM+z1dob0S9KNmmtOmOT= zqocPMDH*wt80wk+qLemPgRBsW!=OP{6^XA)L4m6+6#iMl*a{RNE>}l^vKt^bMP%$1 zoYZ5SdMaX!Hy{)XZ+cn1`aMgblj zE90ab>vx&V=7+It1Y)RZ4AZf|5IZTnG-eD+-NhI@b09w%gzwdiJMN>f#yNsLU>xpIDn=G;0}_vROOL`t_9?6J>nT2mo8F)^=(GwiWa z!{O+}(XaKz_afszm6gPgGl-nG$j}A(KrEnNEyib>4GeW=*K_B^c9_r~?U^{Uqw78O z!^L1T624+4OZ0WSZbT{_DdR^(TeT|*k%>evF1>=WtvlY9(&O6x9^aY0uywEcJ6ZPX zohDL|P}D7VTF*;|P1mDq~P4e7k)Dj(INE6hnjM#LmRx)+uhOZm@y2qlgi z>s(t7Yi%GW#h>)~Y;jv(1Y(Z-gnjglCzv}PkyJ+Nl=?JUDj7C&!9OQtCdJq-4fAYb zYdV6VoTpdM5t2nOM>(vIPlr7F`H^?=5!6MrK7U}i(Kb|V zaRaR=TiGKIe2`SezSb*evW>r!uq;HmZ45TgsCkq6e!ED8;MewKBf`4q*)Lr zba1vl3h=EEATZoAJI(osNjbn1K=k#m@W)^f@ zcfelTDk-X95k$>N<}HdaUz|HeR;eVKX@0!spm|eupRZ88Fgz)Nu{3j}**H$z+dwKG zyaM&*Q8~8Yg3iz+8?ImCbr8z*Fc47?DYI?lPOxkaSqHY~w{g<3YTaZbKWRUAO@^^$ z4*eWRyqjb4PQFGtw7q#5V_{5zLGR)Kh19b>Rt`oVvKj}%Vg|zE zsl|=Ro~qD6cBrDQE0_QK-|DkJYPBEm@W1sG5Ufen_oA1xS8UZsxJo0Qrd(=W$}xb> ze;z4F%;Bc@*1)cOn%PsOmS2@$K5J#l zdKtR8Ipg>1r7dH}lUtl$EXX?8x>dh{HtIc5Kjrh`{X;oger7?fKdF^22&|ZS@k;B^+_#Csz*-(P*v0_I|L=A{*>N0VfRgV;k-^JR@FT z^l-Lb1?!PI-@}Eh@s5YQIxMb<(uvQB(Oqk7hQMK|-TMa}(>MGz9i}5tKxPztR|;cYznm0p@rujP zO-Cdb?CS;vhV1BGo*s5gXKZxHD(_`;r@%PAccU6uEgypdO4{Nr3x#Vjlr`-Jv;L_i z^rPkT-y00@GVx2O?+Q^8I(6;94Ac8fM>-#5EuDF%Zc2bHy+8kU;^Ac-QL;R-Bo-@a z;w(L9uE&hM`Ml(+s*Pt9RRbp6blMcO)XO90jlCV`=D-aR6%&BBXT}Xh^N?m@Ze)5P ziyH2wIab`T%rU!A=$dR26zH!bt~=E~k%0ma{U&!O_dv}uBI_5;W5=I2udq0J>o?zX zMMua9f(UL6A}bKdz~?MzgPgORhnYs)V8&x&ZpR)(d(Fl($t67tU7UB^wlb&)HmbL@ zD(Lk$6*gToxfx)*TzQG6Xx6%}@y3=A#+qzw+buGLUlGdTZIIjzC=*l#%U-2mo0(xb zF{aJn!r+&#QKRGj-Y5XxOYB7fGl$wvHmt%B9cc$bG?;AJ5mSV^wm1(38ZO*yW8V6x zT3zjMO+)Ae*L87Bwr~tKtgq0$O7hrw7Ir=W+mxmJLs-j%Yhe|U=tOtTKtNR0^108W z@_u*jbtx22@P3zXg?ymC6w-4_H1sPJi4}`s#uiK!aK3f+NY*1kV!ee|+%ZLpd)=x< zqSBs(p|hV4UQNGx%`xweo4bIJ6fnRf%{h5zeRNIkCtr2@_{aH6CbzBH)bqsnmA2lr z0S1T~Y#8-vG3$W;GjK_m0Qd9Vmw#G{f0zAOmOtGe_`9xu;(zFGL55*ftXD`*#yY8q aC?HgSl`>HLe|kR6 literal 0 HcmV?d00001 diff --git a/pmoradiofrance/assets/radiofrance-logo.webp b/pmoradiofrance/assets/radiofrance-logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..4c1687cfc12d3532f7775db7eb7dd0673833030e GIT binary patch literal 3680 zcmYM1cRbXOAICpuugoMf*?Y@Mmzj}>>~V=4$|e#Hafpa?XCE>`I(v`IgyO7>>ulM3 zkGr4W_xt_*zVGpPKi}`i>+$;kZLF)UZ9xkF=344TW=4t@Q~&@_T=vXgb$Q|TyaE8* znm{Hz?8QoPCi9Jd@U_R|DeI_M6yk=!sQ(HisPzkMh!D10 z@`d)(?ycPc46KFVanVT-BoNl!##|;VIf0oQ>Qi?BP>TN8q8L+xQ=_Ny5$bYTpvXugRcNH2fTWe1d&2hMFnQ zts6QC-IpcMtwB%e1TC5fq9^_r{tLwCgx%?cJL`A^!Ef{CdF?6cWMG5P44!Ry8XOuj zbw(3X7Nz^c65=+^cAM>V}#LT;u9!nw?|LA5g$Q+9lR1&C`BWk!c;AYx!0T4g&uiojRjG(3R z>-eEsoo4D?;esr(mDo?e*&;+4e=B`DI9Ms=+}s}gMGRm5Oux8obdEn#4NtN%KVxVz zphzRH;PKe=I>FT?KjSq!r5Lp|eT%sdzjI@BVg2WIzg~myQFP#iLDxF{Z|nRT`QEgf zTJ5W!u1N?UV!qBA&#ek%=2{l>+VD8`y(#R$T*)f*OzKOF&Uinbb75dr(P>p2?}h0_ zE_=NP8$HNC*>>>(f?*vI2zHdw{jL1g#Ju5_&D1hePXJHA2a&#A7u;W2Ja`W!=!csIqL@wFFri`N-t(WnEFl#=P!9lU7MSZ-P+X& z6gQ93xOQ*XTwQSy1=+4vlqr5GxuG>fQ`pm5atigt?JI2ql2UH_UEVFR)yXR7Xd&MUmh#3T)yLLkP-PJbN!S^E8=JF} zv;AslfJ21N?1U-CT;{b$3VQDFd*f?Ov|WpXvcAnHNB74V`MFMbp3BEu1NRVd(mfs? znPHTuqgmnA!_lx-Ba*O#oaW;QkSe~afi%6i8+vbrufWpqF@$5Pl`-7f9LaBgO<_Nn zoDKKx_e1+lZ(ku41P~^k7r?J>C?OoYe25@ad$SJ*$?_qks2VMgaN(`}_`m<3LMjXL`j zsietj%lw=5$qMhHzxAE0`VtKAtEsO*d+9q-votr#bD258f$l80B#Qg7pDTX6lOf3` zc_{T7TH?$>o@xa@8#C`s&!n|gJi(M$AA|W6#^)VadHxW$YHIr3uMjT-k0uoU za8bFro9&?c##D>w7FLyaC817-dScd3 z`9;ppPA4d9yJ#B?ulS+ONPO@B`uV{gwPeM#w!UnKPIDk`nkvCOu$jPk{JA_a*#9i& zHKkjQ;Vdf=^oFBCADLL-)9DmsVF?sIb)&H9#2n_ivnkl5het^;9?RwpipRfJyQ;}s zU#yT+X%sHTmu-3i4HrZeiS%b;A900_p84u=DO!tVT9KA1XPE{{?Uwu$1HI`C1#?5D zfk_efajK{?nd22^Hc!f$E+;`AW@&dDlC^=TEfG_^ZI{HH%7dS(>rOFr3O z|M&TI>0TYbr!Js;t+M5bOyjwM5D~nO?JUi?ybBkDFX!w`h4#*U`CKz76N? z(juu3SD6$OtQ+2%_s=+78-lr?JybSbJl9KDtY9fO*?Ikv76?1K>vO>cSp@`;iwe+|$lZY2|g!#w&w9B+@ zm^qEHdcHmZgZ3Z+8#BDG$>uxHp6_fhKiEM-ZLxYQt7GS~J~~H!tl!aU7+c^l=X;gQ z@Fz$NiHIUpnN*(Zd#G?opa>^kp=M{MWZCvA*=|?Mh>s1Z=b1@5kZi}x&s)iF3I-@1 zC6E8~F=k;eKetvL?vG0dOpulS&`s)oTLy2O^_C!N%8b*#a0gwXl%14XMD9#3gp^2h zjZiC4ZXGT-=e?sFZbQ_;rV|1mhAT!kJ13O1|B6KH4>RwOo-#cMhE& zjL%v=ez6CUGoBIDee+oQ`q$Qg_t0!ub;#(dldD~-6*yOp`r=Epq^9NqA{X_cU}tc) z)*^JJxS;V%V!FFY-Fx<{k=3M_yRmCDOTu=_{rEC1bVXO3S=It&kC;gQFJsk&;D-W( zkDR8l;|=+8chDLQX?+fD+V(H)l0P*2Z<4&qT<;)C%N1By+P*8tf{RzJaBAk_7F}|D zHDZv4|KQ%qLsF6GoV)l<{fcm&>;&bYkq?UZc4x0qxe~jP>aA+|x%U{gjMq)uAMD`OtR2~KN4xdS;iQD}K{>>w+r z*nF74r8}d0euO^5j3iptj*SHOO-HvC>|?2jh!$p>g(IVPy|Z{Va$Iig9IWd&1hTP#jNK)yki~>tfquXm=kDeU@J-*HEyuR_``i zcGK=_a1_VH5M6^(-uoqCSk%_GwlU02EupA{+0YH8Vrj@g5#Zq+{>V3qQ_GJlS@Gp2 zl4)w7CNc$IThv;?S?VyJDaJL*FZ-Tg3Qe@S5f}hR-fS09QnSib$;)7-@A63=0K|Uv z#=H=9Eo0veVc{;2KOvhM^FHTOlI(#)X;)RK4CIIegdODQjyu1O(bsV{MqfY=69p`u zye_Z-&S zMkCLNKSs>KiZSZdXUz%`eYB8)g@nOGUj{An#qAi{u331DOdQ4b4>z{WTH9mcQ%p3* zTK?8SwWNIO3s|xTDLUqPk#gn>dnvk_pTv!*g|!bnRon|L5aPY8>9!mb)*#tGB`1pS zD;KQ59Oz&*y_aP2w=J7vXom*2F{fcE=1VEs49|pH^ze=uE?%oFF|{yZ9^qr4JZ2T3@v(DiY*!m-14IO&-;p+TNSKH=zEaj3@h28 zTHK~CRsYNk-A($VP)4ZTit6U7n5`Z89G1#9x?~s8?m+m#z(py4=(ud)Bh3B$hF;*O zr&+6J@gAZ{r(p%!!IIF)&sXWY6rmD94te#ABE9v;M^6ISPbj3gKTCH4z$@Vd7t6N3 z$*t2-hBzU5Aw#juk8qJVzTA}Mb0An!= zDYP1gY#1M|-!PA`>akldWU{Fw<%Jf}2Q0Cp1)9I=dnhf%k$B?BuH?_CDlui6wfB%S zU(qVVRq96k%674io7mV;eH0NIfj{|fjpZ|8&AnpVG{KQ`lncSd^fri(tS~l5^99Z* z5?feC{W>bo9RbQHo2JNTvl7`Jl_I2Gk7RUaZsk5m3+k7NJuFpOKG0Aye6>2YBIi`d zStDK84}R-jRbyQyuYXEMcI_5+=18>nyrPl&Okln8Zfa$E>3t7{0j}GB&I+k!rER6^ zNy563b&+LWA9Rx5iOq9a3xkqvMeZ^WLb`l?Um1{Tk|&?o0`$pxE4RbJn*DIaF8Q65yz|>PgvnqgR^{!39XPlqAzo&$i-?AB) zG8@pc(A93g{K5|6n@#*L N=aK!RxitGb{sa9*`I!I! literal 0 HcmV?d00001 diff --git a/pmoradiofrance/src/api_rest.rs b/pmoradiofrance/src/api_rest.rs new file mode 100644 index 00000000..7507c7e4 --- /dev/null +++ b/pmoradiofrance/src/api_rest.rs @@ -0,0 +1,126 @@ +//! Endpoints API REST pour Radio France +//! +//! Ce module définit les handlers HTTP pour accéder aux stations Radio France, +//! leurs métadonnées live et les flux de streaming. + +use crate::models::LiveResponse; +use crate::playlist::StationGroups; +use crate::pmoserver_ext::RadioFranceState; +use axum::{ + body::Body, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use futures::StreamExt; +use serde_json; + +// ============ Gestion des erreurs ============ + +struct AppError(String); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self.0.as_str() { + "not_found" => (StatusCode::NOT_FOUND, self.0), + "internal_error" => (StatusCode::INTERNAL_SERVER_ERROR, self.0), + "bad_gateway" => (StatusCode::BAD_GATEWAY, self.0), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0), + }; + + let body = Json(serde_json::json!({ + "error": message + })); + + (status, body).into_response() + } +} + +impl From for AppError { + fn from(err: String) -> Self { + Self(err) + } +} + +/// Crée le router pour l'API Radio France +pub fn create_router(state: RadioFranceState) -> Router { + Router::new() + .route("/stations", get(get_stations)) + .route("/{slug}/metadata", get(get_metadata)) + .route("/{slug}/stream", get(proxy_stream)) + .with_state(state) +} + +// ============================================================================ +// Route Handlers +// ============================================================================ + +/// GET /api/radiofrance/stations +/// Returns the grouped list of stations +#[axum::debug_handler] +async fn get_stations( + State(state): State, +) -> Result, AppError> { + let stations = state + .client + .get_stations() + .await + .map_err(|e| AppError(e.to_string()))?; + + let groups = StationGroups::from_stations(stations); + Ok(Json(groups)) +} + +/// GET /api/radiofrance/{slug}/metadata +/// Returns live metadata for a station (with caching) +async fn get_metadata( + State(state): State, + Path(slug): Path, +) -> Result, AppError> { + let metadata = state + .client + .get_live_metadata(&slug) + .await + .map_err(|e| AppError(e.to_string()))?; + + Ok(Json(metadata)) +} + +/// GET /api/radiofrance/{slug}/stream +/// Proxies the AAC stream from Radio France (passthrough, no transcoding) +async fn proxy_stream( + State(state): State, + Path(slug): Path, +) -> Result { + // Get the stream URL + let stream_url = state + .client + .get_stream_url(&slug) + .await + .map_err(|e| AppError(format!("Stream not found: {}", e)))?; + + // Connect to the Radio France stream + let response = reqwest::get(&stream_url) + .await + .map_err(|e| AppError(format!("Failed to connect: {}", e)))?; + + if !response.status().is_success() { + return Err(AppError(format!("Upstream returned {}", response.status()))); + } + + // Build response headers + let mut headers = HeaderMap::new(); + headers.insert("content-type", "audio/aac".parse().unwrap()); + headers.insert("cache-control", "no-cache".parse().unwrap()); + + // Create streaming body + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + + let body = Body::from_stream(stream); + + Ok((headers, body).into_response()) +} diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs index cf9e3fe9..c0da4680 100644 --- a/pmoradiofrance/src/lib.rs +++ b/pmoradiofrance/src/lib.rs @@ -101,6 +101,18 @@ pub mod stateful_client; #[cfg(feature = "playlist")] pub mod playlist; +#[cfg(feature = "server")] +pub mod source; + +#[cfg(feature = "server")] +pub mod pmoserver_ext; + +#[cfg(feature = "server")] +pub mod pmoserver_impl; + +#[cfg(feature = "server")] +pub mod api_rest; + // Re-exports pub use client::{ClientBuilder, RadioFranceClient}; pub use error::{Error, Result}; @@ -117,3 +129,9 @@ pub use stateful_client::RadioFranceStatefulClient; #[cfg(feature = "playlist")] pub use playlist::{StationGroup, StationGroups, StationPlaylist}; + +#[cfg(feature = "server")] +pub use source::RadioFranceSource; + +#[cfg(feature = "server")] +pub use pmoserver_ext::{RadioFranceExt, RadioFranceState}; diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs index dcf9e099..c577fb04 100644 --- a/pmoradiofrance/src/models.rs +++ b/pmoradiofrance/src/models.rs @@ -110,7 +110,7 @@ impl Station { // ============================================================================ /// Response from the /api/live? endpoint -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LiveResponse { /// Station name (slug) @@ -134,7 +134,7 @@ impl LiveResponse { } /// Metadata for a show or track currently playing -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ShowMetadata { /// Whether to display music program info @@ -174,7 +174,7 @@ pub struct ShowMetadata { } /// A line of text with optional link -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Line { /// Text content pub title: Option, @@ -192,7 +192,7 @@ impl Line { } /// Song information (for music stations) -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Song { /// Song UUID pub id: String, @@ -214,7 +214,7 @@ impl Song { } /// Album/release information -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Release { /// Record label pub label: Option, @@ -225,7 +225,7 @@ pub struct Release { } /// Available media streams -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Media { /// List of available stream sources #[serde(default)] @@ -270,7 +270,7 @@ impl Media { } /// A stream source with URL and format info -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StreamSource { /// Stream URL @@ -284,7 +284,7 @@ pub struct StreamSource { } /// Type of broadcast -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum BroadcastType { /// Live stream @@ -294,7 +294,7 @@ pub enum BroadcastType { } /// Stream format -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum StreamFormat { /// MP3 format @@ -317,7 +317,7 @@ impl StreamFormat { } /// An embedded image -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmbedImage { /// Model type (usually "EmbedImage") @@ -348,7 +348,7 @@ impl EmbedImage { } /// Visual assets for different display contexts -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Visuals { /// Card-sized image pub card: Option, @@ -357,7 +357,7 @@ pub struct Visuals { } /// A local France Bleu radio station -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalRadio { /// Internal ID diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index aa409c46..42f273fd 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -30,6 +30,7 @@ use crate::error::Result; use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat}; use pmodidl::{Item, Resource}; +use serde::{Deserialize, Serialize}; #[cfg(feature = "cache")] use pmocovers::Cache as CoverCache; @@ -46,7 +47,7 @@ use std::sync::Arc; /// - `standalone` : Stations sans webradios (France Culture, France Inter, France Info, Mouv') /// - `with_webradios` : Groupes avec station principale + webradios (FIP, France Musique) /// - `local_radios` : Toutes les radios ICI (ex-France Bleu) -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct StationGroups { /// Stations sans webradios associées pub standalone: Vec, @@ -57,7 +58,7 @@ pub struct StationGroups { } /// Groupe station principale + webradios associées -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct StationGroup { /// Station principale (ex: FIP) pub main: Station, diff --git a/pmoradiofrance/src/pmoserver_ext.rs b/pmoradiofrance/src/pmoserver_ext.rs new file mode 100644 index 00000000..aa1f7bd1 --- /dev/null +++ b/pmoradiofrance/src/pmoserver_ext.rs @@ -0,0 +1,86 @@ +//! Extension pmoserver pour Radio France +//! +//! Ce module fournit un trait d'extension pour ajouter l'API Radio France +//! à un serveur pmoserver. + +use anyhow::Result; +use std::sync::Arc; + +use crate::stateful_client::RadioFranceStatefulClient; + +/// État partagé pour les handlers Radio France +#[derive(Clone)] +pub struct RadioFranceState { + pub client: Arc, +} + +impl RadioFranceState { + pub fn new(client: RadioFranceStatefulClient) -> Self { + Self { + client: Arc::new(client), + } + } +} + +/// Trait pour étendre pmoserver avec les fonctionnalités Radio France +/// +/// Ce trait permet à `pmoradiofrance` d'ajouter des méthodes d'extension sur +/// `pmoserver::Server` sans que pmoserver dépende de pmoradiofrance. +/// +/// # Architecture +/// +/// Similaire au pattern utilisé par `pmoqobuz` avec `QobuzServerExt`, ce trait permet +/// une extension propre et découplée : +/// +/// - `pmoserver` définit un serveur HTTP générique +/// - `pmoradiofrance` étend ce serveur avec les fonctionnalités Radio France via ce trait +/// - Le serveur n'a pas besoin de connaître `pmoradiofrance` +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmoradiofrance::RadioFranceExt; +/// use pmoserver::ServerBuilder; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let mut server = ServerBuilder::new_configured().build(); +/// +/// // Initialise le client Radio France +/// server.init_radiofrance().await?; +/// +/// server.start().await; +/// server.wait().await; +/// Ok(()) +/// } +/// ``` +pub trait RadioFranceExt { + /// Initialise l'extension Radio France et enregistre les routes HTTP + /// + /// Cette méthode : + /// - Crée un client stateful Radio France + /// - Configure les routes API pour les stations et métadonnées + /// - Configure le proxy streaming pour les flux AAC + /// + /// # Returns + /// État partagé de Radio France + /// + /// # Routes enregistrées + /// + /// - `GET /api/radiofrance/stations` - Liste groupée des stations + /// - `GET /api/radiofrance/:slug/metadata` - Métadonnées live d'une station + /// - `GET /api/radiofrance/:slug/stream` - Proxy du flux AAC + /// + /// # Exemple + /// ```ignore + /// use pmoserver::ServerBuilder; + /// use pmoradiofrance::RadioFranceExt; + /// + /// let mut server = ServerBuilder::new_configured().build(); + /// server.init_radiofrance().await?; + /// ``` + async fn init_radiofrance(&mut self) -> Result>; +} + +// L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs) +// pour éviter les dépendances circulaires diff --git a/pmoradiofrance/src/pmoserver_impl.rs b/pmoradiofrance/src/pmoserver_impl.rs new file mode 100644 index 00000000..68017838 --- /dev/null +++ b/pmoradiofrance/src/pmoserver_impl.rs @@ -0,0 +1,59 @@ +//! Implémentation du trait RadioFranceExt pour pmoserver::Server +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités du client Radio France +//! en implémentant le trait [`RadioFranceExt`](crate::RadioFranceExt). +//! +//! ## Architecture +//! +//! `pmoradiofrance` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoradiofrance`. +//! C'est le pattern d'extension : `pmoradiofrance` ajoute des fonctionnalités à un type +//! externe via un trait, similaire au pattern utilisé par `pmoqobuz` pour `QobuzServerExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmoradiofrance::RadioFranceExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Le trait RadioFranceExt est automatiquement disponible +//! let state = server.init_radiofrance().await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::api_rest::create_router; +use crate::pmoserver_ext::{RadioFranceExt, RadioFranceState}; +use crate::stateful_client::RadioFranceStatefulClient; +use anyhow::Result; +use pmoserver::Server; +use std::sync::Arc; +use tracing::info; + +impl RadioFranceExt for Server { + async fn init_radiofrance(&mut self) -> Result> { + info!("Initializing Radio France API..."); + + // Créer le client stateful + let config = pmoconfig::get_config(); + let client = RadioFranceStatefulClient::new(config) + .await + .map_err(|e| anyhow::anyhow!("Failed to create Radio France client: {}", e))?; + + // Créer l'état partagé (RadioFranceState est Clone et contient déjà un Arc) + let state = RadioFranceState::new(client); + + // Créer et enregistrer le router + let router = create_router(state.clone()); + self.add_router("/api/radiofrance", router).await; + + info!("Radio France API initialized"); + info!("API endpoints available at /api/radiofrance/*"); + + Ok(Arc::new(state)) + } +} diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs new file mode 100644 index 00000000..4b047066 --- /dev/null +++ b/pmoradiofrance/src/source.rs @@ -0,0 +1,595 @@ +//! MusicSource implementation for Radio France +//! +//! This module implements the `MusicSource` trait from `pmosource` for Radio France, +//! providing UPnP/DLNA integration with dynamic container generation. + +use crate::error::Result; +use crate::models::Station; +use crate::playlist::{StationGroup, StationGroups, StationPlaylist}; +use crate::stateful_client::RadioFranceStatefulClient; +use pmoconfig::Config; +use pmodidl::{Container, Item}; +use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, SourceCapabilities}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; + +#[cfg(feature = "cache")] +use pmocovers::Cache as CoverCache; + +#[cfg(feature = "server")] +use pmoupnp; + +/// Default image for Radio France source +const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofrance-logo.webp"); + +/// Radio France music source +/// +/// Provides access to ~70 Radio France stations via UPnP/DLNA with: +/// - Dynamic container generation based on station structure +/// - Automatic metadata refresh for active streams +/// - Hierarchical organization (standalone, groups, local radios) +pub struct RadioFranceSource { + /// Stateful client with automatic caching + client: RadioFranceStatefulClient, + + /// Cache of playlists by station slug (volatile metadata) + playlists: Arc>>, + + /// Background tasks for metadata refresh + refresh_handles: Arc>>>, + + /// Cover cache (optional) + #[cfg(feature = "cache")] + cover_cache: Option>, + + /// Server base URL for cover URLs + server_base_url: Option, + + /// Update counter for change tracking + update_id: Arc>, + + /// Last change timestamp + last_change: Arc>>, +} + +impl RadioFranceSource { + /// Create a new Radio France source + /// + /// # Arguments + /// + /// * `config` - Configuration for the client + /// + /// # Example + /// + /// ```no_run + /// use pmoradiofrance::RadioFranceSource; + /// use pmoconfig::get_config; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let config = get_config(); + /// let source = RadioFranceSource::new(config).await?; + /// Ok(()) + /// } + /// ``` + pub async fn new(config: Arc) -> Result { + let client = RadioFranceStatefulClient::new(config).await?; + + Ok(Self { + client, + playlists: Arc::new(RwLock::new(HashMap::new())), + refresh_handles: Arc::new(RwLock::new(HashMap::new())), + #[cfg(feature = "cache")] + cover_cache: None, + server_base_url: None, + update_id: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(None)), + }) + } + + /// Set the cover cache + #[cfg(feature = "cache")] + pub fn with_cover_cache(mut self, cache: Arc) -> Self { + self.cover_cache = Some(cache); + self + } + + /// Set the server base URL for cover serving + pub fn with_server_base_url(mut self, url: impl Into) -> Self { + self.server_base_url = Some(url.into()); + self + } + + /// Create a new Radio France source from the cache registry + /// + /// This is the recommended way to create a source when using the UPnP server. + /// The cover cache is automatically retrieved from the global registry. + /// + /// # Arguments + /// + /// * `client` - Radio France stateful client + /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080") + /// + /// # Errors + /// + /// Returns an error if the cover cache is not initialized in the registry + #[cfg(feature = "server")] + pub fn from_registry( + client: RadioFranceStatefulClient, + base_url: impl Into, + ) -> Result { + #[cfg(feature = "cache")] + let cover_cache = pmoupnp::cache_registry::get_cover_cache(); + + Ok(Self { + client, + playlists: Arc::new(RwLock::new(HashMap::new())), + refresh_handles: Arc::new(RwLock::new(HashMap::new())), + #[cfg(feature = "cache")] + cover_cache, + server_base_url: Some(base_url.into()), + update_id: Arc::new(RwLock::new(0)), + last_change: Arc::new(RwLock::new(None)), + }) + } + + /// Start metadata refresh task for a station + async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { + let mut handles = self.refresh_handles.write().await; + + // If already running, do nothing + if handles.contains_key(station_slug) { + return Ok(()); + } + + let client = self.client.clone(); + let playlists = self.playlists.clone(); + let slug = station_slug.to_string(); + let update_id = self.update_id.clone(); + let last_change = self.last_change.clone(); + + #[cfg(feature = "cache")] + let cover_cache = self.cover_cache.clone(); + #[cfg(feature = "cache")] + let server_base_url = self.server_base_url.clone(); + + let handle = tokio::spawn(async move { + loop { + match client.get_live_metadata(&slug).await { + Ok(metadata) => { + let delay = std::time::Duration::from_millis(metadata.delay_to_refresh); + + // Update the playlist metadata + #[cfg(feature = "cache")] + { + let mut pls = playlists.write().await; + if let Some(playlist) = pls.get_mut(&slug) { + let _: Result<()> = playlist + .update_metadata( + &metadata, + cover_cache.as_ref(), + server_base_url.as_deref(), + ) + .await; + + // Update change tracking + *update_id.write().await = update_id.read().await.wrapping_add(1); + *last_change.write().await = Some(SystemTime::now()); + } + } + + #[cfg(not(feature = "cache"))] + { + let mut pls = playlists.write().await; + if let Some(playlist) = pls.get_mut(&slug) { + let _: () = playlist.update_metadata_no_cache(&metadata); + + // Update change tracking + *update_id.write().await = update_id.read().await.wrapping_add(1); + *last_change.write().await = Some(SystemTime::now()); + } + } + + tokio::time::sleep(delay).await; + } + Err(e) => { + #[cfg(feature = "logging")] + tracing::warn!("Failed to refresh metadata for {}: {}", slug, e); + + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } + } + } + }); + + handles.insert(station_slug.to_string(), handle); + + #[cfg(feature = "logging")] + tracing::debug!("Started metadata refresh for station: {}", station_slug); + + Ok(()) + } + + /// Stop metadata refresh task for a station + async fn stop_metadata_refresh(&self, station_slug: &str) { + let mut handles = self.refresh_handles.write().await; + if let Some(handle) = handles.remove(station_slug) { + handle.abort(); + + #[cfg(feature = "logging")] + tracing::debug!("Stopped metadata refresh for station: {}", station_slug); + } + } + + /// Build the UPnP container tree dynamically from station data + async fn build_container_tree(&self) -> Result { + let stations = self.client.get_stations().await?; + let groups = StationGroups::from_stations(stations); + + let mut containers = Vec::new(); + let mut items = Vec::new(); + + // 1. Standalone stations → direct items (avec appels API) + for station in &groups.standalone { + items.push(self.build_station_item(station).await?); + } + + // 2. Stations with webradios → containers + for group in &groups.with_webradios { + containers.push(self.build_station_container(group).await?); + } + + // 3. Local radios → single "Radios ICI" container + if !groups.local_radios.is_empty() { + containers.push(self.build_ici_container(&groups.local_radios).await?); + } + + Ok(Container { + id: "radiofrance".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some((containers.len() + items.len()).to_string()), + searchable: Some("0".to_string()), + title: "Radio France".to_string(), + class: "object.container".to_string(), + artist: None, + album_art: None, + containers, + items, + }) + } + + /// Build a container for a station group (main + webradios) + /// Returns an empty container - items will be built when browsing into it + async fn build_station_container(&self, group: &StationGroup) -> Result { + let child_count = 1 + group.webradios.len(); // main + webradios + + Ok(Container { + id: format!("radiofrance:group:{}", group.main.slug), + parent_id: "radiofrance".to_string(), + restricted: Some("1".to_string()), + child_count: Some(child_count.to_string()), + searchable: Some("0".to_string()), + title: group.main.name.clone(), + class: "object.container".to_string(), + artist: None, + album_art: None, + containers: vec![], + items: vec![], + }) + } + + /// Build the "Radios ICI" container + /// Returns an empty container - items will be built when browsing into it + async fn build_ici_container(&self, local_radios: &[Station]) -> Result { + Ok(Container { + id: "radiofrance:ici".to_string(), + parent_id: "radiofrance".to_string(), + restricted: Some("1".to_string()), + child_count: Some(local_radios.len().to_string()), + searchable: Some("0".to_string()), + title: "Radios ICI".to_string(), + class: "object.container".to_string(), + artist: None, + album_art: None, + containers: vec![], + items: vec![], + }) + } + + /// Build a UPnP item for a station + /// + /// Fetches live metadata to create a complete item with stream URL. + async fn build_station_item(&self, station: &Station) -> Result { + let playlists = self.playlists.read().await; + + // If we already have this station in cache, use it + if let Some(existing) = playlists.get(&station.slug) { + return Ok(existing.stream_item.clone()); + } + + // Release read lock before fetching metadata + drop(playlists); + + // Fetch metadata from API + let metadata = self.client.get_live_metadata(&station.slug).await?; + + // Create playlist with metadata + #[cfg(feature = "cache")] + let playlist = StationPlaylist::from_live_metadata( + station.clone(), + &metadata, + self.cover_cache.as_ref(), + self.server_base_url.as_deref(), + ) + .await?; + + #[cfg(not(feature = "cache"))] + let playlist = StationPlaylist::from_live_metadata_no_cache(station.clone(), &metadata)?; + + // Cache it + let mut playlists_write = self.playlists.write().await; + playlists_write.insert(station.slug.clone(), playlist.clone()); + drop(playlists_write); + + // Start metadata refresh task + let _ = self.start_metadata_refresh(&station.slug).await; + + Ok(playlist.stream_item) + } +} + +impl std::fmt::Debug for RadioFranceSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RadioFranceSource") + .field("client", &self.client) + .field("playlists_count", &"") + .field("refresh_handles_count", &"") + .finish() + } +} + +#[async_trait] +impl MusicSource for RadioFranceSource { + fn name(&self) -> &str { + "Radio France" + } + + fn id(&self) -> &str { + "radiofrance" + } + + fn default_image(&self) -> &[u8] { + RADIOFRANCE_DEFAULT_IMAGE + } + + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_fifo: false, + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: false, + max_sample_rate: Some(48000), // AAC 48kHz + supports_multiple_formats: false, + supports_advanced_search: false, + supports_pagination: false, + } + } + + async fn root_container(&self) -> pmosource::Result { + Ok(Container { + id: "radiofrance".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("0".to_string()), + title: "Radio France".to_string(), + class: "object.container".to_string(), + artist: None, + album_art: None, + containers: vec![], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> pmosource::Result { + match object_id { + "radiofrance" => { + let container = self + .build_container_tree() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + Ok(BrowseResult::Mixed { + containers: container.containers, + items: container.items, + }) + } + id if id.starts_with("radiofrance:group:") => { + let slug = id + .strip_prefix("radiofrance:group:") + .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; + + let stations = self + .client + .get_stations() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let groups = StationGroups::from_stations(stations); + + let group = groups + .with_webradios + .iter() + .find(|g| g.main.slug == slug) + .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; + + // Build items for this group only (main + webradios) + let mut items = vec![self + .build_station_item(&group.main) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?]; + + for webradio in &group.webradios { + items.push( + self.build_station_item(webradio) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?, + ); + } + + Ok(BrowseResult::Items(items)) + } + "radiofrance:ici" => { + let stations = self + .client + .get_stations() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let groups = StationGroups::from_stations(stations); + + // Build items for local radios only + let mut items = Vec::new(); + for station in &groups.local_radios { + items.push( + self.build_station_item(station) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?, + ); + } + + Ok(BrowseResult::Items(items)) + } + _ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), + } + } + + async fn get_item(&self, object_id: &str) -> pmosource::Result { + // Format: radiofrance:{slug}:stream + let slug = object_id + .strip_prefix("radiofrance:") + .and_then(|s| s.strip_suffix(":stream")) + .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; + + let playlists = self.playlists.read().await; + playlists + .get(slug) + .map(|p| p.stream_item.clone()) + .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string())) + } + + async fn resolve_uri(&self, object_id: &str) -> pmosource::Result { + // Extract station slug from object_id (format: radiofrance:{slug}:stream) + let slug = object_id + .strip_prefix("radiofrance:") + .and_then(|s| s.strip_suffix(":stream")) + .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; + + // Ensure we have metadata for this station + let playlists = self.playlists.read().await; + let needs_metadata = !playlists.contains_key(slug); + drop(playlists); + + if needs_metadata { + // Fetch metadata and create playlist + let stations = self + .client + .get_stations() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let station = stations + .iter() + .find(|s| s.slug == slug) + .ok_or_else(|| MusicSourceError::ObjectNotFound(slug.to_string()))?; + + let metadata = self + .client + .get_live_metadata(slug) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + #[cfg(feature = "cache")] + let playlist = StationPlaylist::from_live_metadata( + station.clone(), + &metadata, + self.cover_cache.as_ref(), + self.server_base_url.as_deref(), + ) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + #[cfg(not(feature = "cache"))] + let playlist = StationPlaylist::from_live_metadata_no_cache(station.clone(), &metadata) + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let mut playlists_write = self.playlists.write().await; + playlists_write.insert(slug.to_string(), playlist); + + // Start metadata refresh + drop(playlists_write); + let _ = self.start_metadata_refresh(slug).await; + } + + let item = self.get_item(object_id).await?; + item.resources + .first() + .map(|r| r.url.clone()) + .ok_or_else(|| MusicSourceError::UriResolutionError("No resource found".to_string())) + } + + fn supports_fifo(&self) -> bool { + false + } + + async fn append_track(&self, _track: Item) -> pmosource::Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn remove_oldest(&self) -> pmosource::Result> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + *self.update_id.read().await + } + + async fn last_change(&self) -> Option { + *self.last_change.read().await + } + + async fn get_items(&self, offset: usize, count: usize) -> pmosource::Result> { + // Not applicable for radio stations + let _ = (offset, count); + Ok(vec![]) + } +} + +impl Drop for RadioFranceSource { + fn drop(&mut self) { + // Abort all refresh tasks on drop + if let Ok(handles) = self.refresh_handles.try_write() { + for (_, handle) in handles.iter() { + handle.abort(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: These tests require a valid pmoconfig setup + // They are primarily structural tests + + #[test] + fn test_source_metadata() { + // Test that we can create a source with proper metadata + // Actual async tests would go in integration tests + } +} diff --git a/pmoradiofrance/src/stateful_client.rs b/pmoradiofrance/src/stateful_client.rs index f7393082..1f121176 100644 --- a/pmoradiofrance/src/stateful_client.rs +++ b/pmoradiofrance/src/stateful_client.rs @@ -123,6 +123,27 @@ impl RadioFranceStatefulClient { }) } + /// Create a client from global configuration + /// + /// This is a convenience method that reads the configuration from + /// the global config singleton. + /// + /// # Example + /// + /// ```ignore + /// use pmoradiofrance::RadioFranceStatefulClient; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = RadioFranceStatefulClient::from_config().await?; + /// Ok(()) + /// } + /// ``` + pub async fn from_config() -> Result { + let config = pmoconfig::get_config(); + Self::new(config).await + } + /// Create a client with a custom RadioFranceClient pub fn with_client(client: RadioFranceClient, config: Arc) -> Self { Self { @@ -188,11 +209,16 @@ impl RadioFranceStatefulClient { return Ok(stations); } - // Cache miss - discover and cache + // Cache miss - discover and cache with timeout #[cfg(feature = "logging")] tracing::info!("Station cache miss - discovering stations"); - let stations = self.client.discover_all_stations().await?; + let stations = tokio::time::timeout( + std::time::Duration::from_secs(10), + self.client.discover_all_stations(), + ) + .await + .map_err(|_| Error::other("Timeout while discovering Radio France stations (10s)"))??; // Cache the results self.config.set_radiofrance_cached_stations(&stations)?; @@ -281,11 +307,21 @@ impl RadioFranceStatefulClient { } } - // Cache miss or expired - fetch fresh data + // Cache miss or expired - fetch fresh data with timeout #[cfg(feature = "logging")] tracing::debug!("Fetching live metadata for {}", station); - let metadata = self.client.live_metadata(station).await?; + let metadata = tokio::time::timeout( + std::time::Duration::from_secs(5), + self.client.live_metadata(station), + ) + .await + .map_err(|_| { + Error::other(format!( + "Timeout while fetching metadata for {} (5s)", + station + )) + })??; // Update cache { From 418b0b7cbec0cc10178a90be7d38877f52888718 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 12:32:18 +0100 Subject: [PATCH 05/13] Add logging to container tree building and station item creation This commit adds conditional logging to the container tree building process and station item creation in the radio france source. The logging includes information about the number of groups, standalone stations, webradios, and local radios being processed. It also logs when using cached items and when starting metadata refresh tasks. The logging is enabled only when the 'logging' feature is activated. --- pmomediaserver/Cargo.toml | 1 + pmoradiofrance/src/source.rs | 48 ++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index 82d65fa4..27f46c4b 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -58,5 +58,6 @@ radiofrance = [ "api", "dep:pmoradiofrance", "pmoradiofrance/server", + "pmoradiofrance/logging", "dep:pmoconfig" ] diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index 4b047066..0024811b 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -226,27 +226,62 @@ impl RadioFranceSource { /// Build the UPnP container tree dynamically from station data async fn build_container_tree(&self) -> Result { + #[cfg(feature = "logging")] + tracing::debug!("Building container tree"); + let stations = self.client.get_stations().await?; let groups = StationGroups::from_stations(stations); + #[cfg(feature = "logging")] + tracing::debug!( + "Groups: {} standalone, {} with webradios, {} local radios", + groups.standalone.len(), + groups.with_webradios.len(), + groups.local_radios.len() + ); + let mut containers = Vec::new(); let mut items = Vec::new(); // 1. Standalone stations → direct items (avec appels API) + #[cfg(feature = "logging")] + tracing::debug!( + "Building {} standalone station items", + groups.standalone.len() + ); + for station in &groups.standalone { items.push(self.build_station_item(station).await?); } // 2. Stations with webradios → containers + #[cfg(feature = "logging")] + tracing::debug!("Building {} group containers", groups.with_webradios.len()); + for group in &groups.with_webradios { + #[cfg(feature = "logging")] + tracing::debug!("Building container for group: {}", group.main.name); containers.push(self.build_station_container(group).await?); } // 3. Local radios → single "Radios ICI" container + #[cfg(feature = "logging")] + tracing::debug!( + "Building ICI container with {} local radios", + groups.local_radios.len() + ); + if !groups.local_radios.is_empty() { containers.push(self.build_ici_container(&groups.local_radios).await?); } + #[cfg(feature = "logging")] + tracing::debug!( + "Container tree built: {} containers, {} items", + containers.len(), + items.len() + ); + Ok(Container { id: "radiofrance".to_string(), parent_id: "0".to_string(), @@ -304,10 +339,19 @@ impl RadioFranceSource { /// /// Fetches live metadata to create a complete item with stream URL. async fn build_station_item(&self, station: &Station) -> Result { + #[cfg(feature = "logging")] + tracing::debug!( + "Building station item for: {} ({})", + station.name, + station.slug + ); + let playlists = self.playlists.read().await; // If we already have this station in cache, use it if let Some(existing) = playlists.get(&station.slug) { + #[cfg(feature = "logging")] + tracing::debug!("Using cached item for: {}", station.slug); return Ok(existing.stream_item.clone()); } @@ -335,8 +379,8 @@ impl RadioFranceSource { playlists_write.insert(station.slug.clone(), playlist.clone()); drop(playlists_write); - // Start metadata refresh task - let _ = self.start_metadata_refresh(&station.slug).await; + // Note: We don't start metadata refresh here to avoid blocking during browse. + // Refresh will be started in resolve_uri() when the stream is actually played. Ok(playlist.stream_item) } From aad1cc772f0a1fc0817648fc8fac8d56efba9e63 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 16:55:29 +0100 Subject: [PATCH 06/13] =?UTF-8?q?Ajout=20d'une=20route=20pour=20le=20logo?= =?UTF-8?q?=20par=20d=C3=A9faut=20et=20am=C3=A9lioration=20de=20la=20gesti?= =?UTF-8?q?on=20des=20covers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'une nouvelle route /api/radiofrance/default-logo pour retourner le logo par défaut de Radio France. Modification de la gestion des covers pour: - Filtrer France Bleu des stations principales - Prioriser les images de fond, puis les cartes et les joueurs - Ajouter un fallback sur le logo par défaut via l'API REST - Passer l'URL de base du serveur aux fonctions de gestion des covers Correction de la construction du titre et des métadonnées (artist, album) pour éviter les duplications et améliorer la cohérence. --- pmoradiofrance/src/api_rest.rs | 13 ++++ pmoradiofrance/src/playlist.rs | 123 ++++++++++++++++++++++++++++----- pmoradiofrance/src/source.rs | 33 +++++++-- 3 files changed, 143 insertions(+), 26 deletions(-) diff --git a/pmoradiofrance/src/api_rest.rs b/pmoradiofrance/src/api_rest.rs index 7507c7e4..95f6eefa 100644 --- a/pmoradiofrance/src/api_rest.rs +++ b/pmoradiofrance/src/api_rest.rs @@ -50,6 +50,7 @@ pub fn create_router(state: RadioFranceState) -> Router { .route("/stations", get(get_stations)) .route("/{slug}/metadata", get(get_metadata)) .route("/{slug}/stream", get(proxy_stream)) + .route("/default-logo", get(get_default_logo)) .with_state(state) } @@ -124,3 +125,15 @@ async fn proxy_stream( Ok((headers, body).into_response()) } + +/// GET /api/radiofrance/default-logo +/// Returns the default Radio France logo (embedded in binary) +async fn get_default_logo() -> impl IntoResponse { + use crate::source::RADIOFRANCE_DEFAULT_IMAGE; + + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", "image/webp".parse().unwrap()); + headers.insert("Cache-Control", "public, max-age=86400".parse().unwrap()); + + (headers, RADIOFRANCE_DEFAULT_IMAGE).into_response() +} diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 42f273fd..292c2100 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -87,7 +87,11 @@ impl StationGroups { for station in stations { match &station.station_type { StationType::Main => { - main_stations.insert(station.slug.clone(), station); + // Filtrer France Bleu : ce n'est pas une vraie radio mais le nom générique + // pour toutes les radios locales ICI (ex-France Bleu) + if station.slug != "francebleu" { + main_stations.insert(station.slug.clone(), station); + } } StationType::Webradio { parent_station } => { webradios_by_parent @@ -235,9 +239,13 @@ impl StationPlaylist { } /// Construit une playlist sans cache de covers - pub fn from_live_metadata_no_cache(station: Station, metadata: &LiveResponse) -> Result { + pub fn from_live_metadata_no_cache( + station: Station, + metadata: &LiveResponse, + server_base_url: Option<&str>, + ) -> Result { let id = format!("radiofrance:{}", station.slug); - let stream_item = Self::build_item_from_metadata_sync(&station, metadata, None)?; + let stream_item = Self::build_item_from_metadata_sync(&station, metadata, server_base_url)?; Ok(Self { id, @@ -285,7 +293,11 @@ impl StationPlaylist { } /// Met à jour les métadonnées sans cache - pub fn update_metadata_no_cache(&mut self, metadata: &LiveResponse) -> Result<()> { + pub fn update_metadata_no_cache( + &mut self, + metadata: &LiveResponse, + server_base_url: Option<&str>, + ) -> Result<()> { let old_url = self .stream_item .resources @@ -293,7 +305,8 @@ impl StationPlaylist { .map(|r| r.url.clone()) .unwrap_or_default(); - let mut new_item = Self::build_item_from_metadata_sync(&self.station, metadata, None)?; + let mut new_item = + Self::build_item_from_metadata_sync(&self.station, metadata, server_base_url)?; if let Some(res) = new_item.resources.first_mut() { if !old_url.is_empty() { @@ -320,7 +333,7 @@ impl StationPlaylist { let (album_art, album_art_pk) = if let Some(cache) = cover_cache { Self::cache_cover(metadata, cache, server_base_url).await } else { - Self::extract_cover_url(metadata) + Self::extract_cover_url(metadata, server_base_url) }; // Construction de la ressource (stream) @@ -349,12 +362,12 @@ impl StationPlaylist { fn build_item_from_metadata_sync( station: &Station, metadata: &LiveResponse, - _cover_url_override: Option, + server_base_url: Option<&str>, ) -> Result { let (title, creator, artist, album, genre, class) = Self::extract_metadata_fields(station, metadata); - let (album_art, album_art_pk) = Self::extract_cover_url(metadata); + let (album_art, album_art_pk) = Self::extract_cover_url(metadata, server_base_url); let resource = Self::build_stream_resource(metadata); Ok(Item { @@ -410,20 +423,32 @@ impl StationPlaylist { let first = now.first_line.title_or_default(); let second = now.second_line.title_or_default(); + // Construire le titre en évitant les duplications let title = if !first.is_empty() && !second.is_empty() { - format!("{} • {}", first, second) + // Si first contient déjà second, utiliser seulement first + if first.contains(second) { + first.to_string() + } else { + format!("{} • {}", first, second) + } } else if !first.is_empty() { first.to_string() } else { station.display_name().to_string() }; - let creator = now.producer.clone(); - let artist = now.producer.clone(); + // Artist/Creator = "{Station} - {Subtitle}" + let artist = if !second.is_empty() { + Some(format!("{} - {}", station.name, second)) + } else { + Some(station.name.clone()) + }; + let creator = artist.clone(); + // Album = nom de l'émission principale let album = if !first.is_empty() { Some(first.to_string()) } else { - None + Some(station.name.clone()) }; let genre = Some("Talk Radio".to_string()); let class = "object.item.audioItem.audioBroadcast".to_string(); @@ -433,8 +458,13 @@ impl StationPlaylist { } /// Extrait l'URL de cover depuis les métadonnées (sans cache) - fn extract_cover_url(metadata: &LiveResponse) -> (Option, Option) { - // Priorité : visual_background > song cover + fn extract_cover_url( + metadata: &LiveResponse, + server_base_url: Option<&str>, + ) -> (Option, Option) { + // Priorité : visual_background > visuals.card > visuals.player > logo par défaut + + // 1. visual_background if let Some(ref visual) = metadata.now.visual_background { if let Some(uuid) = visual.extract_uuid() { let url = ImageSize::Large.build_url(&uuid); @@ -442,7 +472,34 @@ impl StationPlaylist { } } - // Pas de cover trouvée + // 2. visuals.card + if let Some(ref visuals) = metadata.now.visuals { + if let Some(ref card) = visuals.card { + if let Some(uuid) = card.extract_uuid() { + let url = ImageSize::Large.build_url(&uuid); + return (Some(url), None); + } + } + + // 3. visuals.player + if let Some(ref player) = visuals.player { + if let Some(uuid) = player.extract_uuid() { + let url = ImageSize::Large.build_url(&uuid); + return (Some(url), None); + } + } + } + + // Fallback sur le logo par défaut via l'API REST + if let Some(base) = server_base_url { + let logo_url = format!( + "{}/api/radiofrance/default-logo", + base.trim_end_matches('/') + ); + return (Some(logo_url), None); + } + + // Pas de cover trouvée et pas de serveur configuré (None, None) } @@ -453,16 +510,35 @@ impl StationPlaylist { cache: &Arc, server_base_url: Option<&str>, ) -> (Option, Option) { - // Extraire l'UUID de la cover + // Extraire l'UUID de la cover (priorité : visual_background > visuals.card > visuals.player) let uuid = metadata .now .visual_background .as_ref() - .and_then(|v| v.extract_uuid()); + .and_then(|v| v.extract_uuid()) + .or_else(|| { + metadata.now.visuals.as_ref().and_then(|visuals| { + visuals + .card + .as_ref() + .and_then(|c| c.extract_uuid()) + .or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid())) + }) + }); let uuid = match uuid { Some(u) => u, - None => return (None, None), + None => { + // Fallback sur le logo par défaut via l'API REST + if let Some(base) = server_base_url { + let logo_url = format!( + "{}/api/radiofrance/default-logo", + base.trim_end_matches('/') + ); + return (Some(logo_url), None); + } + return (None, None); + } }; // URL haute résolution @@ -479,7 +555,16 @@ impl StationPlaylist { } Err(e) => { tracing::warn!("Failed to cache Radio France cover: {}", e); - (Some(cover_url), None) + // Fallback sur le logo par défaut via l'API REST en cas d'erreur + if let Some(base) = server_base_url { + let logo_url = format!( + "{}/api/radiofrance/default-logo", + base.trim_end_matches('/') + ); + (Some(logo_url), None) + } else { + (Some(cover_url), None) + } } } } diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index 0024811b..78fefeaa 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -22,8 +22,8 @@ use pmocovers::Cache as CoverCache; #[cfg(feature = "server")] use pmoupnp; -/// Default image for Radio France source -const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofrance-logo.webp"); +/// Default image for Radio France source (embedded in binary) +pub const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofrance-logo.webp"); /// Radio France music source /// @@ -153,7 +153,7 @@ impl RadioFranceSource { #[cfg(feature = "cache")] let cover_cache = self.cover_cache.clone(); - #[cfg(feature = "cache")] + let server_base_url = self.server_base_url.clone(); let handle = tokio::spawn(async move { @@ -185,7 +185,10 @@ impl RadioFranceSource { { let mut pls = playlists.write().await; if let Some(playlist) = pls.get_mut(&slug) { - let _: () = playlist.update_metadata_no_cache(&metadata); + let _: Result<()> = playlist.update_metadata_no_cache( + &metadata, + server_base_url.as_deref(), + ); // Update change tracking *update_id.write().await = update_id.read().await.wrapping_add(1); @@ -372,7 +375,11 @@ impl RadioFranceSource { .await?; #[cfg(not(feature = "cache"))] - let playlist = StationPlaylist::from_live_metadata_no_cache(station.clone(), &metadata)?; + let playlist = StationPlaylist::from_live_metadata_no_cache( + station.clone(), + &metadata, + self.server_base_url.as_deref(), + )?; // Cache it let mut playlists_write = self.playlists.write().await; @@ -382,6 +389,14 @@ impl RadioFranceSource { // Note: We don't start metadata refresh here to avoid blocking during browse. // Refresh will be started in resolve_uri() when the stream is actually played. + #[cfg(feature = "logging")] + tracing::debug!( + "Built item for {}: {} resources, album_art: {:?}", + station.slug, + playlist.stream_item.resources.len(), + playlist.stream_item.album_art.is_some() + ); + Ok(playlist.stream_item) } } @@ -568,8 +583,12 @@ impl MusicSource for RadioFranceSource { .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; #[cfg(not(feature = "cache"))] - let playlist = StationPlaylist::from_live_metadata_no_cache(station.clone(), &metadata) - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let playlist = StationPlaylist::from_live_metadata_no_cache( + station.clone(), + &metadata, + self.server_base_url.as_deref(), + ) + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let mut playlists_write = self.playlists.write().await; playlists_write.insert(slug.to_string(), playlist); From 89f315052b7db0717a66b6711b70791e65a1473d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 19:10:37 +0100 Subject: [PATCH 07/13] Add pmocache dependency and update cache implementation This commit adds the pmocache dependency to support enhanced caching functionality and updates the cache implementation to use the new cache routing system. It also includes logging improvements for cover fetching operations. --- Cargo.lock | 1 + pmoradiofrance/Cargo.toml | 3 ++- pmoradiofrance/src/playlist.rs | 23 +++++++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a019320a..241e0e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4152,6 +4152,7 @@ dependencies = [ "chrono", "futures", "pmoaudiocache", + "pmocache", "pmoconfig", "pmocovers", "pmodidl", diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml index 19666ff5..d8bef018 100644 --- a/pmoradiofrance/Cargo.toml +++ b/pmoradiofrance/Cargo.toml @@ -49,6 +49,7 @@ pmodidl = { path = "../pmodidl", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } # Cache support +pmocache = { path = "../pmocache", optional = true } pmocovers = { path = "../pmocovers", optional = true } pmoaudiocache = { path = "../pmoaudiocache", optional = true } @@ -66,7 +67,7 @@ default = ["pmoconfig"] # Feature for pmoconfig support pmoconfig = ["dep:pmoconfig"] # Feature for cache support -cache = ["dep:pmocovers", "dep:pmoaudiocache"] +cache = ["dep:pmocache", "dep:pmocovers", "dep:pmoaudiocache"] # Feature for playlist/FIFO support playlist = ["dep:pmoplaylist", "dep:pmodidl"] # Feature for logging (tracing) diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 292c2100..9a01d1d7 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -32,6 +32,8 @@ use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat} use pmodidl::{Item, Resource}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "cache")] +use pmocache::cache_trait::FileCache; #[cfg(feature = "cache")] use pmocovers::Cache as CoverCache; #[cfg(feature = "cache")] @@ -336,6 +338,14 @@ impl StationPlaylist { Self::extract_cover_url(metadata, server_base_url) }; + #[cfg(feature = "logging")] + tracing::debug!( + "Cover for {}: album_art={:?}, album_art_pk={:?}", + station.slug, + album_art, + album_art_pk + ); + // Construction de la ressource (stream) let resource = Self::build_stream_resource(metadata); @@ -496,10 +506,14 @@ impl StationPlaylist { "{}/api/radiofrance/default-logo", base.trim_end_matches('/') ); + #[cfg(feature = "logging")] + tracing::debug!("Using default Radio France logo: {}", logo_url); return (Some(logo_url), None); } // Pas de cover trouvée et pas de serveur configuré + #[cfg(feature = "logging")] + tracing::warn!("No cover found and no server_base_url configured"); (None, None) } @@ -548,8 +562,13 @@ impl StationPlaylist { match cache.add_from_url(&cover_url, Some("radiofrance")).await { Ok(pk) => { // Construire l'URL publique si server_base_url est fourni - let public_url = server_base_url - .map(|base| format!("{}/covers/{}", base.trim_end_matches('/'), pk)); + let public_url = server_base_url.map(|base| { + format!( + "{}{}", + base.trim_end_matches('/'), + cache.route_for(&pk, None) + ) + }); (public_url.or(Some(cover_url)), Some(pk)) } From ab2f1af620a0d23a111137b037c8d975a1a87add Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 19:13:39 +0100 Subject: [PATCH 08/13] =?UTF-8?q?Am=C3=A9lioration=20de=20l'affichage=20de?= =?UTF-8?q?s=20artistes=20et=20correction=20des=20parent=5Fid=20dans=20les?= =?UTF-8?q?=20listes=20de=20lecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette mise à jour corrige l'affichage des artistes dans les pistes musicales en évitant les duplications avec le nom de la station. Elle ajuste également les identifiants parents des éléments dans les listes de lecture pour une meilleure organisation hiérarchique. Les modifications affectent les fichiers playlist.rs et source.rs. --- pmoradiofrance/src/playlist.rs | 30 +++++++++++++++++++++------- pmoradiofrance/src/source.rs | 36 ++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 9a01d1d7..9ae4e30a 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -417,13 +417,26 @@ impl StationPlaylist { if let Some(ref song) = now.song { // Radio musicale avec morceau let title = now.first_line.title_or_default().to_string(); - let artist = if song.interpreters.is_empty() { + let song_artist = if song.interpreters.is_empty() { None } else { Some(song.artists_display()) }; + + // Artist affiché = "Station - Artiste du morceau" pour identifier la radio + // Éviter la duplication si l'artiste est égal au nom de la station + let artist = if let Some(ref art) = song_artist { + if art != &station.name && art != station.display_name() { + Some(format!("{} - {}", station.display_name(), art)) + } else { + Some(station.display_name().to_string()) + } + } else { + Some(station.display_name().to_string()) + }; + let album = song.release.title.clone(); - let creator = artist.clone(); + let creator = song_artist; // Creator reste l'artiste du morceau pour compatibilité let genre = Some("Music".to_string()); let class = "object.item.audioItem.musicTrack".to_string(); @@ -448,11 +461,14 @@ impl StationPlaylist { }; // Artist/Creator = "{Station} - {Subtitle}" - let artist = if !second.is_empty() { - Some(format!("{} - {}", station.name, second)) - } else { - Some(station.name.clone()) - }; + // Éviter la duplication si subtitle == nom de la station + let artist = + if !second.is_empty() && second != station.name && second != station.display_name() + { + Some(format!("{} - {}", station.name, second)) + } else { + Some(station.name.clone()) + }; let creator = artist.clone(); // Album = nom de l'émission principale let album = if !first.is_empty() { diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index 78fefeaa..0880af77 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -488,17 +488,26 @@ impl MusicSource for RadioFranceSource { .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; // Build items for this group only (main + webradios) - let mut items = vec![self + let group_id = format!("radiofrance:group:{}", slug); + + let mut main_item = self .build_station_item(&group.main) .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?]; + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Fix parent_id to point to the group container + main_item.parent_id = group_id.clone(); + let mut items = vec![main_item]; for webradio in &group.webradios { - items.push( - self.build_station_item(webradio) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?, - ); + let mut item = self + .build_station_item(webradio) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Fix parent_id to point to the group container + item.parent_id = group_id.clone(); + items.push(item); } Ok(BrowseResult::Items(items)) @@ -514,11 +523,14 @@ impl MusicSource for RadioFranceSource { // Build items for local radios only let mut items = Vec::new(); for station in &groups.local_radios { - items.push( - self.build_station_item(station) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?, - ); + let mut item = self + .build_station_item(station) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Fix parent_id to point to the ICI container + item.parent_id = "radiofrance:ici".to_string(); + items.push(item); } Ok(BrowseResult::Items(items)) From df8b062af6ede5d942f8550447bdb17ec48a899d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 20:21:11 +0100 Subject: [PATCH 09/13] Enhance Radio France source with API routes initialization and default album art This commit enhances the Radio France source by: 1. Updating the main.rs file to clarify the purpose of registering the Radio France source, including API route initialization 2. Modifying sources.rs to: - Add RadioFranceExt to the imports - Initialize API routes for Radio France after registering the source 3. Updating source.rs to: - Use a default logo for Radio France containers when server_base_url is configured - Apply the default logo to both group and 'Radios ICI' containers These changes improve the integration and visual presentation of Radio France content within the media server. --- PMOMusic/src/main.rs | 2 +- pmomediaserver/src/sources.rs | 7 ++++++- pmoradiofrance/src/source.rs | 20 ++++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index fb0596f0..6200d11e 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -54,7 +54,7 @@ async fn main() -> Result<(), Box> { } } - // Enregistrer la source Radio France + // Enregistrer la source Radio France (inclut l'initialisation des routes API) info!("📻 Registering Radio France source..."); if let Err(e) = server.write().await.register_radiofrance().await { tracing::warn!("⚠️ Failed to register Radio France source: {}", e); diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 61adc5e9..07c66e14 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -243,7 +243,7 @@ impl SourcesExt for Server { #[cfg(feature = "radiofrance")] async fn register_radiofrance(&mut self) -> Result<()> { - use pmoradiofrance::{RadioFranceSource, RadioFranceStatefulClient}; + use pmoradiofrance::{RadioFranceExt, RadioFranceSource, RadioFranceStatefulClient}; tracing::info!("Initializing Radio France source..."); @@ -265,6 +265,11 @@ impl SourcesExt for Server { // Enregistrer la source self.register_music_source(Arc::new(source)).await; + // Initialiser les routes API Radio France + self.init_radiofrance().await.map_err(|e| { + SourceInitError::RadioFranceError(format!("Failed to init API routes: {}", e)) + })?; + tracing::info!("✅ Radio France source registered successfully"); Ok(()) diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index 0880af77..e0a09eb0 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -305,6 +305,14 @@ impl RadioFranceSource { async fn build_station_container(&self, group: &StationGroup) -> Result { let child_count = 1 + group.webradios.len(); // main + webradios + // Utiliser le logo par défaut si server_base_url est configuré + let album_art = self.server_base_url.as_ref().map(|base| { + format!( + "{}/api/radiofrance/default-logo", + base.trim_end_matches('/') + ) + }); + Ok(Container { id: format!("radiofrance:group:{}", group.main.slug), parent_id: "radiofrance".to_string(), @@ -314,7 +322,7 @@ impl RadioFranceSource { title: group.main.name.clone(), class: "object.container".to_string(), artist: None, - album_art: None, + album_art, containers: vec![], items: vec![], }) @@ -323,6 +331,14 @@ impl RadioFranceSource { /// Build the "Radios ICI" container /// Returns an empty container - items will be built when browsing into it async fn build_ici_container(&self, local_radios: &[Station]) -> Result { + // Utiliser le logo par défaut si server_base_url est configuré + let album_art = self.server_base_url.as_ref().map(|base| { + format!( + "{}/api/radiofrance/default-logo", + base.trim_end_matches('/') + ) + }); + Ok(Container { id: "radiofrance:ici".to_string(), parent_id: "radiofrance".to_string(), @@ -332,7 +348,7 @@ impl RadioFranceSource { title: "Radios ICI".to_string(), class: "object.container".to_string(), artist: None, - album_art: None, + album_art, containers: vec![], items: vec![], }) From 5cee6539c6d9373eac347a05b9fedb9be1345e24 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 20:28:15 +0100 Subject: [PATCH 10/13] =?UTF-8?q?Ajout=20de=20la=20notification=20UPnP=20G?= =?UTF-8?q?ENA=20pour=20les=20mises=20=C3=A0=20jour=20des=20sources=20Radi?= =?UTF-8?q?oFrance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette modification ajoute la capacité de notifier les événements UPnP GENA lors des mises à jour des sources RadioFrance. Cela permet de mettre à jour correctement le ContentDirectory UPnP lorsque les flux radio sont modifiés. - Ajout d'un notifier pour les conteneurs UPnP dans le module sources - Ajout d'un champ container_notifier dans RadioFranceSource - Implémentation de la méthode with_container_notifier - Ajout de la notification dans les deux méthodes de mise à jour (stream et playlist) - Utilisation de l'API state::notify_containers_updated pour envoyer les notifications --- pmomediaserver/src/sources.rs | 8 ++++++++ pmoradiofrance/src/source.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 07c66e14..30ef3f13 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -3,6 +3,7 @@ //! Ce module fournit des helpers pour créer et enregistrer facilement des sources //! musicales préconfigurées à partir de la configuration système. +use crate::contentdirectory::state; use pmoserver::Server; use pmosource::MusicSourceExt; use std::sync::Arc; @@ -262,6 +263,13 @@ impl SourcesExt for Server { SourceInitError::RadioFranceError(format!("Failed to create source: {}", e)) })?; + // Configurer le notifier pour les événements UPnP GENA + let notifier = Arc::new(|containers: &[String]| { + let refs: Vec<&str> = containers.iter().map(|s| s.as_str()).collect(); + state::notify_containers_updated(&refs); + }); + let source = source.with_container_notifier(notifier); + // Enregistrer la source self.register_music_source(Arc::new(source)).await; diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index e0a09eb0..b8426f7e 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -53,6 +53,9 @@ pub struct RadioFranceSource { /// Last change timestamp last_change: Arc>>, + + /// Callback for notifying container updates (UPnP GENA) + container_notifier: Option>, } impl RadioFranceSource { @@ -87,9 +90,19 @@ impl RadioFranceSource { server_base_url: None, update_id: Arc::new(RwLock::new(0)), last_change: Arc::new(RwLock::new(None)), + container_notifier: None, }) } + /// Set the container notifier for UPnP GENA events + pub fn with_container_notifier( + mut self, + notifier: Arc, + ) -> Self { + self.container_notifier = Some(notifier); + self + } + /// Set the cover cache #[cfg(feature = "cache")] pub fn with_cover_cache(mut self, cache: Arc) -> Self { @@ -133,6 +146,7 @@ impl RadioFranceSource { server_base_url: Some(base_url.into()), update_id: Arc::new(RwLock::new(0)), last_change: Arc::new(RwLock::new(None)), + container_notifier: None, }) } @@ -150,6 +164,7 @@ impl RadioFranceSource { let slug = station_slug.to_string(); let update_id = self.update_id.clone(); let last_change = self.last_change.clone(); + let container_notifier = self.container_notifier.clone(); #[cfg(feature = "cache")] let cover_cache = self.cover_cache.clone(); @@ -178,6 +193,13 @@ impl RadioFranceSource { // Update change tracking *update_id.write().await = update_id.read().await.wrapping_add(1); *last_change.write().await = Some(SystemTime::now()); + + // Notify UPnP ContentDirectory of the change + if let Some(ref notifier) = container_notifier { + // Notify the station's stream item container + let container_id = format!("radiofrance:{}", slug); + notifier(&[container_id]); + } } } @@ -193,6 +215,13 @@ impl RadioFranceSource { // Update change tracking *update_id.write().await = update_id.read().await.wrapping_add(1); *last_change.write().await = Some(SystemTime::now()); + + // Notify UPnP ContentDirectory of the change + if let Some(ref notifier) = container_notifier { + // Notify the station's stream item container + let container_id = format!("radiofrance:{}", slug); + notifier(&[container_id]); + } } } From a13467bc2cc8b87a62c57c2a4101c8356f3cb1bf Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 22:02:03 +0100 Subject: [PATCH 11/13] =?UTF-8?q?Ajout=20de=20la=20gestion=20de=20la=20dur?= =?UTF-8?q?=C3=A9e=20dans=20les=20m=C3=A9tadonn=C3=A9es=20UPnP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette mise à jour ajoute la gestion de la durée dans les métadonnées UPnP et OpenHome. Les changements incluent : - Ajout de la dépendance `pmoutils` dans `pmocontrol` - Mise à jour du modèle `Item` pour inclure le champ `duration` - Implémentation de la sérialisation DIDL-Lite avec `pmodidl` et `pmoutils` - Ajout de la gestion de la durée dans les renderers UPnP et OpenHome - Extraction de la durée depuis les métadonnées DIDL-Lite - Support du fallback vers la durée du DIDL lorsque l'ampli ne la retourne pas - Mise à jour des constructeurs d'items pour inclure la durée calculée pour les streams radio Ces modifications permettent une meilleure gestion des durées de pistes audio, notamment pour les streams radio avec des durées calculées à partir des timestamps de début/fin. --- Cargo.lock | 1 + pmocontrol/Cargo.toml | 1 + pmocontrol/src/control_point.rs | 3 +- pmocontrol/src/media_server.rs | 1 + pmocontrol/src/model.rs | 1 + .../src/music_renderer/musicrenderer.rs | 67 ++++++----- .../src/music_renderer/openhome_renderer.rs | 42 ++++++- .../src/music_renderer/upnp_renderer.rs | 110 +++++++++++++++++- pmocontrol/src/music_renderer/watcher.rs | 1 + pmocontrol/src/queue/openhome.rs | 14 ++- .../src/upnp_clients/avtransport_client.rs | 9 ++ .../src/upnp_clients/openhome_client.rs | 1 + pmomediaserver/src/content_handler.rs | 10 ++ pmoradiofrance/src/playlist.rs | 74 +++++++++++- 14 files changed, 296 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 241e0e6b..197d6b5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3907,6 +3907,7 @@ dependencies = [ "pmodidl", "pmoserver", "pmoupnp", + "pmoutils", "quick-xml", "rand 0.9.2", "ratatui", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 00333d8b..e91fb791 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] pmoupnp = { path = "../pmoupnp" } pmodidl = { path = "../pmodidl" } +pmoutils = { path = "../pmoutils" } quick-xml = { workspace = true } thiserror = { workspace = true } ureq = "3.1.4" diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 1c534779..17e746a1 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1647,7 +1647,7 @@ fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem { bits_per_sample: None, sample_frequency: None, nr_audio_channels: None, - duration: None, + duration: metadata.and_then(|m| m.duration.clone()), url: item.uri.clone(), }], descriptions: Vec::new(), @@ -1686,6 +1686,7 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { date: None, track_number: None, creator: None, + duration: None, }) } diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index ef5eaa61..41bea3c5 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -565,6 +565,7 @@ pub fn playback_item_from_entry( date: entry.date.clone(), track_number: entry.track_number.clone(), creator: entry.creator.clone(), + duration: resource.duration.clone(), }; debug!( diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index e89160f5..cfb1c1b3 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -93,6 +93,7 @@ pub struct TrackMetadata { pub date: Option, pub track_number: Option, pub creator: Option, + pub duration: Option, } #[derive(Clone, Debug, Copy)] diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index a0df13c9..fe801cd8 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -1245,33 +1245,46 @@ pub(crate) fn build_didl_lite_metadata( uri: &str, protocol_info: &str, ) -> String { - format!( - r#" - -{} -{} -{} -{} -{} -{} - -"#, - metadata.title.as_deref().unwrap_or("Unknown Title"), - metadata - .creator - .as_deref() - .or(metadata.artist.as_deref()) - .unwrap_or("Unknown Artist"), - metadata.artist.as_deref().unwrap_or("Unknown Artist"), - metadata.album.as_deref().unwrap_or("Unknown Album"), - metadata - .album_art_uri - .as_ref() - .map(|art_uri| format!("{}", art_uri)) - .unwrap_or_default(), - protocol_info, - uri - ) + use pmodidl::{DIDLLite, Item, Resource}; + use pmoutils::ToXmlElement; + + // Construire l'Item DIDL avec toutes les métadonnées + let item = Item { + id: "0".to_string(), + parent_id: "-1".to_string(), + restricted: Some("1".to_string()), + title: metadata + .title + .clone() + .unwrap_or_else(|| "Unknown Title".to_string()), + creator: metadata.creator.clone().or_else(|| metadata.artist.clone()), + class: "object.item.audioItem.musicTrack".to_string(), + artist: metadata.artist.clone(), + album: metadata.album.clone(), + genre: metadata.genre.clone(), + album_art: metadata.album_art_uri.clone(), + album_art_pk: None, + date: metadata.date.clone(), + original_track_number: metadata.track_number.clone(), + resources: vec![Resource { + protocol_info: protocol_info.to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: metadata.duration.clone(), + url: uri.to_string(), + }], + descriptions: vec![], + }; + + // Construire le DIDL-Lite complet + let didl = DIDLLite { + items: vec![item], + ..Default::default() + }; + + // Sérialiser en XML via pmodidl + didl.to_xml() } impl DeviceIdentity for MusicRenderer { diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index e4cba46a..e8319348 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -327,17 +327,57 @@ impl PlaybackPosition for OpenHomeRenderer { } } + // Get duration from Time service, but fall back to DIDL metadata if duration is 0 + let track_duration = if time_info.duration_secs == 0 { + // Try to extract duration from DIDL metadata + track_metadata_xml + .as_ref() + .and_then(|xml| parse_didl_duration_openhome(xml)) + } else { + Some(format_hhmmss_u32(time_info.duration_secs)) + }; + + tracing::trace!( + "OpenHome playback_position: duration_secs={}, track_duration={:?}", + time_info.duration_secs, + track_duration + ); + Ok(PlaybackPositionInfo { track: track_id, rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)), abs_time: None, - track_duration: Some(format_hhmmss_u32(time_info.duration_secs)), + track_duration, track_metadata: track_metadata_xml, track_uri, }) } } +/// Parse duration from DIDL-Lite metadata XML (OpenHome version) +fn parse_didl_duration_openhome(didl: &str) -> Option { + // Search for duration attribute in element + let res_start = didl.find("')?; + let tag_attrs = &after_res[..tag_close]; + + if let Some(duration_start) = tag_attrs.find("duration=\"") { + let duration_offset = duration_start + "duration=\"".len(); + if let Some(duration_end) = tag_attrs[duration_offset..].find('"') { + let duration = &tag_attrs[duration_offset..duration_offset + duration_end]; + tracing::info!( + "OpenHome: Extracted duration from DIDL metadata: {}", + duration + ); + return Some(duration.to_string()); + } + } + + tracing::debug!("OpenHome: No duration found in DIDL metadata"); + None +} + pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { match raw.trim().to_ascii_uppercase().as_str() { "PLAYING" => PlaybackState::Playing, diff --git a/pmocontrol/src/music_renderer/upnp_renderer.rs b/pmocontrol/src/music_renderer/upnp_renderer.rs index 6036ef94..f74a9d49 100644 --- a/pmocontrol/src/music_renderer/upnp_renderer.rs +++ b/pmocontrol/src/music_renderer/upnp_renderer.rs @@ -23,6 +23,8 @@ pub struct UpnpRenderer { connection_manager: Option, has_avtransport_set_next: bool, queue: Arc>, + /// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas) + cached_duration: Arc>>, } impl UpnpRenderer { @@ -103,6 +105,7 @@ impl UpnpRenderer { connection_manager, has_avtransport_set_next, queue, + cached_duration: Arc::new(Mutex::new(None)), } } } @@ -153,6 +156,7 @@ impl RendererFromMediaRendererInfo for UpnpRenderer { connection_manager, has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), queue, + cached_duration: Arc::new(Mutex::new(None)), }) } @@ -202,6 +206,21 @@ impl QueueTransportControl for UpnpRenderer { ) }; + tracing::info!( + "play_from_queue DIDL metadata (first 800 chars):\n{}", + &metadata[..metadata.len().min(800)] + ); + + // Parse et cache la durée du DIDL + let duration = parse_didl_duration(&metadata); + if let Some(ref dur) = duration { + tracing::info!("Caching duration from queue DIDL: {}", dur); + *self.cached_duration.lock().unwrap() = Some(dur.clone()); + } else { + tracing::debug!("No duration to cache from queue DIDL"); + *self.cached_duration.lock().unwrap() = None; + } + // UPNP: SetAVTransportURI + Play let avt = self.avtransport()?; avt.set_av_transport_uri(&item.uri, &metadata)?; @@ -307,11 +326,61 @@ impl QueueBackend for UpnpRenderer { } } +/// Parse le DIDL-Lite pour extraire la durée du premier élément +fn parse_didl_duration(didl: &str) -> Option { + // Recherche de l'élément (avec ou sans espace après) + let res_start = didl + .find("")) + .or_else(|| didl.find(" + // Il doit être avant la fermeture du tag (avant '>') + if let Some(tag_close) = after_res.find('>') { + let tag_attrs = &after_res[..tag_close]; + + if let Some(duration_start) = tag_attrs.find("duration=\"") { + let duration_offset = duration_start + "duration=\"".len(); + if let Some(duration_end) = tag_attrs[duration_offset..].find('"') { + let duration = &tag_attrs[duration_offset..duration_offset + duration_end]; + tracing::info!("Extracted duration from DIDL: {}", duration); + return Some(duration.to_string()); + } + } + } + + tracing::warn!("No duration attribute found in DIDL element"); + None +} + /// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. /// /// Cette impl se base sur AVTransport (InstanceID = 0). impl TransportControl for UpnpRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + // Log du DIDL complet pour déboguer + if !meta.is_empty() { + tracing::debug!( + "play_uri DIDL-Lite metadata: {}", + &meta[..meta.len().min(500)] + ); + } + + // Parse le DIDL pour extraire la durée + let duration = parse_didl_duration(meta); + if let Some(ref dur) = duration { + tracing::info!("Caching duration from DIDL: {}", dur); + *self.cached_duration.lock().unwrap() = Some(dur.clone()); + } else { + tracing::warn!( + "No duration to cache from DIDL (this may be expected for streams without duration)" + ); + *self.cached_duration.lock().unwrap() = None; + } + let avt = self.avtransport()?; avt.set_av_transport_uri(uri, meta)?; avt.play(0, "1") @@ -381,11 +450,50 @@ impl PlaybackPosition for UpnpRenderer { let avt = self.avtransport()?; let raw: PositionInfo = avt.get_position_info(0)?; + tracing::trace!( + "GetPositionInfo returned: track_duration={:?}, rel_time={:?}", + raw.track_duration, + raw.rel_time + ); + + // Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration) + let normalized_duration = raw.track_duration.as_ref().and_then(|d| { + if d == "00:00:00" || d == "0:00:00" { + None + } else { + Some(d.clone()) + } + }); + + // Si l'ampli ne retourne pas de durée, utilise la durée cachée du DIDL + let track_duration = if normalized_duration.is_none() { + let cached = self.cached_duration.lock().unwrap(); + if let Some(ref duration) = *cached { + tracing::debug!("Using cached duration from DIDL as fallback: {}", duration); + Some(duration.clone()) + } else { + tracing::warn!("No track_duration from renderer and no cached duration available!"); + None + } + } else { + tracing::debug!( + "Using track_duration from renderer: {:?}", + normalized_duration + ); + normalized_duration + }; + + tracing::trace!( + "Final PlaybackPositionInfo: track_duration={:?}, rel_time={:?}", + track_duration, + raw.rel_time + ); + Ok(PlaybackPositionInfo { track: Some(raw.track), rel_time: raw.rel_time, abs_time: raw.abs_time, - track_duration: raw.track_duration, + track_duration, track_metadata: raw.track_metadata, track_uri: raw.track_uri, }) diff --git a/pmocontrol/src/music_renderer/watcher.rs b/pmocontrol/src/music_renderer/watcher.rs index 9abaccb1..67a1d29e 100644 --- a/pmocontrol/src/music_renderer/watcher.rs +++ b/pmocontrol/src/music_renderer/watcher.rs @@ -217,6 +217,7 @@ pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option String { } let escaped_protocol_info = escape(item.protocol_info.as_str()); - xml.push_str(&format!( - r#"{}"#, - escaped_protocol_info, escaped_uri - )); + + // Build element with optional duration attribute + xml.push_str(&format!(r#"{}"#, escaped_uri)); xml.push_str(r#"object.item.audioItem.musicTrack"#); xml } diff --git a/pmocontrol/src/upnp_clients/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs index ce1dc839..7bd16166 100644 --- a/pmocontrol/src/upnp_clients/avtransport_client.rs +++ b/pmocontrol/src/upnp_clients/avtransport_client.rs @@ -71,6 +71,15 @@ impl AvTransportClient { /// - `uri` : CurrentURI /// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide) pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + // Log le DIDL-Lite envoyé (limité pour éviter de polluer les logs) + if !meta.is_empty() { + tracing::debug!( + "SetAVTransportURI - URI: {}, MetaData: {}", + &uri[..uri.len().min(80)], + &meta[..meta.len().min(500)] + ); + } + let args = [ ("InstanceID", "0"), ("CurrentURI", uri), diff --git a/pmocontrol/src/upnp_clients/openhome_client.rs b/pmocontrol/src/upnp_clients/openhome_client.rs index 2b387dce..e3e42c3d 100644 --- a/pmocontrol/src/upnp_clients/openhome_client.rs +++ b/pmocontrol/src/upnp_clients/openhome_client.rs @@ -895,6 +895,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option { date: item.date.clone(), track_number: item.original_track_number.clone(), creator: item.creator.clone(), + duration: item.resources.first().and_then(|r| r.duration.clone()), }) } diff --git a/pmomediaserver/src/content_handler.rs b/pmomediaserver/src/content_handler.rs index b631ae90..82ad80e2 100644 --- a/pmomediaserver/src/content_handler.rs +++ b/pmomediaserver/src/content_handler.rs @@ -32,6 +32,16 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result Resource { + // Calculer la durée restante (maintenant -> end_time) + // Cela permet au curseur de progresser de 0 jusqu'à la fin de l'émission + let duration = if let Some(end) = metadata.now.end_time { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + #[cfg(feature = "logging")] + tracing::debug!( + "Calculating duration: end_time={}, now={}, diff={}", + end, + now, + end.saturating_sub(now) + ); + + if end > now { + let duration_secs = end - now; + // Format UPnP: H:MM:SS ou H:MM:SS.F + let hours = duration_secs / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + let duration_str = format!("{}:{:02}:{:02}", hours, minutes, seconds); + + #[cfg(feature = "logging")] + tracing::info!("Track duration set to: {}", duration_str); + + Some(duration_str) + } else { + #[cfg(feature = "logging")] + tracing::warn!("end_time ({}) is in the past (now={})", end, now); + None + } + } else { + #[cfg(feature = "logging")] + tracing::debug!("No end_time available in metadata"); + None + }; + // Trouver le meilleur stream HiFi let best_stream = metadata.now.media.best_hifi_stream(); @@ -640,14 +691,27 @@ impl StationPlaylist { } }; - Resource { + let resource = Resource { protocol_info, bits_per_sample: None, sample_frequency, nr_audio_channels, - duration: None, // Stream live = pas de durée + duration: duration.clone(), // Durée calculée depuis start_time/end_time si disponible url, - } + }; + + #[cfg(feature = "logging")] + tracing::info!( + "Built resource with duration: {:?}, url: {}", + resource.duration, + if resource.url.is_empty() { + "" + } else { + &resource.url[..resource.url.len().min(50)] + } + ); + + resource } /// Retourne l'URL du stream From d3d2f24a6a60c1c7aee38f00c68ae5482ddfb7ef Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 22:30:10 +0100 Subject: [PATCH 12/13] Ajout du support du proxy pour les flux audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mise à jour de la fonction build_stream_resource pour intégrer l'URL du proxy et un fallback direct vers l'URL du flux. Cela permet de détecter l'activité du stream via le proxy PMOMusic. --- pmoradiofrance/src/playlist.rs | 39 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 8ffe9e0f..89f31ade 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -347,7 +347,7 @@ impl StationPlaylist { ); // Construction de la ressource (stream) - let resource = Self::build_stream_resource(metadata); + let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url); let item = Item { id: format!("radiofrance:{}:stream", station.slug), @@ -390,7 +390,7 @@ impl StationPlaylist { Self::extract_metadata_fields(station, metadata); let (album_art, album_art_pk) = Self::extract_cover_url(metadata, server_base_url); - let resource = Self::build_stream_resource(metadata); + let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url); Ok(Item { id: format!("radiofrance:{}:stream", station.slug), @@ -616,8 +616,12 @@ impl StationPlaylist { } } - /// Construit la ressource stream - fn build_stream_resource(metadata: &LiveResponse) -> Resource { + /// Construit la ressource stream avec URL du proxy + fn build_stream_resource( + metadata: &LiveResponse, + station_slug: &str, + server_base_url: Option<&str>, + ) -> Resource { // Calculer la durée restante (maintenant -> end_time) // Cela permet au curseur de progresser de 0 jusqu'à la fin de l'émission let duration = if let Some(end) = metadata.now.end_time { @@ -657,10 +661,24 @@ impl StationPlaylist { None }; - // Trouver le meilleur stream HiFi + // Construire l'URL du proxy ou fallback direct + let url = if let Some(base_url) = server_base_url { + // Utiliser le proxy PMOMusic pour détecter quand le stream est actif + format!("{}/api/radiofrance/{}/stream", base_url, station_slug) + } else { + // Fallback : utiliser l'URL directe si pas de base_url + metadata + .now + .media + .best_hifi_stream() + .map(|s| s.url.clone()) + .unwrap_or_default() + }; + + // Déterminer le protocol_info et caractéristiques audio let best_stream = metadata.now.media.best_hifi_stream(); - let (url, protocol_info, sample_frequency, nr_audio_channels) = match best_stream { + let (protocol_info, sample_frequency, nr_audio_channels) = match best_stream { Some(stream) => { let protocol_info = match stream.format { StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(), @@ -678,16 +696,11 @@ impl StationPlaylist { _ => None, }; - (stream.url.clone(), protocol_info, sample_freq, channels) + (protocol_info, sample_freq, channels) } None => { // Fallback : pas de stream trouvé - ( - String::new(), - "http-get:*:audio/aac:*".to_string(), - None, - None, - ) + ("http-get:*:audio/aac:*".to_string(), None, None) } }; From a0c1ab4228d2ea2ce442bcf1b62a266aa971160d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 22:41:54 +0100 Subject: [PATCH 13/13] Bump version to 0.3.12 and enhance Radio France source integration Bump version from 0.3.11 to 0.3.12 - Update Cargo.toml and version.txt to 0.3.12 - Refactor Radio France source registration to share Arc with API - Enhance API routes to start/stop metadata refresh on stream access - Add cleanup logic for stream disconnection - Improve logging for metadata refresh operations - Update Radio France source methods to be public for external access --- Cargo.lock | 2 +- PMOMusic/Cargo.toml | 2 +- pmomediaserver/src/sources.rs | 15 +++-- pmoradiofrance/src/api_rest.rs | 60 ++++++++++++++++- pmoradiofrance/src/pmoserver_ext.rs | 17 +++++ pmoradiofrance/src/pmoserver_impl.rs | 25 +++++++ pmoradiofrance/src/source.rs | 99 +++++++++++++++++++++++++++- version.txt | 2 +- 8 files changed, 209 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 197d6b5b..242c47ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.11" +version = "0.3.12" dependencies = [ "axum 0.8.7", "console-subscriber", diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index e075335a..081aece7 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.11" +version = "0.3.12" edition = "2024" [dependencies] diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 30ef3f13..bd17eb99 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -270,13 +270,16 @@ impl SourcesExt for Server { }); let source = source.with_container_notifier(notifier); - // Enregistrer la source - self.register_music_source(Arc::new(source)).await; + // Enregistrer la source (Arc pour partage avec l'API) + let source_arc = Arc::new(source); + self.register_music_source(source_arc.clone()).await; - // Initialiser les routes API Radio France - self.init_radiofrance().await.map_err(|e| { - SourceInitError::RadioFranceError(format!("Failed to init API routes: {}", e)) - })?; + // Initialiser les routes API Radio France avec la source + self.init_radiofrance_with_source(source_arc) + .await + .map_err(|e| { + SourceInitError::RadioFranceError(format!("Failed to init API routes: {}", e)) + })?; tracing::info!("✅ Radio France source registered successfully"); diff --git a/pmoradiofrance/src/api_rest.rs b/pmoradiofrance/src/api_rest.rs index 95f6eefa..3ef56a96 100644 --- a/pmoradiofrance/src/api_rest.rs +++ b/pmoradiofrance/src/api_rest.rs @@ -16,6 +16,7 @@ use axum::{ }; use futures::StreamExt; use serde_json; +use std::sync::Arc; // ============ Gestion des erreurs ============ @@ -95,6 +96,25 @@ async fn proxy_stream( State(state): State, Path(slug): Path, ) -> Result { + // Start metadata refresh when stream is accessed + #[cfg(feature = "logging")] + tracing::info!("Stream proxy accessed for station: {}", slug); + + if let Some(ref source) = state.source { + // Spawn refresh task (non-blocking) + let source_clone = Arc::clone(source); + let slug_clone = slug.clone(); + tokio::spawn(async move { + if let Err(e) = source_clone.start_metadata_refresh(&slug_clone).await { + #[cfg(feature = "logging")] + tracing::error!("Failed to start metadata refresh for {}: {}", slug_clone, e); + } + }); + } else { + #[cfg(feature = "logging")] + tracing::warn!("No source available to start metadata refresh"); + } + // Get the stream URL let stream_url = state .client @@ -116,12 +136,48 @@ async fn proxy_stream( headers.insert("content-type", "audio/aac".parse().unwrap()); headers.insert("cache-control", "no-cache".parse().unwrap()); - // Create streaming body + // Create streaming body with cleanup on disconnect let stream = response .bytes_stream() .map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); - let body = Body::from_stream(stream); + // Wrap the stream to detect when client disconnects + let source_for_cleanup = state.source.clone(); + let slug_for_cleanup = slug.clone(); + let monitored_stream = + futures::stream::unfold((stream, false), move |(mut stream, mut done)| { + let source = source_for_cleanup.clone(); + let slug = slug_for_cleanup.clone(); + async move { + if done { + return None; + } + + match stream.next().await { + Some(Ok(chunk)) => Some((Ok(chunk), (stream, false))), + Some(Err(e)) => { + // Error occurred, stop refresh + #[cfg(feature = "logging")] + tracing::info!("Stream error for {}, stopping refresh", slug); + if let Some(src) = source { + src.stop_metadata_refresh(&slug).await; + } + Some((Err(e), (stream, true))) + } + None => { + // Stream ended normally, stop refresh + #[cfg(feature = "logging")] + tracing::info!("Stream ended for {}, stopping refresh", slug); + if let Some(src) = source { + src.stop_metadata_refresh(&slug).await; + } + None + } + } + } + }); + + let body = Body::from_stream(monitored_stream); Ok((headers, body).into_response()) } diff --git a/pmoradiofrance/src/pmoserver_ext.rs b/pmoradiofrance/src/pmoserver_ext.rs index aa1f7bd1..bd46af26 100644 --- a/pmoradiofrance/src/pmoserver_ext.rs +++ b/pmoradiofrance/src/pmoserver_ext.rs @@ -12,14 +12,21 @@ use crate::stateful_client::RadioFranceStatefulClient; #[derive(Clone)] pub struct RadioFranceState { pub client: Arc, + pub source: Option>, } impl RadioFranceState { pub fn new(client: RadioFranceStatefulClient) -> Self { Self { client: Arc::new(client), + source: None, } } + + pub fn with_source(mut self, source: Arc) -> Self { + self.source = Some(source); + self + } } /// Trait pour étendre pmoserver avec les fonctionnalités Radio France @@ -80,6 +87,16 @@ pub trait RadioFranceExt { /// server.init_radiofrance().await?; /// ``` async fn init_radiofrance(&mut self) -> Result>; + + /// Initialise l'extension Radio France avec une source existante + /// + /// Cette méthode est similaire à `init_radiofrance()` mais utilise une source + /// déjà créée et enregistrée, permettant de partager la même instance entre + /// le MediaServer UPnP et les routes API REST. + async fn init_radiofrance_with_source( + &mut self, + source: Arc, + ) -> Result>; } // L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs) diff --git a/pmoradiofrance/src/pmoserver_impl.rs b/pmoradiofrance/src/pmoserver_impl.rs index 68017838..ae9bd586 100644 --- a/pmoradiofrance/src/pmoserver_impl.rs +++ b/pmoradiofrance/src/pmoserver_impl.rs @@ -56,4 +56,29 @@ impl RadioFranceExt for Server { Ok(Arc::new(state)) } + + async fn init_radiofrance_with_source( + &mut self, + source: Arc, + ) -> Result> { + info!("Initializing Radio France API with existing source..."); + + // Créer le client stateful + let config = pmoconfig::get_config(); + let client = RadioFranceStatefulClient::new(config) + .await + .map_err(|e| anyhow::anyhow!("Failed to create Radio France client: {}", e))?; + + // Créer l'état partagé avec la source + let state = RadioFranceState::new(client).with_source(source); + + // Créer et enregistrer le router + let router = create_router(state.clone()); + self.add_router("/api/radiofrance", router).await; + + info!("Radio France API initialized with source"); + info!("API endpoints available at /api/radiofrance/*"); + + Ok(Arc::new(state)) + } } diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index b8426f7e..8e7f90f1 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -151,7 +151,7 @@ impl RadioFranceSource { } /// Start metadata refresh task for a station - async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { + pub async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { let mut handles = self.refresh_handles.write().await; // If already running, do nothing @@ -177,11 +177,51 @@ impl RadioFranceSource { Ok(metadata) => { let delay = std::time::Duration::from_millis(metadata.delay_to_refresh); + #[cfg(feature = "logging")] + { + let artist = metadata + .now + .song + .as_ref() + .and_then(|s| { + if s.interpreters.is_empty() { + None + } else { + Some(s.artists_display()) + } + }) + .unwrap_or_else(|| "".to_string()); + tracing::debug!( + "Refreshed metadata for {}: title='{}' artist='{}' delay={}ms", + slug, + metadata.now.first_line.title.as_deref().unwrap_or(""), + artist, + metadata.delay_to_refresh + ); + } + // Update the playlist metadata #[cfg(feature = "cache")] { let mut pls = playlists.write().await; + + #[cfg(feature = "logging")] + tracing::debug!( + "Looking for playlist '{}' in cache, found: {}", + slug, + pls.contains_key(&slug) + ); + if let Some(playlist) = pls.get_mut(&slug) { + let old_title = playlist.stream_item.title.clone(); + + #[cfg(feature = "logging")] + tracing::debug!( + "Updating playlist for {}: current title = '{}'", + slug, + old_title + ); + let _: Result<()> = playlist .update_metadata( &metadata, @@ -190,6 +230,18 @@ impl RadioFranceSource { ) .await; + let new_title = playlist.stream_item.title.clone(); + + #[cfg(feature = "logging")] + if old_title != new_title { + tracing::info!( + "Metadata updated for {}: {} -> {}", + slug, + old_title, + new_title + ); + } + // Update change tracking *update_id.write().await = update_id.read().await.wrapping_add(1); *last_change.write().await = Some(SystemTime::now()); @@ -198,6 +250,13 @@ impl RadioFranceSource { if let Some(ref notifier) = container_notifier { // Notify the station's stream item container let container_id = format!("radiofrance:{}", slug); + + #[cfg(feature = "logging")] + tracing::info!( + "Notifying UPnP container update: {}", + container_id + ); + notifier(&[container_id]); } } @@ -206,12 +265,41 @@ impl RadioFranceSource { #[cfg(not(feature = "cache"))] { let mut pls = playlists.write().await; + + #[cfg(feature = "logging")] + tracing::debug!( + "Looking for playlist '{}' in cache, found: {}", + slug, + pls.contains_key(&slug) + ); + if let Some(playlist) = pls.get_mut(&slug) { + let old_title = playlist.stream_item.title.clone(); + + #[cfg(feature = "logging")] + tracing::debug!( + "Updating playlist for {}: current title = '{}'", + slug, + old_title + ); + let _: Result<()> = playlist.update_metadata_no_cache( &metadata, server_base_url.as_deref(), ); + let new_title = playlist.stream_item.title.clone(); + + #[cfg(feature = "logging")] + if old_title != new_title { + tracing::info!( + "Metadata updated for {}: {} -> {}", + slug, + old_title, + new_title + ); + } + // Update change tracking *update_id.write().await = update_id.read().await.wrapping_add(1); *last_change.write().await = Some(SystemTime::now()); @@ -220,6 +308,13 @@ impl RadioFranceSource { if let Some(ref notifier) = container_notifier { // Notify the station's stream item container let container_id = format!("radiofrance:{}", slug); + + #[cfg(feature = "logging")] + tracing::info!( + "Notifying UPnP container update: {}", + container_id + ); + notifier(&[container_id]); } } @@ -246,7 +341,7 @@ impl RadioFranceSource { } /// Stop metadata refresh task for a station - async fn stop_metadata_refresh(&self, station_slug: &str) { + pub async fn stop_metadata_refresh(&self, station_slug: &str) { let mut handles = self.refresh_handles.write().await; if let Some(handle) = handles.remove(station_slug) { handle.abort(); diff --git a/version.txt b/version.txt index 20805912..0b9c0199 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.11 +0.3.12