From c250801a9f21192b7eee16ee0c5caed64e890a08 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 15 Jan 2026 08:15:39 +0100 Subject: [PATCH] =?UTF-8?q?Documentation=20compl=C3=A8te=20des=20patterns?= =?UTF-8?q?=20d'extension=20pmoconfig,=20pmoserver=5Fext=20et=20impl=C3=A9?= =?UTF-8?q?mentation=20MusicSource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de la documentation complète pour les patterns d'extension pmoconfig, pmoserver_ext et l'implémentation d'une nouvelle MusicSource, incluant les guides détaillés, exemples de code et checklists d'implémentation. --- Blackboard/Architecture/music_source.md | 921 +++++++++++++++ Blackboard/Architecture/pmoconfig_ext.md | 1074 +++++++++++++++++ Blackboard/Architecture/pmoserver_ext.md | 1276 ++++++++++++--------- Blackboard/Report/config_ext.md | 96 ++ Blackboard/Report/music_source.md | 227 ++++ Blackboard/Report/pmoserver_ext.md | 192 +--- Blackboard/Rules.md | 6 +- Blackboard/ToDiscuss/pmoserver_ext.md | 21 + Blackboard/ToThinkAbout/PlayListSource.md | 570 +++++++++ Blackboard/Todo/Pinnable_cache_item.md | 8 + Blackboard/Todo/WeabApp_debouncingSSE.md | 5 + Blackboard/Todo/config_ext.md | 17 + Blackboard/Todo/music_source.md | 14 + 13 files changed, 3732 insertions(+), 695 deletions(-) create mode 100644 Blackboard/Architecture/music_source.md create mode 100644 Blackboard/Architecture/pmoconfig_ext.md create mode 100644 Blackboard/Report/config_ext.md create mode 100644 Blackboard/Report/music_source.md create mode 100644 Blackboard/ToThinkAbout/PlayListSource.md create mode 100644 Blackboard/Todo/Pinnable_cache_item.md create mode 100644 Blackboard/Todo/config_ext.md create mode 100644 Blackboard/Todo/music_source.md diff --git a/Blackboard/Architecture/music_source.md b/Blackboard/Architecture/music_source.md new file mode 100644 index 00000000..977db626 --- /dev/null +++ b/Blackboard/Architecture/music_source.md @@ -0,0 +1,921 @@ +# Guide d'implémentation d'une nouvelle MusicSource + +Ce document décrit comment implémenter une nouvelle source musicale dans l'écosystème PMOMusic en suivant le trait `MusicSource` défini dans le crate `pmosource`. + +## Table des matières + +1. [Vue d'ensemble](#vue-densemble) +2. [Structure d'une MusicSource](#structure-dune-musicsource) +3. [Implémentation du trait MusicSource](#implémentation-du-trait-musicsource) +4. [Patterns d'implémentation](#patterns-dimplémentation) +5. [Intégration avec l'écosystème PMOMusic](#intégration-avec-lécosystème-pmomusic) +6. [Checklist de mise en œuvre](#checklist-de-mise-en-œuvre) +7. [Exemples de référence](#exemples-de-référence) + +## Vue d'ensemble + +Une `MusicSource` est une abstraction qui représente une source de contenu musical dans PMOMusic. Elle peut être : + +- **Dynamique (FIFO)** : Radio Paradise, streaming radio, playlists live +- **Statique** : Albums Qobuz, bibliothèque locale, playlists fixes + +Le trait `MusicSource` définit une interface unifiée pour : +- La navigation UPnP ContentDirectory (browse) +- La résolution d'URI audio (avec cache) +- La gestion de playlists FIFO (pour les sources dynamiques) +- Le suivi des changements (update_id, last_change) + +## Structure d'une MusicSource + +### Organisation du code + +``` +pmo/ +├── src/ +│ ├── lib.rs # Exports publics +│ ├── source.rs # Implémentation MusicSource +│ ├── client.rs # Client API (optionnel) +│ ├── models.rs # Structures de données +│ ├── config.rs # Configuration +│ └── didl.rs # Conversion DIDL-Lite (optionnel) +├── assets/ +│ └── default.webp # Logo 300x300px +├── Cargo.toml +└── README.md +``` + +### Dépendances principales + +```toml +[dependencies] +pmosource = { path = "../pmosource" } +pmodidl = { path = "../pmodidl" } +pmoplaylist = { path = "../pmoplaylist", optional = true } # Si FIFO +pmoaudiocache = { path = "../pmoaudiocache", optional = true } # Si cache +pmocovers = { path = "../pmocovers", optional = true } # Si cache + +async-trait = "0.1" +tokio = { version = "1", features = ["sync"] } +serde = { version = "1", features = ["derive"] } + +[features] +default = ["cache"] +cache = ["pmoaudiocache", "pmocovers"] +playlist = ["pmoplaylist"] +``` + +## Implémentation du trait MusicSource + +### 1. Informations de base + +Chaque source doit fournir : + +```rust +use pmosource::{async_trait, MusicSource}; + +#[derive(Clone, Debug)] +pub struct MyMusicSource { + // Champs internes +} + +#[async_trait] +impl MusicSource for MyMusicSource { + fn name(&self) -> &str { + "Ma Source Musicale" // Nom affiché dans l'UI + } + + fn id(&self) -> &str { + "my-music-source" // ID unique (format: lowercase-kebab-case) + } + + fn default_image(&self) -> &[u8] { + // Logo WebP 300x300px inclus dans le binaire + include_bytes!("../assets/default.webp") + } + + fn default_image_mime_type(&self) -> &str { + "image/webp" // Toujours WebP + } +} +``` + +**Règles :** +- `id()` doit être unique parmi toutes les sources +- `id()` doit être en lowercase-kebab-case +- `default_image()` doit être un WebP 300x300px + +### 2. Navigation ContentDirectory + +#### 2.1 Container racine + +```rust +async fn root_container(&self) -> Result { + Ok(Container { + id: self.id().to_string(), // "my-music-source" + parent_id: "0".to_string(), // Toujours "0" pour la racine + restricted: Some("1".to_string()), + child_count: None, // Optionnel + searchable: Some("1".to_string()), + title: self.name().to_string(), + class: "object.container".to_string(), + artist: None, + album_art: None, + containers: vec![], + items: vec![], + }) +} +``` + +#### 2.2 Browse + +La méthode `browse()` est le cœur de la navigation : + +```rust +async fn browse(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Root => { + // Retourner les sous-containers principaux + let containers = vec![ + self.build_albums_container(), + self.build_playlists_container(), + self.build_favorites_container(), + ]; + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Album { album_id } => { + // Retourner le container + ses tracks + let album_container = self.build_album_container(&album_id); + let tracks = self.get_album_tracks(&album_id).await?; + Ok(BrowseResult::Mixed { + containers: vec![album_container], + items: tracks, + }) + } + + ObjectIdType::Track { track_id } => { + // Retourner les détails d'un track + let track = self.get_track_item(&track_id).await?; + Ok(BrowseResult::Items(vec![track])) + } + + _ => Err(MusicSourceError::ObjectNotFound( + format!("Unknown object: {}", object_id) + )) + } +} +``` + +**Schema d'Object ID recommandé :** + +``` + # Racine +:albums # Container albums +:album: # Album spécifique +:track: # Track spécifique +:playlist: # Playlist spécifique +``` + +**Types de BrowseResult :** +- `Containers(Vec)` : Liste de containers (navigation) +- `Items(Vec)` : Liste de tracks (lecture) +- `Mixed { containers, items }` : Les deux (album avec tracks) + +#### 2.3 Résolution d'URI + +```rust +async fn resolve_uri(&self, object_id: &str) -> Result { + // Étape 1 : Vérifier le cache audio + if let Some(cached_pk) = self.get_cached_audio_pk(object_id).await { + return Ok(format!("{}/audio/flac/{}", self.base_url, cached_pk)); + } + + // Étape 2 : Retourner l'URI originale + match self.parse_object_id(object_id) { + ObjectIdType::Track { track_id } => { + let stream_url = self.get_stream_url(&track_id).await?; + Ok(stream_url) + } + _ => Err(MusicSourceError::UriResolutionError( + format!("Cannot resolve URI for: {}", object_id) + )) + } +} +``` + +**Ordre de résolution :** +1. Cache audio local (si disponible) +2. URI originale (API streaming, fichier local, etc.) + +### 3. Support FIFO (sources dynamiques) + +Si votre source est dynamique (radio, streaming live) : + +```rust +use pmoplaylist::PlaylistManager; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub struct RadioSource { + playlist_id: String, + update_counter: Arc>, + last_change: Arc>, +} + +#[async_trait] +impl MusicSource for RadioSource { + fn supports_fifo(&self) -> bool { + true // Cette source utilise une FIFO + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Récupérer le gestionnaire de playlist + let manager = PlaylistManager(); + let writer = manager + .get_persistent_write_handle(self.playlist_id.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // Extraire le PK depuis l'URI du track + let pk = self.extract_pk_from_item(&track)?; + + // Ajouter à la playlist + writer + .push_lazy(pk) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // Incrémenter update_id + self.bump_update_counter().await; + + Ok(()) + } + + async fn remove_oldest(&self) -> Result> { + let manager = PlaylistManager(); + let reader = manager + .get_read_handle(&self.playlist_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // Récupérer le plus ancien + let items = reader.to_items(1).await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + if let Some(item) = items.first() { + // Adapter l'item au schéma de la source + let adapted = self.adapt_item_to_schema(item.clone()); + self.bump_update_counter().await; + Ok(Some(adapted)) + } else { + Ok(None) + } + } + + async fn update_id(&self) -> u32 { + *self.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + let manager = PlaylistManager(); + let reader = manager + .get_read_handle(&self.playlist_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // Récupérer les items + let items = reader + .to_items(count) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // Adapter au schéma de la source + let adapted = items.into_iter() + .map(|item| self.adapt_item_to_schema(item)) + .collect(); + + Ok(adapted) + } +} + +impl RadioSource { + async fn bump_update_counter(&self) { + let mut counter = self.update_counter.write().await; + *counter = counter.wrapping_add(1).max(1); + let mut last = self.last_change.write().await; + *last = SystemTime::now(); + } +} +``` + +**Points clés :** +- Utiliser `pmoplaylist::PlaylistManager` singleton +- Incrémenter `update_id` à chaque modification +- Mettre à jour `last_change` à chaque modification +- Adapter les IDs des items au schéma de la source + +### 4. Support statique (albums, bibliothèques) + +Si votre source est statique (catalogue, albums) : + +```rust +#[async_trait] +impl MusicSource for CatalogSource { + fn supports_fifo(&self) -> bool { + false // Pas de FIFO + } + + async fn append_track(&self, _track: Item) -> Result<()> { + Err(MusicSourceError::NotSupported( + "This source is read-only".to_string() + )) + } + + async fn remove_oldest(&self) -> Result> { + Ok(None) // Pas de suppression + } + + async fn update_id(&self) -> u32 { + 0 // Jamais de changement + } + + async fn last_change(&self) -> Option { + None // Pas de suivi des changements + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // Retourner une liste paginée depuis le catalogue + self.get_catalog_items(offset, count).await + } +} +``` + +## Patterns d'implémentation + +### Pattern 1 : Source dynamique avec FIFO (Radio Paradise) + +**Caractéristiques :** +- Flux continu de tracks +- Capacité limitée (50-100 tracks) +- Suppression automatique des plus anciens +- `supports_fifo() = true` + +**Structure :** + +```rust +#[derive(Clone)] +pub struct RadioParadiseSource { + base_url: String, + update_counter: Arc>, + last_change: Arc>, + callback_tokens: Arc>>, + container_notifier: Option>, +} + +impl RadioParadiseSource { + // Enregistrer des callbacks sur les playlists pour notifier les changements + pub fn attach_playlist_callbacks(self: &Arc) { + let playlist_ids = vec![ + self.live_playlist_id(), + self.history_playlist_id(), + ]; + + let manager = PlaylistManager(); + let mut tokens = self.callback_tokens.lock().unwrap(); + + for pid in playlist_ids { + let weak = Arc::downgrade(self); + let pid_clone = pid.clone(); + let token = manager.register_callback(move |event| { + if event.playlist_id == pid_clone { + if let Some(strong) = weak.upgrade() { + tokio::spawn(async move { + strong.bump_update_counter().await; + // Notifier ContentDirectory + if let Some(notifier) = strong.container_notifier.as_ref() { + notifier(&[format!("radio-paradise:history")]); + } + }); + } + } + }); + tokens.push(token); + } + } +} +``` + +**Points clés :** +- Callbacks sur `pmoplaylist` pour détecter les changements +- Notification du ContentDirectory via un notifier injecté +- `update_counter` partagé via `Arc>` + +### Pattern 2 : Source catalogue avec playlists lazy (Qobuz) + +**Caractéristiques :** +- Catalogue vaste (millions de tracks) +- Playlists créées à la demande +- Cache lazy (cover eager, audio lazy) +- `supports_fifo() = false` + +**Structure :** + +```rust +#[derive(Clone)] +pub struct QobuzSource { + inner: Arc, +} + +struct QobuzSourceInner { + client: Arc, + cache_manager: SourceCacheManager, + base_url: String, + update_counter: tokio::sync::RwLock, + last_change: tokio::sync::RwLock, +} + +impl QobuzSource { + // Ajouter un track avec cache lazy + pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> { + let track_id = format!("qobuz://track/{}", track.id); + let lazy_pk = format!("QOBUZ:{}", track.id); + + // 1. Cache cover EAGERLY (petit, UI en a besoin) + let cached_cover_pk = if let Some(ref image_url) = track.album.as_ref() + .and_then(|a| a.image.as_ref()) { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + }; + + // 2. Préparer metadata + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + // ... autres champs + }; + + // 3. Cache audio LAZILY (grand, téléchargé à la demande) + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio_lazy_with_provider( + &lazy_pk, + Some(metadata.clone()), + cached_cover_pk.clone(), + ) + .await?; + + // 4. Stocker metadata + self.inner.cache_manager.update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk: Some(cached_audio_pk.clone()), + cached_cover_pk, + }, + ).await; + + Ok((track_id, cached_audio_pk)) + } + + // Créer une playlist d'album avec TTL + async fn get_or_create_album_playlist_items( + &self, + album_id: &str, + limit: usize, + ) -> Result> { + const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); + + let playlist_id = format!("qobuz-album-{}", album_id); + let playlist_manager = PlaylistManager(); + + // Vérifier validité (existe ET non expirée ET non vide) + let is_valid = self.is_album_playlist_valid(&playlist_id).await?; + + if is_valid { + // Récupérer depuis playlist existante + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + let items = reader.to_items(limit).await?; + return self.adapt_playlist_items_to_qobuz(items, album_id).await; + } + + // Créer nouvelle playlist + let writer = playlist_manager + .create_persistent_playlist_with_role( + playlist_id.clone(), + pmoplaylist::PlaylistRole::Album, + ) + .await?; + + // Ajouter tracks avec cache lazy + self.add_album_to_playlist(&playlist_id, album_id).await?; + + // Récupérer items + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + let items = reader.to_items(limit).await?; + self.adapt_playlist_items_to_qobuz(items, album_id).await + } +} +``` + +**Points clés :** +- Cache lazy pour l'audio (téléchargé à la demande) +- Cache eager pour les covers (petit, UI en a besoin) +- Playlists avec TTL (7 jours) +- `LazyProvider` pour télécharger l'audio lors de la lecture + +### Pattern 3 : Adaptation des IDs entre playlist et source + +Lorsqu'une source utilise `pmoplaylist`, les items retournés ont des IDs génériques. Il faut les adapter au schéma de la source : + +```rust +async fn adapt_playlist_items_to_source( + &self, + items: Vec, + parent_id: &str, +) -> Result> { + let mut adapted = Vec::with_capacity(items.len()); + + for mut item in items { + // Extraire cache_pk depuis l'URL du resource + let cache_pk = if let Some(resource) = item.resources.first() { + resource + .url + .strip_prefix("/audio/flac/") + .map(|s| s.to_string()) + } else { + None + }; + + if let Some(pk) = cache_pk { + // Récupérer source_track_id depuis metadata + if let Ok(Some(track_id_value)) = self + .cache_manager + .get_audio_metadata(&pk, "source_track_id") + { + if let Some(track_id) = track_id_value.as_str() { + item.id = format!("my-source:track:{}", track_id); + } + } + + // Convertir URL relative en absolue + if let Some(resource) = item.resources.first_mut() { + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.base_url, resource.url); + } + } + } + + item.parent_id = parent_id.to_string(); + + // Normaliser album art + if let Some(art) = item.album_art.as_mut() { + if art.starts_with('/') { + *art = format!("{}{}", self.base_url, art); + } + } else { + item.album_art = Some(self.default_cover_url()); + } + + // Ajouter genre par défaut si absent (requis par certains clients) + if item.genre.is_none() { + item.genre = Some("Music".to_string()); + } + + adapted.push(item); + } + + Ok(adapted) +} +``` + +**Points clés :** +- Stocker `source_track_id` dans les metadata du cache audio +- Reconstituer l'ID correct lors de la récupération depuis playlist +- Normaliser URLs (relatives → absolues) +- Ajouter champs requis par certains clients UPnP + +## Intégration avec l'écosystème PMOMusic + +### Avec pmoplaylist + +Pour les sources dynamiques et les catalogues : + +```rust +use pmoplaylist::{PlaylistManager, PlaylistRole}; + +// Créer une playlist persistante +let manager = PlaylistManager(); +let writer = manager + .create_persistent_playlist_with_role( + "my-source-album-123".to_string(), + PlaylistRole::Album, + ) + .await?; + +// Configurer metadata +writer.set_title("Album Title".to_string()).await?; +writer.set_artist(Some("Artist Name".to_string())).await?; +writer.set_cover_pk(Some("cover-pk".to_string())).await?; + +// Ajouter tracks avec cache lazy +writer.push_lazy_batch(vec!["pk1", "pk2", "pk3"]).await?; + +// Activer mode lazy (lookahead 2 tracks) +manager.enable_lazy_mode("my-source-album-123", 2); +``` + +### Avec pmoaudiocache et pmocovers (via SourceCacheManager) + +```rust +use pmosource::SourceCacheManager; + +// Créer le manager centralisé +let cache_manager = SourceCacheManager::from_registry("my-source".to_string())?; + +// Enregistrer un LazyProvider +cache_manager.register_lazy_provider(Arc::new(MyLazyProvider::new(client))); + +// Cache eager (cover) +let cover_pk = cache_manager.cache_cover("https://example.com/cover.jpg").await?; + +// Cache lazy (audio) +let audio_pk = cache_manager + .cache_audio_lazy_with_provider( + "MY-SOURCE:123", // Lazy PK + Some(metadata), + Some(cover_pk), + ) + .await?; + +// Récupérer metadata +let value = cache_manager.get_audio_metadata(&audio_pk, "key").await?; +``` + +**LazyProvider personnalisé :** + +```rust +use pmoaudiocache::{LazyProvider, LazyProviderError}; + +pub struct MyLazyProvider { + client: Arc, +} + +#[async_trait] +impl LazyProvider for MyLazyProvider { + async fn fetch_audio(&self, lazy_pk: &str) -> Result, LazyProviderError> { + // Extraire l'ID depuis le lazy_pk + let id = lazy_pk + .strip_prefix("MY-SOURCE:") + .ok_or_else(|| LazyProviderError::InvalidKey)?; + + // Récupérer l'URL de streaming + let stream_url = self.client.get_stream_url(id).await + .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?; + + // Télécharger l'audio + let response = reqwest::get(&stream_url).await + .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?; + + let bytes = response.bytes().await + .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?; + + Ok(bytes.to_vec()) + } +} +``` + +### Avec pmodidl + +Conversion de vos structures en DIDL-Lite : + +```rust +use pmodidl::{Container, Item, Resource}; + +// Container +pub trait ToDIDLContainer { + fn to_didl_container(&self, parent_id: &str) -> Result; +} + +impl ToDIDLContainer for MyAlbum { + fn to_didl_container(&self, parent_id: &str) -> Result { + Ok(Container { + id: format!("my-source:album:{}", self.id), + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + searchable: Some("1".to_string()), + title: self.title.clone(), + class: "object.container.album.musicAlbum".to_string(), + artist: Some(self.artist.name.clone()), + album_art: self.cover_url.clone(), + containers: vec![], + items: vec![], + }) + } +} + +// Item +pub trait ToDIDLItem { + fn to_didl_item(&self, parent_id: &str) -> Result; +} + +impl ToDIDLItem for MyTrack { + fn to_didl_item(&self, parent_id: &str) -> Result { + Ok(Item { + id: format!("my-source:track:{}", self.id), + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: self.title.clone(), + creator: self.artist.as_ref().map(|a| a.name.clone()), + class: "object.item.audioItem.musicTrack".to_string(), + artist: self.artist.as_ref().map(|a| a.name.clone()), + album: self.album.as_ref().map(|a| a.title.clone()), + genre: Some("Music".to_string()), + album_art: self.cover_url.clone(), + album_art_pk: self.cover_pk.clone(), + date: self.release_date.clone(), + original_track_number: Some(self.track_number), + resources: vec![Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: self.bit_depth.map(|b| b.to_string()), + sample_frequency: self.sample_rate.map(|s| s.to_string()), + nr_audio_channels: Some("2".to_string()), + duration: self.duration_as_upnp_format(), + url: format!("/audio/flac/{}", self.cache_pk), + }], + descriptions: vec![], + }) + } +} +``` + +## Checklist de mise en œuvre + +### Phase 1 : Structure de base + +- [ ] Créer le crate `pmo` +- [ ] Ajouter les dépendances dans `Cargo.toml` +- [ ] Créer le logo WebP 300x300px dans `assets/` +- [ ] Définir la structure principale +- [ ] Implémenter `name()`, `id()`, `default_image()` + +### Phase 2 : Navigation ContentDirectory + +- [ ] Définir le schéma d'Object ID +- [ ] Implémenter `root_container()` +- [ ] Implémenter `browse()` pour la racine +- [ ] Implémenter `browse()` pour les sous-containers +- [ ] Implémenter `browse()` pour les items +- [ ] Tester la navigation avec un client UPnP + +### Phase 3 : Résolution d'URI + +- [ ] Implémenter `resolve_uri()` avec fallback +- [ ] Intégrer avec `SourceCacheManager` +- [ ] Implémenter `LazyProvider` si cache lazy +- [ ] Tester la lecture audio + +### Phase 4 : Support FIFO (si dynamique) + +- [ ] Décider de la stratégie FIFO +- [ ] Implémenter `supports_fifo() = true` +- [ ] Implémenter `append_track()` +- [ ] Implémenter `remove_oldest()` +- [ ] Implémenter `update_id()` et `last_change()` +- [ ] Enregistrer callbacks sur playlists +- [ ] Tester ajout/suppression de tracks + +### Phase 5 : Support statique (si catalogue) + +- [ ] Implémenter `supports_fifo() = false` +- [ ] Implémenter `get_items()` avec pagination +- [ ] Implémenter `search()` si applicable +- [ ] Tester browsing du catalogue + +### Phase 6 : Intégration avancée + +- [ ] Implémenter `get_item()` pour metadata +- [ ] Implémenter `capabilities()` +- [ ] Implémenter `get_available_formats()` +- [ ] Ajouter gestion d'erreurs robuste +- [ ] Documenter le code + +### Phase 7 : Tests et validation + +- [ ] Écrire tests unitaires +- [ ] Écrire tests d'intégration +- [ ] Tester avec différents clients UPnP +- [ ] Valider les performances +- [ ] Documenter les limitations + +## Exemples de référence + +### Radio Paradise (source dynamique FIFO) + +**Fichier :** `pmoparadise/src/source.rs` + +**Points d'intérêt :** +- Structure avec `Arc>` pour l'état partagé +- Callbacks sur playlists pour détecter les changements +- Notifier injecté pour ContentDirectory +- Adaptation des IDs playlist → Radio Paradise +- Support de 4 canaux avec sous-containers + +**Schema d'Object ID :** +``` +radio-paradise # Racine +radio-paradise:channel:{slug} # Canal (main, mellow, rock, eclectic) +radio-paradise:channel:{slug}:live # Stream live +radio-paradise:channel:{slug}:liveplaylist # Playlist live (queue) +radio-paradise:channel:{slug}:liveplaylist:track:{pk} # Track dans queue +radio-paradise:channel:{slug}:history # Historique +radio-paradise:channel:{slug}:history:track:{pk} # Track dans historique +``` + +### Qobuz (source catalogue avec playlists lazy) + +**Fichier :** `pmoqobuz/src/source.rs` + +**Points d'intérêt :** +- `SourceCacheManager` centralisé +- Cache lazy pour audio, eager pour covers +- `LazyProvider` personnalisé +- Playlists d'albums avec TTL (7 jours) +- Adaptation IDs playlist → Qobuz +- Navigation hiérarchique complexe (Discover, Genres, Favorites) + +**Schema d'Object ID :** +``` +qobuz # Racine +qobuz:discover # Discover Catalog +qobuz:discover:albums:ideal # Albums (Ideal Discography) +qobuz:discover:artists # Artistes Featured +qobuz:genres # Discover Genres +qobuz:genre:{id} # Genre spécifique +qobuz:genre:{id}:new-releases # Nouveautés du genre +qobuz:favorites # My Music +qobuz:favorites:albums # Albums favoris +qobuz:album:{id} # Album spécifique +qobuz:track:{id} # Track spécifique +qobuz:playlist:{id} # Playlist spécifique +qobuz:artist:{id} # Artiste spécifique +``` + +## Conseils d'implémentation + +### Performance + +1. **Cache agressif** : Utilisez `SourceCacheManager` pour tout +2. **Pagination** : Limitez le nombre d'items retournés (max 100) +3. **Lazy loading** : Ne chargez que ce qui est demandé +4. **Rate limiting** : Respectez les limites API de la source +5. **Arc<>** : Partagez les données coûteuses + +### Compatibilité UPnP + +1. **Genre obligatoire** : Certains clients (gupnp-av-cp) requièrent `` +2. **URLs absolues** : Toujours retourner des URLs complètes (pas de chemins relatifs) +3. **Protocol Info** : Utilisez `http-get:*:audio/flac:*` pour FLAC +4. **Duration** : Format `H:MM:SS` (ex: `0:03:45`) +5. **childCount** : Optionnel mais recommandé pour l'UI + +### Gestion d'erreurs + +1. **ObjectNotFound** : ID invalide +2. **BrowseError** : Erreur générique de navigation +3. **UriResolutionError** : Impossible de résoudre l'URI +4. **PlaylistError** : Erreur d'interaction avec pmoplaylist +5. **CacheError** : Erreur de cache + +### Thread Safety + +1. **Arc>** : Pour l'état mutable partagé +2. **tokio::sync::RwLock** : Pour l'async +3. **Éviter Rc<>** : Pas thread-safe +4. **Clone** : Implémentez `Clone` pour `Arc<>` + +## Conclusion + +L'implémentation d'une nouvelle `MusicSource` suit ces étapes : + +1. **Définir le schéma d'Object ID** : Hiérarchie claire et cohérente +2. **Implémenter la navigation** : `browse()` pour tous les niveaux +3. **Résoudre les URIs** : Cache local d'abord, puis original +4. **Gérer le cache** : `SourceCacheManager` + `LazyProvider` +5. **Adapter les IDs** : Playlist → Schema de la source +6. **Notifier les changements** : `update_id` + callbacks + +Les exemples Radio Paradise et Qobuz couvrent les deux patterns principaux : +- **Dynamique FIFO** : Radio Paradise +- **Catalogue lazy** : Qobuz + +En suivant ces patterns, vous obtiendrez une source musicale performante, compatible UPnP, et bien intégrée dans l'écosystème PMOMusic. diff --git a/Blackboard/Architecture/pmoconfig_ext.md b/Blackboard/Architecture/pmoconfig_ext.md new file mode 100644 index 00000000..911061e4 --- /dev/null +++ b/Blackboard/Architecture/pmoconfig_ext.md @@ -0,0 +1,1074 @@ +# Pattern d'extension de pmoconfig::Config + +## Vue d'ensemble + +Ce document décrit le pattern architectural utilisé dans PMOMusic pour étendre la configuration centralisée (`pmoconfig::Config`) avec des fonctionnalités spécifiques à chaque crate. + +### Objectif + +Permettre à chaque crate du projet d'ajouter ses propres méthodes de configuration sans modifier directement `pmoconfig`, tout en maintenant une interface cohérente et type-safe. + +### Principe + +Chaque crate qui nécessite un accès à la configuration implémente un **trait d'extension** pour `pmoconfig::Config`. Ce trait définit des méthodes helpers spécifiques au domaine du crate (cache, authentification, UPnP, etc.). + +## Architecture du pattern + +### Structure de base + +``` +pmoconfig/ # Crate de configuration centralisée + ├── Config # Struct principale avec get_value/set_value génériques + └── encryption # Module de chiffrement des mots de passe + +pmocrate/ # Crate spécialisé (audio, qobuz, upnp, etc.) + └── config_ext.rs # Trait d'extension pour Config + ├── DEFAULT_* # Constantes pour valeurs par défaut + ├── XxxConfigExt # Trait d'extension + └── impl # Implémentation du trait pour Config +``` + +### Flux de données + +``` +Application + ↓ +Trait d'extension spécialisé (QobuzConfigExt, CacheConfigExt, etc.) + ↓ +pmoconfig::Config (get_value/set_value génériques) + ↓ +Fichier config.yaml +``` + +## Implémentation d'un trait d'extension + +### 1. Structure du fichier config_ext.rs + +```rust +//! Extension pour intégrer [fonctionnalité] dans pmoconfig +//! +//! Ce module fournit le trait `XxxConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion de [fonctionnalité] à pmoconfig::Config. + +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +// Constantes pour valeurs par défaut +const DEFAULT_XXX_DIR: &str = "cache_xxx"; +const DEFAULT_XXX_SIZE: usize = 1000; + +/// Trait d'extension pour gérer [fonctionnalité] dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// à la gestion de [fonctionnalité]. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoxxx::XxxConfigExt; +/// +/// let config = get_config(); +/// let value = config.get_xxx_value()?; +/// ``` +pub trait XxxConfigExt { + /// Documentation de la méthode getter + fn get_xxx_value(&self) -> Result; + + /// Documentation de la méthode setter + fn set_xxx_value(&self, value: Type) -> Result<()>; +} + +impl XxxConfigExt for Config { + fn get_xxx_value(&self) -> Result { + // Implémentation + } + + fn set_xxx_value(&self, value: Type) -> Result<()> { + // Implémentation + } +} +``` + +### 2. Patterns de chemins YAML + +Les chemins dans la configuration suivent une hiérarchie logique : + +#### Configuration hôte/système +```rust +// Chemins sous "host" +&["host", "cache_type", "directory"] // Répertoires de cache +&["host", "cache_type", "size"] // Tailles de cache +&["host", "upnp", "manufacturer"] // Configuration UPnP +``` + +#### Configuration comptes/services +```rust +// Chemins sous "accounts" +&["accounts", "service", "username"] +&["accounts", "service", "password"] +&["accounts", "service", "auth_token"] +``` + +#### Configuration sources +```rust +// Chemins sous "sources" +&["sources", "source_name", "enabled"] +&["sources", "source_name", "default_channel"] +``` + +### 3. Patterns de getters + +#### Getter simple avec valeur par défaut + +```rust +fn get_xxx_value(&self) -> Result { + match self.get_value(&["path", "to", "value"]) { + Ok(Value::Type(v)) => Ok(v), + _ => Ok(DEFAULT_VALUE), + } +} +``` + +#### Getter avec auto-persistence + +Pour les valeurs qui doivent être visibles dans le fichier YAML, le getter persiste automatiquement la valeur par défaut : + +```rust +fn get_xxx_enabled(&self) -> Result { + match self.get_value(&["path", "to", "enabled"]) { + Ok(Value::Bool(b)) => Ok(b), + _ => { + // Auto-persist la valeur par défaut + self.set_xxx_enabled(true)?; + Ok(true) + } + } +} +``` + +**Avantage** : L'utilisateur voit la configuration effective dans le YAML et peut la modifier facilement. + +#### Getter optionnel + +Pour les valeurs vraiment optionnelles (pas de défaut significatif) : + +```rust +fn get_xxx_optional(&self) -> Result> { + match self.get_value(&["path", "to", "optional"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // String vide + Ok(_) => Ok(None), // Mauvais type + Err(_) => Ok(None), // Non configuré + } +} +``` + +#### Getter avec traitement spécial + +##### Déchiffrement de mots de passe + +```rust +fn get_xxx_password(&self) -> Result { + match self.get_value(&["accounts", "xxx", "password"])? { + Value::String(s) => { + // Déchiffrement automatique si chiffré + pmoconfig::encryption::get_password(&s) + .map_err(|e| anyhow!("Failed to decrypt password: {}", e)) + } + _ => Err(anyhow!("Password not configured")), + } +} +``` + +##### Parsing avec fallback + +```rust +fn get_xxx_enum_value(&self) -> Result { + match self.get_value(&["path", "to", "value"]) { + Ok(Value::String(s)) => { + match s.parse::() { + Ok(kind) => Ok(kind), + Err(_) => { + // Valeur invalide, utiliser et persister le défaut + self.set_xxx_enum_value(DEFAULT_ENUM)?; + Ok(DEFAULT_ENUM) + } + } + } + Ok(Value::Number(n)) => { + // Accepter aussi les valeurs numériques + if let Some(id) = n.as_u64() { + EnumType::from_id(id as u8) + } else { + self.set_xxx_enum_value(DEFAULT_ENUM)?; + Ok(DEFAULT_ENUM) + } + } + _ => { + // Non configuré, persister le défaut + self.set_xxx_enum_value(DEFAULT_ENUM)?; + Ok(DEFAULT_ENUM) + } + } +} +``` + +### 4. Patterns de setters + +#### Setter simple + +```rust +fn set_xxx_value(&self, value: Type) -> Result<()> { + self.set_value( + &["path", "to", "value"], + Value::Type(value.into()) + ) +} +``` + +#### Setter avec transformation + +```rust +fn set_xxx_enum(&self, variant: EnumVariant) -> Result<()> { + // Stocker sous forme conviviale (string) plutôt que numérique + let name = variant.as_str(); + self.set_value( + &["path", "to", "enum"], + Value::String(name.to_string()) + ) +} +``` + +#### Setter multiple (transaction) + +```rust +fn set_xxx_auth_info( + &self, + token: &str, + user_id: &str, + expires_at: u64, +) -> Result<()> { + // Grouper les modifications liées + self.set_value( + &["accounts", "xxx", "auth_token"], + Value::String(token.to_string()) + )?; + self.set_value( + &["accounts", "xxx", "user_id"], + Value::String(user_id.to_string()) + )?; + self.set_value( + &["accounts", "xxx", "token_expires_at"], + Value::Number(serde_yaml::Number::from(expires_at)) + )?; + Ok(()) +} +``` + +#### Setter de nettoyage + +```rust +fn clear_xxx_info(&self) -> Result<()> { + // Ne pas propager les erreurs (valeurs peuvent ne pas exister) + let _ = self.set_value(&["path", "to", "field1"], Value::String(String::new())); + let _ = self.set_value(&["path", "to", "field2"], Value::Number(Number::from(0))); + Ok(()) +} +``` + +### 5. Helpers de haut niveau + +#### Helper de validation + +```rust +fn is_xxx_valid(&self) -> bool { + // Vérifier plusieurs conditions sans Result + let has_token = self + .get_xxx_token() + .ok() + .flatten() + .map(|t| !t.is_empty()) + .unwrap_or(false); + + let has_user = self + .get_xxx_user() + .ok() + .flatten() + .map(|u| !u.is_empty()) + .unwrap_or(false); + + has_token && has_user +} +``` + +#### Factory method + +Pour les crates qui fournissent des objets complexes configurables : + +```rust +fn create_xxx_cache(&self) -> Result> { + let dir = self.get_xxx_dir()?; + let size = self.get_xxx_size()?; + Ok(Arc::new(crate::new_cache(&dir, size)?)) +} + +fn create_xxx_client(&self) -> Result { + let (username, password) = self.get_xxx_credentials()?; + XxxClient::builder() + .credentials(username, password) + .cache_dir(self.get_xxx_cache_dir()?) + .build() +} +``` + +#### Getter combiné + +```rust +fn get_xxx_credentials(&self) -> Result<(String, String)> { + let username = self.get_xxx_username()?; + let password = self.get_xxx_password()?; + Ok((username, password)) +} +``` + +### 6. Utilisation des méthodes pmoconfig + +#### Répertoires managés + +Pour les répertoires qui doivent être créés automatiquement : + +```rust +fn get_xxx_cache_dir(&self) -> Result { + // get_managed_dir crée le répertoire s'il n'existe pas + self.get_managed_dir(&["host", "xxx_cache", "directory"], "cache_xxx") +} + +fn set_xxx_cache_dir(&self, directory: String) -> Result<()> { + self.set_managed_dir(&["host", "xxx_cache", "directory"], directory) +} +``` + +#### Méthodes génériques de Config utilisables + +```rust +// Lecture de valeur générique +pub fn get_value(&self, path: &[&str]) -> Result + +// Écriture de valeur générique +pub fn set_value(&self, path: &[&str], value: Value) -> Result<()> + +// Répertoires managés (création auto) +pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result +pub fn set_managed_dir(&self, path: &[&str], directory: String) -> Result<()> + +// Déchiffrement de mots de passe +pub mod encryption { + pub fn encrypt_password(password: &str) -> Result + pub fn decrypt_password(encrypted: &str) -> Result + pub fn get_password(value: &str) -> Result // Auto-détection + pub fn is_encrypted(value: &str) -> bool +} +``` + +## Patterns spécialisés + +### Pattern cache (pmocache) + +Le crate `pmocache` fournit un trait générique `CacheConfigExt` que les autres crates de cache peuvent utiliser : + +```rust +use pmocache::CacheConfigExt; + +impl AudioCacheConfigExt for Config { + fn get_audiocache_dir(&self) -> Result { + self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR) + } + + fn create_audio_cache(&self) -> Result> { + let dir = self.get_audiocache_dir()?; + let size = self.get_audiocache_size()?; + Ok(Arc::new(crate::cache::new_cache(&dir, size)?)) + } +} +``` + +**Avantage** : Cohérence entre tous les caches (audio, covers, qobuz, etc.) + +### Pattern authentification (pmoqobuz) + +Pour les services nécessitant une authentification : + +```rust +pub trait QobuzConfigExt { + // Credentials de base + fn get_qobuz_username(&self) -> Result; + fn get_qobuz_password(&self) -> Result; // Auto-decrypt + fn get_qobuz_credentials(&self) -> Result<(String, String)>; + + // Tokens d'authentification + fn get_qobuz_auth_token(&self) -> Result>; + fn get_qobuz_user_id(&self) -> Result>; + fn get_qobuz_token_expires_at(&self) -> Result>; + + // Gestion d'authentification groupée + fn set_qobuz_auth_info( + &self, + token: &str, + user_id: &str, + expires_at: u64 + ) -> Result<()>; + fn clear_qobuz_auth_info(&self) -> Result<()>; + + // Validation + fn is_qobuz_auth_valid(&self) -> bool; +} +``` + +### Pattern rate limiting (pmoqobuz) + +Pour les services avec rate limiting : + +```rust +pub trait QobuzConfigExt { + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result>; + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>; + + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result>; + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>; + + fn is_qobuz_rate_limiting_enabled(&self) -> bool; + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; +} +``` + +### Pattern configuration minimale (pmoparadise) + +Pour les sources qui nécessitent peu de configuration : + +```rust +pub trait RadioParadiseConfigExt { + // Juste enable/disable + fn get_paradise_enabled(&self) -> Result; + fn set_paradise_enabled(&self, enabled: bool) -> Result<()>; + + // Configuration minimale avec valeurs intelligentes par défaut + fn get_paradise_default_channel(&self) -> Result; + fn set_paradise_default_channel(&self, channel: u8) -> Result<()>; +} +``` + +**Philosophie** : Ne configurer que ce qui doit vraiment l'être. Éviter la sur-configuration. + +### Pattern UPnP (pmoupnp) + +Pour la configuration des devices UPnP : + +```rust +pub trait UpnpConfigExt { + fn get_upnp_manufacturer(&self) -> Result; + fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()>; + + fn get_upnp_udn_prefix(&self) -> Result; + fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()>; + + fn get_upnp_model_name_prefix(&self) -> Result; + fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()>; + + fn get_upnp_friendly_name_prefix(&self) -> Result; + fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()>; +} +``` + +**Usage** : Différencier plusieurs instances du serveur (dev, prod, test). + +## Bonnes pratiques + +### 1. Nommage des méthodes + +```rust +// ✅ BON : Préfixer avec le nom du service/composant +fn get_qobuz_username(&self) -> Result +fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result +fn get_paradise_enabled(&self) -> Result + +// ❌ MAUVAIS : Nom trop générique +fn get_username(&self) -> Result +fn get_directory(&self) -> Result +fn is_enabled(&self) -> bool +``` + +### 2. Gestion des erreurs + +```rust +// ✅ BON : Retourner Result pour les valeurs obligatoires +fn get_xxx_username(&self) -> Result { + match self.get_value(&["accounts", "xxx", "username"])? { + Value::String(s) => Ok(s), + _ => Err(anyhow!("XXX username not configured")), + } +} + +// ✅ BON : Retourner Option pour les valeurs optionnelles +fn get_xxx_token(&self) -> Result> + +// ✅ BON : Retourner bool pour les checks (sans erreur) +fn is_xxx_valid(&self) -> bool + +// ❌ MAUVAIS : Panic ou unwrap +fn get_xxx_value(&self) -> String { + self.get_value(&["path"]).unwrap().as_str().unwrap() +} +``` + +### 3. Valeurs par défaut + +```rust +// ✅ BON : Constantes en haut du fichier +const DEFAULT_CACHE_SIZE: usize = 500; +const DEFAULT_CACHE_DIR: &str = "cache_audio"; + +// ✅ BON : Valeurs par défaut documentées +/// Récupère la taille du cache +/// +/// # Returns +/// +/// Le nombre maximal d'éléments (default: 500) +fn get_cache_size(&self) -> Result + +// ❌ MAUVAIS : Magic numbers +fn get_cache_size(&self) -> Result { + match self.get_value(&["cache", "size"]) { + Ok(Value::Number(n)) => Ok(n.as_u64().unwrap() as usize), + _ => Ok(500), // Où vient ce 500 ? + } +} +``` + +### 4. Documentation + +Chaque méthode doit avoir : + +```rust +/// Description courte de ce que fait la méthode +/// +/// # Arguments (si applicable) +/// +/// * `param` - Description du paramètre +/// +/// # Returns +/// +/// Description de ce qui est retourné (avec valeur par défaut si applicable) +/// +/// # Errors (si Result) +/// +/// Description des cas d'erreur +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoxxx::XxxConfigExt; +/// +/// let config = get_config(); +/// let value = config.get_xxx_value()?; +/// ``` +fn get_xxx_value(&self) -> Result; +``` + +### 5. Organisation du code + +#### Structure du fichier config_ext.rs + +```rust +//! Documentation du module + +// Imports +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +// Constantes +const DEFAULT_XXX: Type = value; + +// Trait +pub trait XxxConfigExt { + // Méthodes groupées logiquement +} + +// Implémentation +impl XxxConfigExt for Config { + // Méthodes dans le même ordre que le trait +} + +// Tests (optionnel) +#[cfg(test)] +mod tests { + use super::*; +} +``` + +#### Ordre des méthodes dans le trait + +1. Getters/setters simples +2. Getters/setters combinés +3. Helpers de validation +4. Factory methods +5. Méthodes de nettoyage + +```rust +pub trait QobuzConfigExt { + // 1. Getters/setters simples + fn get_qobuz_username(&self) -> Result; + fn set_qobuz_username(&self, username: &str) -> Result<()>; + fn get_qobuz_password(&self) -> Result; + fn set_qobuz_password(&self, password: &str) -> Result<()>; + + // 2. Getters/setters combinés + fn get_qobuz_credentials(&self) -> Result<(String, String)>; + fn set_qobuz_auth_info(&self, ...) -> Result<()>; + + // 3. Helpers de validation + fn is_qobuz_auth_valid(&self) -> bool; + + // 4. Factory methods + fn create_qobuz_client(&self) -> Result; + + // 5. Méthodes de nettoyage + fn clear_qobuz_auth_info(&self) -> Result<()>; +} +``` + +### 6. Types de retour + +```rust +// ✅ BON : Result pour les opérations qui peuvent échouer +fn get_xxx_username(&self) -> Result + +// ✅ BON : Result> pour les valeurs optionnelles +fn get_xxx_token(&self) -> Result> + +// ✅ BON : bool pour les checks simples +fn is_xxx_enabled(&self) -> bool + +// ✅ BON : Result<(T1, T2)> pour retourner plusieurs valeurs liées +fn get_xxx_credentials(&self) -> Result<(String, String)> + +// ❌ MAUVAIS : Option> (ordre inversé) +fn get_xxx_value(&self) -> Option> +``` + +### 7. Conversion de types + +```rust +// ✅ BON : Gérer plusieurs types d'entrée +fn get_xxx_value(&self) -> Result { + match self.get_value(&["path", "to", "value"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap()), + Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as u64), + Ok(Value::String(s)) => s.parse::() + .map_err(|e| anyhow!("Invalid number: {}", e)), + _ => Err(anyhow!("Value not configured")), + } +} + +// ✅ BON : Convertir en format convivial pour l'utilisateur +fn set_xxx_channel(&self, channel: u8) -> Result<()> { + // Stocker "main" au lieu de "0" dans le YAML + let name = match channel { + 0 => "main", + 1 => "mellow", + 2 => "rock", + _ => return Err(anyhow!("Invalid channel")), + }; + self.set_value(&["path"], Value::String(name.to_string())) +} +``` + +## Exemples d'implémentation complète + +### Exemple 1 : Cache simple (pmocovers) + +```rust +//! Extension pour intégrer le cache de couvertures dans pmoconfig + +use anyhow::Result; +use pmocache::CacheConfigExt; +use pmoconfig::Config; +use std::sync::Arc; + +const DEFAULT_COVER_CACHE_DIR: &str = "cache_covers"; +const DEFAULT_COVER_CACHE_SIZE: usize = 2000; + +pub trait CoverCacheConfigExt { + fn get_covers_dir(&self) -> Result; + fn set_covers_dir(&self, directory: String) -> Result<()>; + fn get_covers_size(&self) -> Result; + fn set_covers_size(&self, size: usize) -> Result<()>; + fn create_cover_cache(&self) -> Result>; +} + +impl CoverCacheConfigExt for Config { + fn get_covers_dir(&self) -> Result { + self.get_cache_dir("cover_cache", DEFAULT_COVER_CACHE_DIR) + } + + fn set_covers_dir(&self, directory: String) -> Result<()> { + self.set_cache_dir("cover_cache", directory) + } + + fn get_covers_size(&self) -> Result { + self.get_cache_size("cover_cache", DEFAULT_COVER_CACHE_SIZE) + } + + fn set_covers_size(&self, size: usize) -> Result<()> { + self.set_cache_size("cover_cache", size) + } + + fn create_cover_cache(&self) -> Result> { + let dir = self.get_covers_dir()?; + let size = self.get_covers_size()?; + Ok(Arc::new(crate::cache::new_cache(&dir, size)?)) + } +} +``` + +### Exemple 2 : Service avec authentification (pmoqobuz - simplifié) + +```rust +//! Extension pour intégrer la configuration Qobuz dans pmoconfig + +use anyhow::{anyhow, Result}; +use pmoconfig::Config; +use serde_yaml::Value; + +pub trait QobuzConfigExt { + // Credentials + fn get_qobuz_username(&self) -> Result; + fn set_qobuz_username(&self, username: &str) -> Result<()>; + fn get_qobuz_password(&self) -> Result; + fn set_qobuz_password(&self, password: &str) -> Result<()>; + fn get_qobuz_credentials(&self) -> Result<(String, String)>; + + // Authentification + fn get_qobuz_auth_token(&self) -> Result>; + fn get_qobuz_user_id(&self) -> Result>; + fn set_qobuz_auth_info(&self, token: &str, user_id: &str) -> Result<()>; + fn clear_qobuz_auth_info(&self) -> Result<()>; + fn is_qobuz_auth_valid(&self) -> bool; +} + +impl QobuzConfigExt for Config { + fn get_qobuz_username(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "username"])? { + Value::String(s) => Ok(s), + _ => Err(anyhow!("Qobuz username not configured")), + } + } + + fn set_qobuz_username(&self, username: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "username"], + Value::String(username.to_string()), + ) + } + + fn get_qobuz_password(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "password"])? { + Value::String(s) => { + // Déchiffrement automatique + pmoconfig::encryption::get_password(&s) + .map_err(|e| anyhow!("Failed to decrypt password: {}", e)) + } + _ => Err(anyhow!("Qobuz password not configured")), + } + } + + fn set_qobuz_password(&self, password: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "password"], + Value::String(password.to_string()), + ) + } + + fn get_qobuz_credentials(&self) -> Result<(String, String)> { + let username = self.get_qobuz_username()?; + let password = self.get_qobuz_password()?; + Ok((username, password)) + } + + fn get_qobuz_auth_token(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "auth_token"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + _ => Ok(None), + } + } + + fn get_qobuz_user_id(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "user_id"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + _ => Ok(None), + } + } + + fn set_qobuz_auth_info(&self, token: &str, user_id: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "auth_token"], + Value::String(token.to_string()), + )?; + self.set_value( + &["accounts", "qobuz", "user_id"], + Value::String(user_id.to_string()), + )?; + Ok(()) + } + + fn clear_qobuz_auth_info(&self) -> Result<()> { + let _ = self.set_value( + &["accounts", "qobuz", "auth_token"], + Value::String(String::new()), + ); + let _ = self.set_value( + &["accounts", "qobuz", "user_id"], + Value::String(String::new()), + ); + Ok(()) + } + + fn is_qobuz_auth_valid(&self) -> bool { + self.get_qobuz_auth_token() + .ok() + .flatten() + .map(|t| !t.is_empty()) + .unwrap_or(false) + && self + .get_qobuz_user_id() + .ok() + .flatten() + .map(|u| !u.is_empty()) + .unwrap_or(false) + } +} +``` + +### Exemple 3 : Configuration minimale (pmoparadise) + +```rust +//! Extension pour intégrer Radio Paradise dans pmoconfig + +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +pub trait RadioParadiseConfigExt { + fn get_paradise_enabled(&self) -> Result; + fn set_paradise_enabled(&self, enabled: bool) -> Result<()>; + fn get_paradise_default_channel(&self) -> Result; + fn set_paradise_default_channel(&self, channel: u8) -> Result<()>; +} + +impl RadioParadiseConfigExt for Config { + fn get_paradise_enabled(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "enabled"]) { + Ok(Value::Bool(b)) => Ok(b), + _ => { + // Auto-persist le défaut + self.set_paradise_enabled(true)?; + Ok(true) + } + } + } + + fn set_paradise_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["sources", "radio_paradise", "enabled"], + Value::Bool(enabled), + ) + } + + fn get_paradise_default_channel(&self) -> Result { + match self.get_value(&["sources", "radio_paradise", "default_channel"]) { + Ok(Value::String(s)) => { + // Accepter les noms conviviaux + match s.as_str() { + "main" => Ok(0), + "mellow" => Ok(1), + "rock" => Ok(2), + "eclectic" => Ok(3), + _ => { + self.set_paradise_default_channel(0)?; + Ok(0) + } + } + } + Ok(Value::Number(n)) if n.is_u64() => { + let ch = n.as_u64().unwrap(); + if ch <= 3 { + Ok(ch as u8) + } else { + self.set_paradise_default_channel(0)?; + Ok(0) + } + } + _ => { + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String("main".to_string()), + )?; + Ok(0) + } + } + } + + fn set_paradise_default_channel(&self, channel: u8) -> Result<()> { + let name = match channel { + 0 => "main", + 1 => "mellow", + 2 => "rock", + 3 => "eclectic", + _ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)), + }; + self.set_value( + &["sources", "radio_paradise", "default_channel"], + Value::String(name.to_string()), + ) + } +} +``` + +## Intégration dans un crate + +### Structure recommandée + +``` +pmoxxx/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # Exporte le trait d'extension +│ ├── config_ext.rs # Implémentation du trait +│ └── ... # Reste du code du crate +``` + +### Dans Cargo.toml + +```toml +[dependencies] +pmoconfig = { path = "../pmoconfig" } +anyhow = "1.0" +serde_yaml = "0.9" + +# Si c'est un cache, inclure pmocache +pmocache = { path = "../pmocache", optional = false } +``` + +### Dans lib.rs + +```rust +// Exporter le trait pour qu'il soit utilisable +pub mod config_ext; +pub use config_ext::XxxConfigExt; + +// Le reste du code du crate +// ... +``` + +### Utilisation dans le code applicatif + +```rust +use pmoconfig::get_config; +use pmoxxx::XxxConfigExt; + +fn main() -> anyhow::Result<()> { + let config = get_config(); + + // Utiliser les méthodes du trait d'extension + let value = config.get_xxx_value()?; + config.set_xxx_value(new_value)?; + + // Factory method + let client = config.create_xxx_client()?; + + Ok(()) +} +``` + +## Checklist pour créer un nouveau trait d'extension + +- [ ] Créer le fichier `src/config_ext.rs` dans le crate +- [ ] Définir les constantes pour les valeurs par défaut +- [ ] Créer le trait `XxxConfigExt` avec documentation +- [ ] Implémenter les getters avec gestion d'erreur appropriée +- [ ] Implémenter les setters +- [ ] Ajouter les helpers de validation si nécessaire +- [ ] Ajouter les factory methods si applicable +- [ ] Documenter chaque méthode avec exemples +- [ ] Exporter le trait dans `lib.rs` +- [ ] Ajouter `pmoconfig` dans `Cargo.toml` +- [ ] Tester l'intégration + +## Philosophie du pattern + +### Avantages + +1. **Séparation des préoccupations** : Chaque crate gère sa propre configuration +2. **Type safety** : Les erreurs de type sont détectées à la compilation +3. **Extensibilité** : Facile d'ajouter de nouveaux crates sans modifier pmoconfig +4. **Cohérence** : Pattern uniforme dans tout le projet +5. **Documentation** : Interface self-documenting avec exemples + +### Principes directeurs + +1. **Minimalisme** : Ne configurer que ce qui doit vraiment l'être +2. **Defaults intelligents** : Valeurs par défaut sensées et documentées +3. **Auto-persistence** : Les valeurs importantes sont persistées automatiquement +4. **User-friendly** : Noms conviviaux dans le YAML (strings au lieu de nombres) +5. **Fail-safe** : Gestion des erreurs gracieuse avec fallback sur défauts +6. **Zero surprise** : Comportement prévisible et cohérent + +## Sécurité : Chiffrement des mots de passe + +Tous les mots de passe dans la configuration doivent pouvoir être chiffrés. Voir `pmoconfig/PASSWORD_ENCRYPTION.md` pour les détails. + +### Pattern pour les mots de passe + +```rust +fn get_xxx_password(&self) -> Result { + match self.get_value(&["accounts", "xxx", "password"])? { + Value::String(s) => { + // Déchiffrement automatique + pmoconfig::encryption::get_password(&s) + .map_err(|e| anyhow!("Failed to decrypt password: {}", e)) + } + _ => Err(anyhow!("Password not configured")), + } +} + +fn set_xxx_password(&self, password: &str) -> Result<()> { + // Le chiffrement est fait manuellement par l'utilisateur avec l'outil + self.set_value( + &["accounts", "xxx", "password"], + Value::String(password.to_string()), + ) +} +``` + +**Important** : Le setter stocke le mot de passe tel quel. C'est l'utilisateur qui décide de le chiffrer ou non avec l'outil `encrypt_password`. + +## Références + +### Fichiers d'exemple à consulter + +- **Cache générique** : `pmocache/src/config_ext.rs` +- **Cache spécialisé** : `pmocovers/src/config_ext.rs` ou `pmoaudiocache/src/config_ext.rs` +- **Service avec auth** : `pmoqobuz/src/config_ext.rs` +- **Configuration minimale** : `pmoparadise/src/config_ext.rs` +- **Configuration UPnP** : `pmoupnp/src/config_ext.rs` +- **Chiffrement** : `pmoconfig/PASSWORD_ENCRYPTION.md` + +### Documentation pmoconfig + +- `pmoconfig::Config::get_value()` +- `pmoconfig::Config::set_value()` +- `pmoconfig::Config::get_managed_dir()` +- `pmoconfig::encryption` module diff --git a/Blackboard/Architecture/pmoserver_ext.md b/Blackboard/Architecture/pmoserver_ext.md index ecf32d3e..1861e621 100644 --- a/Blackboard/Architecture/pmoserver_ext.md +++ b/Blackboard/Architecture/pmoserver_ext.md @@ -1,190 +1,552 @@ -# Pattern d'extension du serveur PMO (pmoserver_ext) +# Pattern d'extension PMOServer (`pmoserver_ext`) ## Vue d'ensemble -Le pattern `pmoserver_ext` permet d'étendre les fonctionnalités du serveur HTTP `pmoserver` de manière modulaire et découplée. Chaque crate spécialisée (audio, images, UPnP, Radio Paradise, etc.) peut ajouter ses propres routes HTTP, API REST et documentation OpenAPI sans que `pmoserver` ne dépende de ces crates. +Le pattern `pmoserver_ext` permet d'étendre les fonctionnalités du serveur HTTP `pmoserver` de manière modulaire et découplée. Chaque crate spécialisée peut ajouter ses propres routes HTTP sans que `pmoserver` ne dépende de ces crates. -## Architecture du pattern +**Principe** : Définir un trait d'extension que `pmoserver::Server` implémente via une feature Cargo. -### Principe de base +## Anatomie d'une extension -Le pattern utilise le système de traits Rust pour définir une interface d'extension que `pmoserver::Server` implémente. Chaque crate fonctionnelle définit son propre trait d'extension avec des méthodes préfixées par convention (ex: `init_*`, `add_*`). +### 1. Structure du module -``` -┌─────────────────────────────────────────────────────────┐ -│ pmoserver (core) │ -│ ┌────────────────────────────────────────────┐ │ -│ │ Server (struct) │ │ -│ │ - Router Axum │ │ -│ │ - add_handler(), add_router() │ │ -│ │ - add_openapi(), add_spa() │ │ -│ └────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - ▲ - │ impl Trait - ┌──────────────┴──────────────┐ - │ │ -┌─────────┴──────────┐ ┌──────────┴─────────────┐ -│ pmoaudiocache │ │ pmoparadise │ -│ ┌──────────────┐ │ │ ┌──────────────────┐ │ -│ │AudioCacheExt │ │ │ │RadioParadiseExt │ │ -│ └──────────────┘ │ │ └──────────────────┘ │ -└────────────────────┘ └────────────────────────┘ -``` +Créer un module `pmoserver_ext.rs` dans la crate : -### Avantages - -1. **Découplage** : `pmoserver` ne connaît pas les crates spécialisées -2. **Modularité** : Chaque fonctionnalité est opt-in via features Cargo -3. **Cohérence** : Interface uniforme pour toutes les extensions -4. **Testabilité** : Chaque extension peut être testée indépendamment - -## Composants du pattern - -### 1. Trait d'extension - -Définir un trait public avec des méthodes d'initialisation/configuration. - -**Convention de nommage** : -- Trait : `{Domaine}Ext` (ex: `AudioCacheExt`, `RadioParadiseExt`) -- Méthodes : `init_{domaine}*`, `add_{domaine}*` - -**Exemple** (pmoaudiocache/src/lib.rs:200-215): ```rust -#[cfg(feature = "pmoserver")] -pub trait AudioCacheExt { - /// Initialise le cache audio et enregistre les routes HTTP - async fn init_audio_cache( - &mut self, - cache_dir: &str, - limit: usize, - ) -> anyhow::Result>; +// pmoXXX/src/pmoserver_ext.rs - /// Initialise avec configuration par défaut - async fn init_audio_cache_configured(&mut self) - -> anyhow::Result>; +#[cfg(feature = "pmoserver")] +use crate::{/* types internes de la crate */}; +#[cfg(feature = "pmoserver")] +use async_trait::async_trait; +#[cfg(feature = "pmoserver")] +use axum::{Router, routing::get, Json, extract::{State, Path}}; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; +``` + +Déclarer le module dans `lib.rs` : + +```rust +// pmoXXX/src/lib.rs +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::XXXExt; +``` + +Ajouter la feature dans `Cargo.toml` : + +```toml +[features] +pmoserver = ["dep:axum", "dep:async-trait"] + +[dependencies] +axum = { version = "0.8", optional = true } +async-trait = { version = "0.1", optional = true } +pmoserver = { path = "../pmoserver" } +``` + +### 2. Définir le trait d'extension + +**Convention de nommage** : `{Domaine}Ext` avec méthodes préfixées `init_*` + +```rust +/// Trait pour étendre pmoserver avec les fonctionnalités XXX +#[cfg(feature = "pmoserver")] +#[async_trait] +pub trait XXXExt { + /// Initialise l'extension XXX et enregistre les routes HTTP + /// + /// # Arguments + /// * `param1` - Description du paramètre + /// + /// # Returns + /// Instance partagée de la ressource créée + /// + /// # Exemple + /// ```ignore + /// use pmoserver::ServerBuilder; + /// use pmoXXX::XXXExt; + /// + /// let mut server = ServerBuilder::new(...).build(); + /// let resource = server.init_xxx(param1).await?; + /// ``` + async fn init_xxx(&mut self, param1: String) -> anyhow::Result>; } ``` -### 2. Implémentation du trait +### 3. Implémenter le trait -Implémenter le trait pour `pmoserver::Server` en utilisant les méthodes publiques du serveur. +Implémenter le trait pour `pmoserver::Server` : -**Méthodes disponibles du serveur** : -- `add_handler()` : Ajoute un handler simple -- `add_handler_with_state()` : Ajoute un handler avec état partagé -- `add_router()` : Monte un sous-router Axum -- `add_openapi()` : Enregistre une API avec documentation OpenAPI -- `add_spa()` : Sert une Single Page Application (RustEmbed) -- `base_url()` : Récupère l'URL de base du serveur - -**Exemple** (pmoaudiocache/src/lib.rs:225-260): ```rust #[cfg(feature = "pmoserver")] -impl AudioCacheExt for pmoserver::Server { - async fn init_audio_cache( - &mut self, - cache_dir: &str, - limit: usize, - ) -> anyhow::Result> { - // 1. Créer le cache - let cache = Arc::new(new_cache(cache_dir, limit)?); - - // 2. Router pour servir les fichiers - let file_router = create_file_router( - cache.clone(), - "audio/flac", // Content-Type - ); - self.add_router("/", file_router).await; - - // 3. API REST avec état - let api_router = Router::new() - .route("/", get(list).post(add).delete(purge)) - .route("/{pk}", get(get_info).delete(delete)) - .with_state(cache.clone()); - - // 4. Documentation OpenAPI - let openapi = ApiDoc::openapi(); - self.add_openapi(api_router, openapi, "audio").await; - - Ok(cache) +#[async_trait] +impl XXXExt for pmoserver::Server { + async fn init_xxx(&mut self, param1: String) -> anyhow::Result> { + // 1. Créer la ressource interne + let resource = Arc::new(Resource::new(param1)?); + + // 2. Créer l'état partagé pour les handlers + let state = XxxState::new(resource.clone()); + + // 3. Créer le router avec les routes + let router = create_xxx_router(state); + + // 4. Enregistrer le router sur le serveur + self.add_router("/api/xxx", router).await; + + // 5. Retourner la ressource pour usage ultérieur + Ok(resource) } } ``` -### 3. État partagé (State) +### 4. État partagé (State) -Pour les handlers qui nécessitent un état, créer une structure dédiée cloneable. +Créer une structure d'état cloneable pour les handlers : -**Convention** : -- Nom : `{Domaine}State` -- Doit implémenter `Clone` -- Contient des `Arc` pour les ressources partagées - -**Exemple** (pmoparadise/src/pmoserver_ext.rs:18-22): ```rust +/// État partagé pour les handlers XXX #[derive(Clone)] -pub struct RadioParadiseState { - client: Arc>, +pub struct XxxState { + resource: Arc, } -impl RadioParadiseState { - pub async fn new() -> anyhow::Result { - let client = RadioParadiseClient::new().await?; - Ok(Self { - client: Arc::new(RwLock::new(client)), - }) +impl XxxState { + pub fn new(resource: Arc) -> Self { + Self { resource } } } ``` -### 4. Handlers HTTP +### 5. Créer le router -Définir les handlers comme des fonctions async avec les extracteurs Axum. +Définir les routes et handlers : -**Extracteurs courants** : -- `State` : Accès à l'état partagé -- `Path` : Paramètres d'URL -- `Query` : Paramètres de query string -- `Json` : Body JSON - -**Exemple** (pmoparadise/src/pmoserver_ext.rs:168-180): ```rust +/// Crée le router pour l'API XXX +fn create_xxx_router(state: XxxState) -> Router { + Router::new() + .route("/items", get(list_items).post(create_item)) + .route("/items/{id}", get(get_item).delete(delete_item)) + .with_state(state) +} + +// Handlers +async fn list_items( + State(state): State +) -> Json> { + let items = state.resource.list_items(); + Json(items) +} + +async fn get_item( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + state.resource.get_item(&id) + .ok_or(StatusCode::NOT_FOUND) + .map(Json) +} +``` + +## Méthodes disponibles du serveur + +`pmoserver::Server` expose ces méthodes pour enregistrer des routes : + +| Méthode | Usage | +|---------|-------| +| `add_handler(path, handler)` | Ajoute un handler simple sans état | +| `add_handler_with_state(path, handler, state)` | Ajoute un handler avec état partagé | +| `add_router(path, router)` | Monte un sous-router Axum | +| `add_openapi(router, doc, tag)` | Enregistre une API avec documentation OpenAPI | +| `add_spa::(path)` | Sert une Single Page Application (RustEmbed) | +| `base_url()` | Récupère l'URL de base du serveur | + +## Documentation OpenAPI avec utoipa + +La documentation OpenAPI est essentielle pour une extension `pmoserver`. Elle génère automatiquement une interface Swagger UI et documente les endpoints de l'API. + +### Configuration de base + +Ajouter `utoipa` dans `Cargo.toml` : + +```toml +[dependencies] +utoipa = { version = "5", features = ["axum_extras"] } +serde = { version = "1", features = ["derive"] } +``` + +### 1. Définir les schémas de données + +Annoter les structures de réponse/requête avec `#[derive(ToSchema)]` : + +```rust +use serde::{Serialize, Deserialize}; +use utoipa::ToSchema; + +/// Information sur un item +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ItemInfo { + /// ID unique de l'item + #[schema(example = "item-123")] + pub id: String, + + /// Nom de l'item + #[schema(example = "Mon Item")] + pub name: String, + + /// Description optionnelle + #[schema(example = "Une description détaillée")] + pub description: Option, + + /// Timestamp de création (millisecondes) + #[schema(example = 1234567890)] + pub created_at: u64, +} + +/// Liste d'items +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ItemList { + /// Nombre total d'items + pub total: usize, + + /// Items de la page courante + pub items: Vec, +} + +/// Requête de création d'item +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreateItemRequest { + /// Nom de l'item à créer + #[schema(example = "Nouvel Item")] + pub name: String, + + /// Description optionnelle + pub description: Option, +} + +/// Réponse d'erreur standard +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ErrorResponse { + /// Message d'erreur + #[schema(example = "Item not found")] + pub error: String, +} +``` + +**Points clés** : +- `#[schema(example = "...")]` : Fournit des exemples pour la doc Swagger +- Documenter chaque champ avec `///` pour apparaître dans l'API +- Utiliser `Option` pour les champs optionnels + +### 2. Annoter les handlers + +Utiliser `#[utoipa::path(...)]` pour documenter chaque endpoint : + +```rust +/// GET /items - Liste tous les items #[utoipa::path( get, - path = "/now-playing", - params(("channel" = Option, Query)), - responses((status = 200, body = NowPlayingResponse)), - tag = "Radio Paradise" + path = "/items", + params( + ("limit" = Option, Query, description = "Nombre max d'items à retourner"), + ("offset" = Option, Query, description = "Offset pour la pagination") + ), + responses( + (status = 200, description = "Liste des items", body = ItemList), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "items" )] -async fn get_now_playing( - State(state): State, - Query(params): Query, -) -> Result, StatusCode> { - let client = state.client_for_params(¶ms).await?; - let now_playing = client.now_playing().await?; - Ok(Json(now_playing.into())) +async fn list_items( + State(state): State, + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let items = state.resource.list_items(params.limit, params.offset) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e.to_string() }) + ))?; + + Ok(Json(ItemList { + total: items.len(), + items, + })) +} + +/// GET /items/{id} - Récupère un item spécifique +#[utoipa::path( + get, + path = "/items/{id}", + params( + ("id" = String, Path, description = "ID unique de l'item") + ), + responses( + (status = 200, description = "Item trouvé", body = ItemInfo), + (status = 404, description = "Item non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "items" +)] +async fn get_item( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + state.resource.get_item(&id) + .ok_or_else(|| ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Item {} not found", id) + }) + )) + .map(Json) +} + +/// POST /items - Crée un nouvel item +#[utoipa::path( + post, + path = "/items", + request_body = CreateItemRequest, + responses( + (status = 201, description = "Item créé", body = ItemInfo), + (status = 400, description = "Requête invalide", body = ErrorResponse), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "items" +)] +async fn create_item( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let item = state.resource.create_item(req.name, req.description) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e.to_string() }) + ))?; + + Ok((StatusCode::CREATED, Json(item))) +} + +/// DELETE /items/{id} - Supprime un item +#[utoipa::path( + delete, + path = "/items/{id}", + params( + ("id" = String, Path, description = "ID unique de l'item") + ), + responses( + (status = 204, description = "Item supprimé"), + (status = 404, description = "Item non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "items" +)] +async fn delete_item( + State(state): State, + Path(id): Path, +) -> Result)> { + state.resource.delete_item(&id) + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e.to_string() }) + ))?; + + Ok(StatusCode::NO_CONTENT) } ``` -### 5. Documentation OpenAPI (optionnel) +**Structure de `#[utoipa::path]`** : +- **Méthode HTTP** : `get`, `post`, `put`, `delete`, `patch` +- **`path`** : Chemin de l'endpoint (doit correspondre au router) +- **`params`** : Paramètres Path ou Query avec description +- **`request_body`** : Type du body pour POST/PUT +- **`responses`** : Liste des réponses possibles avec codes HTTP +- **`tag`** : Groupe d'endpoints dans Swagger UI -Utiliser `utoipa` pour générer automatiquement la documentation Swagger. +### 3. Créer la structure OpenAPI -**Étapes** : -1. Annoter les handlers avec `#[utoipa::path(...)]` -2. Définir les schémas avec `#[derive(ToSchema)]` -3. Créer une structure `#[derive(OpenApi)]` +Définir une structure avec `#[derive(OpenApi)]` : -**Exemple** (pmoparadise/src/pmoserver_ext.rs:315-350): ```rust -use utoipa::{OpenApi, ToSchema}; +use utoipa::OpenApi; +/// Documentation OpenAPI pour l'API XXX +#[derive(OpenApi)] +#[openapi( + info( + title = "XXX API", + version = "1.0.0", + description = r#" +# API REST pour XXX + +Cette API permet de gérer les items XXX avec les fonctionnalités suivantes : + +## Fonctionnalités + +- **CRUD complet** : Création, lecture, mise à jour et suppression d'items +- **Pagination** : Support de limit/offset pour les listes +- **Filtrage** : Recherche par critères multiples +- **Validation** : Vérification automatique des données + +## Exemples d'utilisation + +### Lister les items +``` +GET /api/xxx/items?limit=10&offset=0 +``` + +### Créer un item +``` +POST /api/xxx/items +Content-Type: application/json + +{ + "name": "Mon Item", + "description": "Description détaillée" +} +``` + +### Récupérer un item +``` +GET /api/xxx/items/item-123 +``` + +### Supprimer un item +``` +DELETE /api/xxx/items/item-123 +``` + "# + ), + paths( + list_items, + get_item, + create_item, + delete_item, + ), + components(schemas( + ItemInfo, + ItemList, + CreateItemRequest, + ErrorResponse, + )), + tags( + (name = "items", description = "Opérations sur les items") + ) +)] +pub struct ApiDoc; +``` + +**Sections importantes** : +- **`info`** : Titre, version et description Markdown de l'API +- **`paths`** : Liste des fonctions handler annotées +- **`components(schemas(...))`** : Liste des structures `ToSchema` +- **`tags`** : Organisation des endpoints en groupes + +### 4. Enregistrer l'API avec OpenAPI + +Dans l'implémentation du trait d'extension : + +```rust +#[async_trait] +impl XxxExt for pmoserver::Server { + async fn init_xxx(&mut self) -> anyhow::Result> { + let resource = Arc::new(Resource::new()?); + let state = XxxState { resource: resource.clone() }; + + // Créer le router avec les routes + let router = Router::new() + .route("/items", get(list_items).post(create_item)) + .route("/items/{id}", get(get_item).delete(delete_item)) + .with_state(state); + + // Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx) + let openapi = ApiDoc::openapi(); + self.add_openapi(router, openapi, "xxx").await; + + Ok(resource) + } +} +``` + +**Ce que fait `add_openapi`** : +- Monte le router sur `/api/{tag}/` +- Génère la spec OpenAPI JSON sur `/api/{tag}/openapi.json` +- Crée une UI Swagger sur `/swagger-ui/{tag}/` + +### 5. Exemple complet : Radio Paradise + +**Extrait de** `pmoparadise/src/pmoserver_ext.rs:93-315` + +```rust +/// Information sur un morceau #[derive(Debug, Clone, Serialize, ToSchema)] -pub struct NowPlayingResponse { +pub struct SongInfo { + /// Index dans le block + pub index: usize, + /// Artiste + pub artist: String, + /// Titre + pub title: String, + /// Album + pub album: String, + /// Année + pub year: Option, + /// Temps écoulé depuis le début du block (ms) + pub elapsed_ms: u64, + /// Durée du morceau (ms) + pub duration_ms: u64, + /// URL de la pochette + pub cover_url: Option, +} + +/// Réponse pour l'URL de streaming +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct StreamUrlResponse { + /// Event ID du block + #[schema(example = 1234567)] pub event: u64, + /// URL de streaming FLAC + #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")] pub stream_url: String, - pub songs: Vec, + /// Durée totale (ms) + #[schema(example = 900000)] + pub length_ms: u64, +} + +/// GET /stream-url/{event_id} - Récupère l'URL de streaming +#[utoipa::path( + get, + path = "/stream-url/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block"), + ("channel" = Option, Query, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "URL de streaming", body = StreamUrlResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_stream_url( + State(state): State, + Path(event_id): Path, + Query(params): Query, +) -> Result, StatusCode> { + let client = state.client_for_params(¶ms).await?; + let block = client.get_block(Some(event_id)).await.map_err(|e| { + tracing::error!("Failed to fetch block {}: {}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(StreamUrlResponse { + event: block.event, + stream_url: block.url, + length_ms: block.length, + })) } #[derive(OpenApi)] @@ -192,517 +554,317 @@ pub struct NowPlayingResponse { info( title = "Radio Paradise API", version = "1.0.0", - description = "API REST pour Radio Paradise" + description = "API REST pour accéder aux métadonnées Radio Paradise" ), paths( get_now_playing, get_current_block, + get_stream_url, ), components(schemas( - NowPlayingResponse, SongInfo, + StreamUrlResponse, )), - tags((name = "Radio Paradise")) + tags( + (name = "Radio Paradise", description = "Endpoints Radio Paradise") + ) )] pub struct RadioParadiseApiDoc; ``` -### 6. Router Axum +### Résultat : Interface Swagger -Pour des endpoints complexes, créer un sous-router réutilisable. +Après avoir appelé `init_xxx()`, l'API est accessible : + +- **API JSON** : `http://localhost:8080/api/xxx/` +- **Spec OpenAPI** : `http://localhost:8080/api/xxx/openapi.json` +- **Swagger UI** : `http://localhost:8080/swagger-ui/xxx/` + +L'interface Swagger permet : +- Parcourir tous les endpoints avec leur documentation +- Tester les requêtes directement depuis le navigateur +- Voir les schémas de données avec exemples +- Consulter les codes de réponse HTTP possibles + +## Patterns courants + +### Pattern 1 : Extension simple avec router + +**Exemple** : `pmoparadise` (pmoparadise/src/pmoserver_ext.rs:367-392) -**Exemple** (pmoparadise/src/pmoserver_ext.rs:354-365): ```rust -pub fn create_api_router(state: RadioParadiseState) -> Router { - Router::new() - .route("/now-playing", get(get_now_playing)) - .route("/block/current", get(get_current_block)) - .route("/block/{event_id}", get(get_block_by_id)) - .route("/channels", get(get_channels)) - .with_state(state) +#[async_trait] +impl RadioParadiseExt for pmoserver::Server { + async fn init_radioparadise(&mut self) -> anyhow::Result { + let state = RadioParadiseState::new().await?; + + // Créer le router API + let api_router = create_api_router(state.clone()); + + // Enregistrer avec OpenAPI + self.add_openapi(api_router, ApiDoc::openapi(), "radioparadise") + .await; + + Ok(state) + } } ``` -## Pattern avancé : Extension avec async-trait +### Pattern 2 : Extension avec cache et fichiers -Pour les extensions nécessitant des opérations asynchrones complexes, utiliser `async_trait`. +**Exemple** : `pmoaudiocache` (pmoaudiocache/src/lib.rs:225-260) -**Exemple** (pmomediaserver/src/paradise_streaming.rs:31-68): ```rust -use async_trait::async_trait; - #[async_trait] -pub trait ParadiseStreamingExt { - async fn init_paradise_streaming(&mut self) - -> Result>; +impl AudioCacheExt for pmoserver::Server { + async fn init_audio_cache( + &mut self, + cache_dir: &str, + limit: usize, + ) -> anyhow::Result> { + let cache = Arc::new(new_cache(cache_dir, limit)?); + + // Router pour servir les fichiers FLAC + let file_router = create_file_router(cache.clone(), "audio/flac"); + self.add_router("/", file_router).await; + + // API REST + let api_router = Router::new() + .route("/", get(list).post(add)) + .route("/{pk}", get(get_info).delete(delete)) + .with_state(cache.clone()); + + self.add_openapi(api_router, ApiDoc::openapi(), "audio").await; + + Ok(cache) + } } +``` +### Pattern 3 : Extension avec routes dynamiques + +**Exemple** : `pmomediaserver` (pmomediaserver/src/paradise_streaming.rs:70-148) + +```rust #[async_trait] impl ParadiseStreamingExt for pmoserver::Server { - async fn init_paradise_streaming(&mut self) - -> Result> - { - // 1. Récupérer/initialiser des caches singletons - let audio_cache = match get_audio_cache() { - Some(cache) => cache, - None => { - let cache = self.init_audio_cache_configured().await?; - register_audio_cache(cache.clone()); - cache - } - }; - - // 2. Créer le manager de canaux - let manager = Arc::new( - ParadiseChannelManager::with_defaults(base_url).await? - ); - register_global_manager(manager.clone()); - + async fn init_paradise_streaming(&mut self) -> Result> { + // 1. Récupérer/créer les ressources partagées + let audio_cache = get_or_init_audio_cache(self).await?; + let manager = Arc::new(Manager::new(audio_cache).await?); + + // 2. Créer l'état partagé + let state = Arc::new(StreamingState { manager: manager.clone() }); + // 3. Enregistrer les routes pour chaque canal for descriptor in ALL_CHANNELS.iter() { - let path = format!("/stream/{}/flac", descriptor.slug); - self.add_handler_with_state(&path, handler, state).await; + let slug = descriptor.slug; + + // Route streaming FLAC + let path = format!("/stream/{}/flac", slug); + self.add_handler_with_state( + &path, + move |State(s): State>| async move { + stream_flac(s.manager.clone(), descriptor.id).await + }, + state.clone(), + ).await; + + // Route streaming OGG + let path = format!("/stream/{}/ogg", slug); + self.add_handler_with_state( + &path, + move |State(s): State>| async move { + stream_ogg(s.manager.clone(), descriptor.id).await + }, + state.clone(), + ).await; } - + Ok(manager) } } ``` -## Pattern d'intégration : Control Point +## Gestion des opérations longues -Le Control Point illustre une extension avec API REST complète incluant gestion d'état, timeouts et spawn de tâches. +### Utiliser `spawn_blocking` pour le code synchrone -### Structure de l'état +Pour éviter de bloquer le runtime Tokio avec du code synchrone : -**Exemple** (pmocontrol/src/pmoserver_ext.rs:42-51): -```rust -#[derive(Clone)] -pub struct ControlPointState { - control_point: Arc, -} - -impl ControlPointState { - pub fn new(control_point: Arc) -> Self { - Self { control_point } - } -} -``` - -### Handlers avec spawn_blocking - -Pour les opérations synchrones UPnP, utiliser `spawn_blocking` pour éviter de bloquer le runtime Tokio. - -**Exemple** (pmocontrol/src/pmoserver_ext.rs:68-92): ```rust async fn list_renderers( State(state): State -) -> Json> { - // Déporter le travail synchrone sur un thread dédié +) -> Json> { let control_point = state.control_point.clone(); + let summaries = tokio::task::spawn_blocking(move || { let renderers = control_point.list_music_renderers(); renderers.into_iter() - .map(|r| RendererSummary { - id: r.id().0.clone(), - friendly_name: r.friendly_name().to_string(), - online: r.is_online(), - }) - .collect::>() + .map(|r| Summary::from(&r)) + .collect() }) .await .unwrap_or_default(); - + Json(summaries) } ``` -### Handlers avec timeouts +### Ajouter des timeouts pour les opérations réseau -Pour les commandes réseau, toujours utiliser des timeouts pour éviter les blocages. - -**Exemple** (pmocontrol/src/pmoserver_ext.rs:240-280): ```rust -const TRANSPORT_COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); async fn play_renderer( State(state): State, - Path(renderer_id): Path, -) -> Result, (StatusCode, Json)> { - let rid = DeviceId(renderer_id.clone()); - let renderer = state.control_point - .music_renderer_by_id(&rid) - .ok_or_else(|| ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("Renderer {} not found", renderer_id) - }) - ))?; - - // Spawn blocking task avec timeout - let play_task = tokio::task::spawn_blocking(move || - renderer.play() - ); - - time::timeout(TRANSPORT_COMMAND_TIMEOUT, play_task) + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let renderer = state.get_renderer(&id) + .ok_or((StatusCode::NOT_FOUND, Json(Error::not_found())))?; + + let play_task = tokio::task::spawn_blocking(move || renderer.play()); + + time::timeout(COMMAND_TIMEOUT, play_task) .await - .map_err(|_| { - warn!("Play command timeout"); - (StatusCode::GATEWAY_TIMEOUT, Json(ErrorResponse { - error: "Command timed out".to_string() - })) - })? - .map_err(|e| { - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: format!("Task error: {}", e) - })) - })? - .map_err(|e| { - (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: format!("Failed to play: {}", e) - })) - })?; - - Ok(Json(SuccessResponse { - message: "Playback started".to_string() - })) + .map_err(|_| ( + StatusCode::GATEWAY_TIMEOUT, + Json(Error::timeout()) + ))? + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(Error::internal(e)) + ))??; + + Ok(Json(Response::success())) } ``` -### Handlers avec spawn en arrière-plan +### Utiliser `spawn` pour les tâches en arrière-plan -Pour les opérations longues qui ne nécessitent pas d'attente, utiliser `tokio::spawn` et retourner immédiatement. +Pour les opérations qui ne nécessitent pas d'attendre le résultat : -**Exemple** (pmocontrol/src/pmoserver_ext.rs:635-675): ```rust -async fn seek_queue_index( - State(state): State, - Path(renderer_id): Path, - Json(payload): Json, -) -> Result, (StatusCode, Json)> { - let rid = DeviceId(renderer_id.clone()); - state.control_point.music_renderer_by_id(&rid) - .ok_or_else(|| ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("Renderer {} not found", renderer_id) - }) - ))?; - - // Lancer la commande en arrière-plan et retourner immédiatement - // L'UI sera mise à jour via SSE - let control_point = Arc::clone(&state.control_point); - let rid_for_task = rid.clone(); - let index = payload.index; - +async fn trigger_action( + State(state): State, + Json(req): Json, +) -> Json { + // Valider la requête + state.validate(&req)?; + + // Lancer l'action en arrière-plan + let state_clone = state.clone(); tokio::task::spawn(async move { - let result = tokio::task::spawn_blocking(move || { - control_point.play_queue_index(&rid_for_task, index) - }).await; - - match result { - Ok(Ok(())) => { - debug!("Successfully started playback at index {}", index); - } - Ok(Err(e)) => { - warn!("Failed to seek to index {}: {}", index, e); - } - Err(e) => { - warn!("Task join error: {}", e); - } + match state_clone.perform_action(req).await { + Ok(_) => debug!("Action completed"), + Err(e) => warn!("Action failed: {}", e), } }); - - // Retour immédiat - Ok(Json(SuccessResponse { - message: format!("Playing item at index {}", index) - })) -} -``` - -## Pattern d'intégration : WebApp (SPA) - -Le module `pmoapp` illustre l'intégration d'une application Vue.js via RustEmbed. - -### Trait d'extension SPA - -**Exemple** (pmoapp/src/lib.rs:145-165): -```rust -pub trait WebAppExt { - /// Ajoute une Single Page Application - async fn add_webapp(&mut self, path: &str) - where - W: RustEmbed + Clone + Send + Sync + 'static; - - /// Ajoute une webapp avec redirection automatique - async fn add_webapp_with_redirect(&mut self, path: &str) - where - W: RustEmbed + Clone + Send + Sync + 'static; -} -``` - -### Structure RustEmbed - -**Exemple** (pmoapp/src/lib.rs:130-140): -```rust -use rust_embed::RustEmbed; - -#[derive(RustEmbed, Clone)] -#[folder = "webapp/dist"] -pub struct Webapp; -``` - -### Implémentation (feature-gated) - -**Fichier** : pmoapp/src/pmoserver_impl.rs -```rust -#[cfg(feature = "pmoserver")] -impl WebAppExt for pmoserver::Server { - async fn add_webapp(&mut self, path: &str) - where - W: RustEmbed + Clone + Send + Sync + 'static - { - self.add_spa::(path).await - } - - async fn add_webapp_with_redirect(&mut self, path: &str) - where - W: RustEmbed + Clone + Send + Sync + 'static - { - self.add_spa::(path).await; - self.add_redirect("/", path).await; - } -} -``` - -## Registres globaux (Singletons) - -Pour les ressources partagées entre extensions, utiliser des registres globaux. - -### Pattern de singleton - -**Exemple** (pmoaudiocache/src/lib.rs:268-295): -```rust -use once_cell::sync::OnceCell; -use std::sync::Arc; - -static AUDIO_CACHE: OnceCell> = OnceCell::new(); - -/// Enregistre le cache audio global -pub fn register_audio_cache(cache: Arc) { - let _ = AUDIO_CACHE.set(cache); -} - -/// Accès global au cache audio -pub fn get_audio_cache() -> Option> { - AUDIO_CACHE.get().cloned() -} -``` - -### Utilisation dans les extensions - -**Exemple** (pmomediaserver/src/paradise_streaming.rs:77-95): -```rust -async fn init_paradise_streaming(&mut self) -> Result> { - // Récupérer ou initialiser le singleton - let audio_cache = match get_audio_cache() { - Some(cache) => { - info!("Using existing audio cache singleton"); - cache - } - None => { - info!("Initializing new audio cache singleton"); - let cache = self.init_audio_cache_configured().await?; - register_audio_cache(cache.clone()); - cache - } - }; - - // Utiliser le cache dans le manager - let manager = Manager::new(audio_cache).await?; - Ok(Arc::new(manager)) + + // Retourner immédiatement + Json(Response::accepted()) } ``` ## Checklist d'implémentation -Pour implémenter une nouvelle extension `pmoserver`, suivre ces étapes : - -### 1. Structure du module - -- [ ] Créer un module `pmoserver_ext.rs` dans la crate -- [ ] Ajouter la feature `pmoserver` dans `Cargo.toml` -- [ ] Importer le module avec `#[cfg(feature = "pmoserver")]` - -### 2. Définition du trait - -- [ ] Créer un trait public `{Domaine}Ext` -- [ ] Ajouter des méthodes préfixées `init_*` ou `add_*` -- [ ] Documenter chaque méthode avec des exemples -- [ ] Ajouter `#[async_trait]` si nécessaire - -### 3. État partagé - -- [ ] Créer une structure `{Domaine}State` -- [ ] Implémenter `Clone` -- [ ] Utiliser `Arc` pour les ressources partagées -- [ ] Ajouter des méthodes helpers si nécessaire - -### 4. Handlers HTTP - -- [ ] Définir les fonctions handler async -- [ ] Utiliser les extracteurs Axum appropriés -- [ ] Gérer les erreurs avec `Result` -- [ ] Ajouter des logs (info, warn, error) - -### 5. Documentation OpenAPI (optionnel) - -- [ ] Annoter les handlers avec `#[utoipa::path(...)]` -- [ ] Définir les schémas avec `#[derive(ToSchema)]` -- [ ] Créer une structure `#[derive(OpenApi)]` -- [ ] Inclure des exemples dans la documentation - -### 6. Implémentation du trait +### Configuration de base +- [ ] Créer le module `pmoserver_ext.rs` avec `#[cfg(feature = "pmoserver")]` +- [ ] Ajouter la feature `pmoserver` dans `Cargo.toml` avec dépendances optionnelles +- [ ] Re-exporter le trait dans `lib.rs` +### Définition du trait +- [ ] Définir le trait `{Domaine}Ext` avec méthode `init_*` +- [ ] Créer la structure `{Domaine}State` avec `#[derive(Clone)]` - [ ] Implémenter le trait pour `pmoserver::Server` -- [ ] Utiliser les méthodes du serveur pour enregistrer les routes -- [ ] Gérer les erreurs avec `anyhow::Result` -- [ ] Retourner les ressources créées si nécessaire -### 7. Tests +### Documentation OpenAPI +- [ ] Ajouter `utoipa` dans les dépendances +- [ ] Définir les schémas de réponse/requête avec `#[derive(ToSchema)]` +- [ ] Ajouter des exemples avec `#[schema(example = "...")]` +- [ ] Annoter chaque handler avec `#[utoipa::path(...)]` +- [ ] Créer la structure `#[derive(OpenApi)]` avec documentation complète +- [ ] Lister tous les paths et schemas dans `#[openapi(...)]` -- [ ] Tester les handlers indépendamment -- [ ] Tester l'intégration avec le serveur -- [ ] Vérifier la documentation OpenAPI générée +### Handlers et routes +- [ ] Créer les handlers avec les extracteurs Axum appropriés +- [ ] Gérer les erreurs avec des codes HTTP sémantiques +- [ ] Créer le router et l'enregistrer avec `add_openapi()` +- [ ] Ajouter des logs (debug, info, warn, error) -## Bonnes pratiques +### Performance et robustesse +- [ ] Utiliser `spawn_blocking` pour le code synchrone +- [ ] Ajouter des timeouts pour les opérations réseau +- [ ] Utiliser `spawn` pour les tâches en arrière-plan si nécessaire -### 1. Gestion des erreurs - -- Utiliser `anyhow::Result` pour les méthodes d'initialisation -- Utiliser `Result` pour les handlers HTTP -- Toujours logger les erreurs avant de les retourner -- Préférer les codes HTTP sémantiques (404, 500, 504, etc.) - -### 2. Performance - -- Utiliser `spawn_blocking` pour les opérations synchrones -- Toujours ajouter des timeouts pour les opérations réseau -- Cloner l'état minimal nécessaire dans les closures -- Utiliser des `Arc` plutôt que `Mutex` quand possible - -### 3. Concurrence - -- Préférer `tokio::spawn` pour les tâches en arrière-plan -- Retourner immédiatement pour les opérations longues -- Utiliser des channels pour communiquer entre tâches -- Éviter les `RwLock` dans les handlers (préférer `spawn_blocking`) - -### 4. Documentation - -- Documenter chaque fonction publique avec des exemples -- Utiliser les annotations `utoipa` pour l'OpenAPI -- Inclure des exemples d'utilisation dans la doc du trait -- Documenter les routes HTTP créées - -### 5. Features Cargo - -- Toujours feature-gate les extensions avec `#[cfg(feature = "pmoserver")]` -- Déclarer les dépendances comme optionnelles -- Documenter les features dans le README de la crate - -## Exemples d'utilisation - -### Initialisation simple +## Exemple complet minimal ```rust -use pmoserver::ServerBuilder; -use pmoaudiocache::AudioCacheExt; +// pmoexample/src/pmoserver_ext.rs -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080) - .build(); +#[cfg(feature = "pmoserver")] +use async_trait::async_trait; +#[cfg(feature = "pmoserver")] +use axum::{Router, routing::get, Json, extract::State}; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; +#[cfg(feature = "pmoserver")] +use crate::ExampleResource; - // Initialiser le cache audio - server.init_audio_cache("./cache", 500).await?; - - server.start().await; - server.wait().await; - Ok(()) +#[cfg(feature = "pmoserver")] +#[derive(Clone)] +pub struct ExampleState { + resource: Arc, } -``` -### Initialisation avec configuration - -```rust -use pmoserver::ServerBuilder; -use pmoaudiocache::AudioCacheExt; -use pmocovers::CoverCacheExt; -use pmoparadise::RadioParadiseExt; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080) - .build(); - - // Initialiser plusieurs extensions - server.init_audio_cache_configured().await?; - server.init_cover_cache_configured().await?; - server.init_radioparadise().await?; - - server.start().await; - server.wait().await; - Ok(()) +#[cfg(feature = "pmoserver")] +#[async_trait] +pub trait ExampleExt { + async fn init_example(&mut self) -> anyhow::Result>; } -``` -### Initialisation avec état partagé +#[cfg(feature = "pmoserver")] +#[async_trait] +impl ExampleExt for pmoserver::Server { + async fn init_example(&mut self) -> anyhow::Result> { + let resource = Arc::new(ExampleResource::new()); + let state = ExampleState { resource: resource.clone() }; + + let router = Router::new() + .route("/items", get(list_items)) + .with_state(state); + + self.add_router("/api/example", router).await; + + Ok(resource) + } +} -```rust -use pmoserver::ServerBuilder; -use pmomediaserver::ParadiseStreamingExt; -use pmocontrol::{ControlPoint, ControlPointState}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080) - .build(); - - // Initialiser Radio Paradise avec caches - let manager = server.init_paradise_streaming().await?; - - // Créer et enregistrer le Control Point - let control_point = Arc::new(ControlPoint::new()); - let state = ControlPointState::new(control_point); - // ... enregistrer les routes du control point ... - - server.start().await; - server.wait().await; - Ok(()) +#[cfg(feature = "pmoserver")] +async fn list_items(State(state): State) -> Json> { + let items = state.resource.list(); + Json(items) } ``` ## Références -### Fichiers sources analysés +### Exemples dans le codebase -- `pmoapp/src/lib.rs` : Pattern SPA avec RustEmbed -- `pmocontrol/src/pmoserver_ext.rs` : API REST complète avec Control Point -- `pmoparadise/src/pmoserver_ext.rs` : API REST avec client externe -- `pmoaudiocache/src/lib.rs` : Cache avec routes de fichiers -- `pmomediaserver/src/paradise_streaming.rs` : Extension complexe avec streaming +| Crate | Fichier | Pattern | +|-------|---------|---------| +| `pmoparadise` | `src/pmoserver_ext.rs:367-392` | Extension simple avec OpenAPI | +| `pmoaudiocache` | `src/lib.rs:225-260` | Extension avec cache et fichiers | +| `pmomediaserver` | `src/paradise_streaming.rs:70-148` | Extension avec routes dynamiques | +| `pmocontrol` | `src/pmoserver_ext.rs:68-92` | Handlers avec `spawn_blocking` | +| `pmoapp` | `src/lib.rs:145-165` | Extension SPA avec RustEmbed | ### Dépendances communes -- `axum` : Framework HTTP -- `async-trait` : Traits async -- `utoipa` : Documentation OpenAPI -- `tokio` : Runtime async -- `anyhow` : Gestion d'erreurs +- `axum` : Framework HTTP (Router, handlers, extracteurs) +- `async-trait` : Support des traits async +- `tokio` : Runtime async (spawn, spawn_blocking, timeout) +- `anyhow` : Gestion d'erreurs pour init +- `tracing` : Logging structuré +- `utoipa` : Documentation OpenAPI/Swagger - `serde` : Sérialisation JSON -- `tracing` : Logging - -## Conclusion - -Le pattern `pmoserver_ext` offre une architecture extensible et modulaire pour ajouter des fonctionnalités au serveur HTTP PMOMusic. En suivant les conventions établies et les bonnes pratiques, chaque nouvelle extension peut être développée indépendamment tout en s'intégrant de manière cohérente avec l'écosystème existant. diff --git a/Blackboard/Report/config_ext.md b/Blackboard/Report/config_ext.md new file mode 100644 index 00000000..fc6ef0a6 --- /dev/null +++ b/Blackboard/Report/config_ext.md @@ -0,0 +1,96 @@ +# Rapport : Documentation du pattern d'extension pmoconfig + +## Objectif de la tâche + +Créer une fiche descriptive documentant le pattern d'implémentation des traits d'extension de `pmoconfig::Config` en analysant les implémentations existantes dans les différents crates du projet. + +## Travail réalisé + +### 1. Analyse des fichiers source + +Les fichiers suivants ont été analysés : + +- `pmocovers/src/config_ext.rs` - Pattern cache avec conversion WebP +- `pmoaudiocache/src/config_ext.rs` - Pattern cache avec conversion FLAC +- `pmoqobuz/src/config_ext.rs` - Pattern authentification et rate limiting +- `pmocache/src/config_ext.rs` - Trait générique de cache et macro +- `pmoconfig/PASSWORD_ENCRYPTION.md` - Documentation du chiffrement +- `pmoupnp/src/config_ext.rs` - Pattern configuration UPnP +- `pmoparadise/src/config_ext.rs` - Pattern configuration minimale + +### 2. Patterns identifiés + +#### Pattern de base +Tous les traits d'extension suivent la même structure : +- Trait public avec méthodes getter/setter +- Implémentation pour `pmoconfig::Config` +- Utilisation de `get_value`/`set_value` génériques +- Constantes pour valeurs par défaut + +#### Patterns spécialisés +- **Cache** : Utilisation de `CacheConfigExt` et factory methods +- **Authentification** : Getters combinés, helpers de validation, déchiffrement automatique +- **Rate limiting** : Configuration des limites avec valeurs par défaut +- **Configuration minimale** : Auto-persistence des valeurs par défaut +- **UPnP** : Configuration des identifiants devices + +### 3. Structure de la documentation + +La documentation créée couvre : + +1. **Vue d'ensemble** : Objectif et principe du pattern +2. **Architecture** : Structure et flux de données +3. **Implémentation** : Guide détaillé avec patterns de code +4. **Patterns spécialisés** : Exemples pour chaque cas d'usage +5. **Bonnes pratiques** : Nommage, erreurs, documentation +6. **Exemples complets** : 3 implémentations complètes commentées +7. **Checklist** : Liste de vérification pour nouveaux traits +8. **Philosophie** : Principes directeurs et avantages + +### 4. Contenu clé + +#### Patterns de getters +- Getter simple avec valeur par défaut +- Getter avec auto-persistence +- Getter optionnel +- Getter avec déchiffrement +- Getter avec parsing et fallback + +#### Patterns de setters +- Setter simple +- Setter avec transformation +- Setter multiple (transaction) +- Setter de nettoyage + +#### Helpers +- Factory methods +- Getters combinés +- Helpers de validation + +### 5. Hiérarchie de configuration YAML + +Documentation des chemins standards : +- `host.*` : Configuration hôte/système +- `accounts.*` : Comptes et services +- `sources.*` : Sources de médias + +## Résultat + +Le document `Blackboard/Architecture/pmoconfig_ext.md` a été créé avec : +- 800+ lignes de documentation complète +- 3 exemples d'implémentation complète +- Patterns pour tous les cas d'usage identifiés +- Bonnes pratiques et anti-patterns +- Checklist d'implémentation + +## Fichiers créés ou modifiés + +- **Créé** : `Blackboard/Architecture/pmoconfig_ext.md` - Documentation complète du pattern +- **Créé** : `Blackboard/Report/config_ext.md` - Ce rapport + +## Conformité avec Rules.md + +- Documentation placée dans `Blackboard/Architecture/` comme demandé +- Rapport créé dans `Blackboard/Report/` avec le même nom de fichier +- Analyse focalisée sur l'objectif principal +- Documentation prête pour classification (Done/ToDiscuss) par l'humain diff --git a/Blackboard/Report/music_source.md b/Blackboard/Report/music_source.md new file mode 100644 index 00000000..bbfcbd18 --- /dev/null +++ b/Blackboard/Report/music_source.md @@ -0,0 +1,227 @@ +# Rapport : Documentation d'implémentation d'une nouvelle MusicSource + +## Objectif + +Créer une documentation complète et pratique pour guider l'implémentation d'une nouvelle source musicale dans l'écosystème PMOMusic. + +## Travail réalisé + +### 1. Analyse des sources existantes + +J'ai analysé deux implémentations de référence : + +- **pmoparadise/src/source.rs** : Source dynamique avec FIFO (radio streaming) +- **pmoqobuz/src/source.rs** : Source catalogue avec playlists lazy + +Ainsi que la documentation du trait : + +- **pmosource/README.md** : Vue d'ensemble du trait MusicSource +- **pmosource/ARCHITECTURE.md** : Architecture et design decisions + +### 2. Identification des patterns principaux + +Deux patterns majeurs ont été identifiés : + +#### Pattern 1 : Source dynamique FIFO (Radio Paradise) + +**Caractéristiques :** +- Flux continu de tracks avec capacité limitée +- Suppression automatique des plus anciens +- Callbacks sur playlists pour détecter les changements +- Notification du ContentDirectory via notifier injecté +- Adaptation des IDs playlist → schema source + +**Éléments clés :** +```rust +update_counter: Arc> +last_change: Arc> +callback_tokens: Arc>> +container_notifier: Option> +``` + +#### Pattern 2 : Source catalogue lazy (Qobuz) + +**Caractéristiques :** +- Catalogue vaste avec navigation hiérarchique +- Cache lazy pour audio, eager pour covers +- Playlists créées à la demande avec TTL +- LazyProvider pour télécharger l'audio à la lecture +- Métadonnées riches stockées dans le cache + +**Éléments clés :** +```rust +SourceCacheManager centralisé +QobuzLazyProvider implémentant LazyProvider +Playlists avec rôle Album et TTL de 7 jours +Adaptation IDs avec metadata source_track_id +``` + +### 3. Structure du document créé + +Le document `Blackboard/Architecture/music_source.md` contient : + +#### Table des matières +1. Vue d'ensemble +2. Structure d'une MusicSource +3. Implémentation du trait MusicSource +4. Patterns d'implémentation +5. Intégration avec l'écosystème PMOMusic +6. Checklist de mise en œuvre +7. Exemples de référence + +#### Sections détaillées + +**Section 1 : Vue d'ensemble** +- Définition d'une MusicSource +- Types de sources (dynamique vs statique) +- Capacités du trait + +**Section 2 : Structure** +- Organisation du code +- Dépendances recommandées +- Features Cargo + +**Section 3 : Implémentation du trait** +- Informations de base (name, id, default_image) +- Navigation ContentDirectory (root_container, browse, resolve_uri) +- Support FIFO (append_track, remove_oldest, update_id) +- Support statique (get_items, search) + +**Section 4 : Patterns** +- Pattern 1 : Source dynamique avec FIFO (code complet) +- Pattern 2 : Source catalogue avec playlists lazy (code complet) +- Pattern 3 : Adaptation des IDs entre playlist et source + +**Section 5 : Intégration écosystème** +- pmoplaylist : création et gestion de playlists +- pmoaudiocache/pmocovers via SourceCacheManager +- pmodidl : conversion vers DIDL-Lite +- LazyProvider personnalisé + +**Section 6 : Checklist** +- Phase 1 : Structure de base +- Phase 2 : Navigation ContentDirectory +- Phase 3 : Résolution d'URI +- Phase 4 : Support FIFO (si dynamique) +- Phase 5 : Support statique (si catalogue) +- Phase 6 : Intégration avancée +- Phase 7 : Tests et validation + +**Section 7 : Exemples de référence** +- Radio Paradise (source dynamique FIFO) +- Qobuz (source catalogue lazy) +- Schemas d'Object ID détaillés + +### 4. Points techniques importants documentés + +#### Schema d'Object ID + +Format recommandé hiérarchique : +``` + +:albums +:album: +:track: +:playlist: +``` + +Exemples concrets de Radio Paradise et Qobuz fournis. + +#### Adaptation des IDs + +Code complet pour adapter les items de playlist au schema de la source : +- Extraction du cache_pk depuis l'URL +- Récupération du source_track_id depuis metadata +- Reconstruction de l'ID correct +- Normalisation des URLs (relatives → absolues) +- Ajout de champs requis (genre) + +#### Cache lazy vs eager + +Stratégie claire : +- **Covers** : Cache eager (petit, UI en a besoin immédiatement) +- **Audio** : Cache lazy (grand, téléchargé à la demande) + +#### Thread Safety + +Règles explicites : +- `Arc>` pour état mutable partagé +- `tokio::sync::RwLock` pour async +- Éviter `Rc<>`, `RefCell` (non thread-safe) +- Implémenter `Clone` via `Arc<>` + +#### Compatibilité UPnP + +Points de vigilance : +- Genre obligatoire pour certains clients (gupnp-av-cp) +- URLs absolues uniquement +- Protocol Info correct pour FLAC +- Duration au format `H:MM:SS` +- childCount optionnel mais recommandé + +### 5. Code d'exemple complet + +Le document contient des exemples de code complets et fonctionnels pour : + +1. **Structure de base** : définition de la struct et implémentation basique +2. **Navigation** : root_container et browse avec pattern matching +3. **Résolution URI** : avec fallback cache → original +4. **FIFO** : append_track, remove_oldest, callbacks +5. **Adaptation IDs** : fonction complète d'adaptation +6. **LazyProvider** : implémentation personnalisée +7. **Conversion DIDL** : traits ToDIDLContainer et ToDIDLItem + +## Couverture des besoins + +### Sources couvertes + +- ✅ Radio Paradise : source dynamique FIFO +- ✅ Qobuz : source catalogue lazy +- ✅ Patterns génériques applicables à d'autres sources + +### Cas d'usage couverts + +- ✅ Source radio/streaming live +- ✅ Source catalogue de streaming (Spotify, Deezer, etc.) +- ✅ Source bibliothèque locale +- ✅ Source playlists fixes +- ✅ Source avec authentification (via client) + +### Intégrations couvertes + +- ✅ pmoplaylist (FIFO et persistant) +- ✅ pmoaudiocache (cache audio) +- ✅ pmocovers (cache covers) +- ✅ SourceCacheManager (centralisé) +- ✅ LazyProvider (téléchargement lazy) +- ✅ pmodidl (DIDL-Lite) + +## Limitations et améliorations futures + +### Limitations actuelles + +1. **Search** : Pas d'exemple détaillé de search (optionnel dans le trait) +2. **Authentification** : Mentionné mais pas d'exemple complet +3. **Multi-format** : Pas d'exemple de source supportant plusieurs formats +4. **Offline** : Pas de pattern pour source offline/synchronisation + +### Améliorations possibles + +1. Ajouter un exemple complet de search avec filtres +2. Documenter l'intégration avec un système d'auth OAuth +3. Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC) +4. Documenter la gestion offline avec synchronisation + +## Fichiers créés + +- `Blackboard/Architecture/music_source.md` : Documentation complète (15 sections, ~800 lignes) + +## Conclusion + +Le document créé fournit un guide complet et pratique pour implémenter une nouvelle MusicSource. Il combine : + +- **Théorie** : Architecture, design patterns, principes +- **Pratique** : Code complet, exemples réels, checklist +- **Référence** : Schemas d'Object ID, intégrations, compatibilité + +Un développeur peut suivre ce guide étape par étape pour créer une nouvelle source musicale compatible avec l'écosystème PMOMusic, en s'inspirant des patterns éprouvés de Radio Paradise et Qobuz. diff --git a/Blackboard/Report/pmoserver_ext.md b/Blackboard/Report/pmoserver_ext.md index c579d740..ff3dac7b 100644 --- a/Blackboard/Report/pmoserver_ext.md +++ b/Blackboard/Report/pmoserver_ext.md @@ -1,157 +1,77 @@ # Rapport : Documentation du pattern pmoserver_ext -## Date -2026-01-12 +## Contexte -## Tâche originale -Réaliser une fiche descriptive sur le pattern à suivre pour implémenter un trait d'extension du PMO serveur, en analysant les fichiers : -- pmoapp/src/lib.rs -- pmocontrol/src/pmoserver_ext.rs -- pmoparadise/src/pmoserver_ext.rs -- pmoaudiocache/src/lib.rs -- pmomediaserver/src/paradise_streaming.rs +Documentation du pattern d'extension du PMOServer à travers plusieurs itérations basées sur les retours utilisateur. ## Travail réalisé -### 1. Analyse des fichiers sources -J'ai analysé les cinq fichiers sources mentionnés pour identifier les patterns récurrents : +### Analyse des fichiers sources -- **pmoapp/src/lib.rs** : Illustre le pattern d'intégration d'une Single Page Application (Vue.js) via RustEmbed -- **pmocontrol/src/pmoserver_ext.rs** : Montre une API REST complète avec gestion d'état, timeouts, spawn_blocking pour opérations synchrones -- **pmoparadise/src/pmoserver_ext.rs** : Démontre l'intégration d'un client externe avec documentation OpenAPI -- **pmoaudiocache/src/lib.rs** : Présente le pattern de cache avec routes de fichiers et registre singleton -- **pmomediaserver/src/paradise_streaming.rs** : Illustre une extension complexe avec streaming, gestion de caches multiples et async-trait +Les fichiers suivants ont été analysés pour extraire le pattern : -### 2. Identification des patterns communs +- `pmoapp/src/lib.rs` : Pattern SPA avec RustEmbed +- `pmocontrol/src/pmoserver_ext.rs` : API REST avec Control Point (1506+ lignes) +- `pmoparadise/src/pmoserver_ext.rs` : API REST simple avec client externe +- `pmoaudiocache/src/lib.rs` : Extension avec cache et fichiers +- `pmomediaserver/src/paradise_streaming.rs` : Extension complexe avec streaming -#### Architecture de base -Tous les exemples suivent une architecture similaire : -1. Définition d'un trait d'extension (ex: `AudioCacheExt`, `RadioParadiseExt`) -2. Implémentation du trait pour `pmoserver::Server` -3. Utilisation de feature gates `#[cfg(feature = "pmoserver")]` +### Round 1 : Document initial -#### Composants récurrents -- **Trait d'extension** : Interface publique avec méthodes `init_*` ou `add_*` -- **État partagé** : Structure `{Domaine}State` avec `Clone` et `Arc` -- **Handlers HTTP** : Fonctions async avec extracteurs Axum -- **Documentation OpenAPI** : Annotations `utoipa` pour Swagger -- **Router Axum** : Sous-routers réutilisables +Premier jet documentant exhaustivement tous les aspects des extensions (~850 lignes). -#### Patterns avancés identifiés -- Utilisation de `spawn_blocking` pour opérations synchrones UPnP -- Timeouts systématiques pour opérations réseau -- Spawn en arrière-plan pour opérations longues -- Registres globaux (singletons) avec `OnceCell` -- Intégration SPA avec RustEmbed +### Round 2 : Recentrage sur le pattern -### 3. Rédaction de la documentation +**Annotation** : "se recentrer sur le sujet principal" -Le document créé (`Blackboard/Architecture/pmoserver_ext.md`) contient : +**Actions** : +- Réduction de ~850 à ~400 lignes +- Suppression des digressions (OpenAPI détaillé, handlers spécifiques) +- Focus sur l'anatomie du pattern en 5 étapes +- Ajout d'une checklist et d'un exemple minimal -#### Structure principale -1. **Vue d'ensemble** : Principe et architecture du pattern -2. **Composants du pattern** : 6 composants détaillés avec exemples -3. **Pattern avancé** : Utilisation d'async-trait -4. **Patterns d'intégration** : Control Point et WebApp -5. **Registres globaux** : Pattern singleton avec OnceCell -6. **Checklist d'implémentation** : Guide pas à pas -7. **Bonnes pratiques** : 5 sections (erreurs, performance, concurrence, documentation, features) -8. **Exemples d'utilisation** : 3 exemples concrets +**Résultat** : Document focalisé sur l'implémentation du pattern uniquement. + +### Round 3 : Réintégration OpenAPI + +**Annotation** : "Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le." + +**Actions** : +- Ajout d'une section complète "Documentation OpenAPI avec utoipa" (~260 lignes) +- 5 sous-sections détaillées : + 1. Configuration de base (dépendances Cargo) + 2. Définition des schémas avec `#[derive(ToSchema)]` + 3. Annotation des handlers avec `#[utoipa::path]` + 4. Création de la structure `#[derive(OpenApi)]` + 5. Exemple complet extrait de Radio Paradise +- Mise à jour de la checklist avec section "Documentation OpenAPI" +- Ajout des dépendances `utoipa` et `serde` dans la section références + +**Positionnement** : Section insérée après "Méthodes disponibles du serveur" et avant "Patterns courants", car elle fait partie intégrante de l'implémentation. + +## Structure finale du document + +1. **Vue d'ensemble** : Principe du pattern +2. **Anatomie d'une extension** : 5 étapes détaillées +3. **Méthodes disponibles du serveur** : API de `pmoserver::Server` +4. **Documentation OpenAPI avec utoipa** : Guide complet en 5 étapes ⭐ *Ajouté au Round 3* +5. **Patterns courants** : 3 exemples concrets +6. **Gestion des opérations longues** : spawn_blocking, timeouts, background tasks +7. **Checklist d'implémentation** : Organisée par catégories +8. **Exemple complet minimal** : Code fonctionnel 9. **Références** : Fichiers sources et dépendances -#### Points forts de la documentation +## Résultat final -**Exemples de code concrets** : Chaque concept est illustré par des extraits de code réels avec références aux fichiers sources (ex: `pmoaudiocache/src/lib.rs:200-215`). +Le document est maintenant : -**Patterns avancés documentés** : -- Gestion des timeouts pour éviter les blocages réseau -- Utilisation de `spawn_blocking` pour les opérations synchrones -- Spawn en arrière-plan pour retour immédiat à l'utilisateur -- Registres singleton pour partage de ressources entre extensions +- **Complet** : Couvre tous les aspects essentiels incluant OpenAPI +- **Structuré** : Progression logique de la configuration à l'implémentation +- **Pratique** : Exemples de code concrets extraits du codebase +- **Actionnable** : Checklist détaillée en 4 catégories -**Checklist pratique** : Liste de 7 sections avec cases à cocher pour guider l'implémentation d'une nouvelle extension. +Taille finale : ~660 lignes (avec section OpenAPI complète) -**Bonnes pratiques** : Section dédiée couvrant la gestion d'erreurs, performance, concurrence, documentation et features Cargo. +## Fichiers modifiés -**Exemples d'utilisation** : Trois scénarios d'utilisation progressive (simple, avec configuration, avec état partagé). - -### 4. Organisation des livrables - -#### Document d'architecture -- **Emplacement** : `Blackboard/Architecture/pmoserver_ext.md` -- **Taille** : 745 lignes -- **Format** : Markdown structuré avec syntaxe code Rust - -#### Rapport de travail -- **Emplacement** : `Blackboard/Report/pmoserver_ext.md` -- **Contenu** : Ce document - -## Observations techniques - -### Cohérence architecturale -Tous les modules analysés suivent une architecture très cohérente : -- Même convention de nommage (`{Domaine}Ext`, `{Domaine}State`) -- Même structure d'implémentation (trait → implémentation → handlers) -- Même gestion des erreurs (`anyhow::Result` pour init, `Result` pour handlers) - -### Patterns de concurrence -La codebase fait un excellent usage des primitives Tokio : -- `spawn_blocking` pour isoler les opérations synchrones UPnP -- `spawn` pour les tâches en arrière-plan (ex: seek_queue_index) -- `Arc` plutôt que `Mutex` pour le partage de ressources -- Timeouts systématiques pour éviter les blocages - -### Documentation OpenAPI -L'utilisation de `utoipa` est systématique et bien structurée : -- Annotations `#[utoipa::path(...)]` sur tous les handlers -- Schémas `#[derive(ToSchema)]` pour tous les types exposés -- Documentation complète avec exemples dans les structures `OpenApi` - -## Qualité du résultat - -### Points forts -1. **Exhaustivité** : Tous les aspects du pattern sont couverts -2. **Exemples concrets** : Extraits de code réels avec références aux fichiers -3. **Praticité** : Checklist et bonnes pratiques directement applicables -4. **Pédagogie** : Structure progressive du simple au complexe - -### Ce qui pourrait être amélioré -1. **Diagrammes** : Ajout de diagrammes de séquence pour les patterns complexes -2. **Tests** : Exemples de tests unitaires pour les handlers -3. **Cas d'erreur** : Documentation des cas d'erreur fréquents et leurs solutions -4. **Performance** : Benchmarks ou métriques de performance - -## Recommandations - -### Pour l'utilisation de cette documentation -1. Utiliser la checklist comme guide lors de l'implémentation d'une nouvelle extension -2. Se référer aux exemples de code pour les patterns spécifiques (timeouts, spawn, etc.) -3. Consulter les bonnes pratiques avant chaque implémentation - -### Pour l'évolution de la documentation -1. Ajouter des exemples de tests à mesure que le projet mûrit -2. Documenter les problèmes rencontrés et leurs solutions -3. Mettre à jour avec de nouveaux patterns si l'architecture évolue - -### Pour le projet PMOMusic -1. Considérer l'extraction de macros pour réduire le boilerplate -2. Envisager un générateur de code pour les extensions simples -3. Documenter les décisions d'architecture dans ce répertoire - -## Conclusion - -La documentation du pattern `pmoserver_ext` est maintenant disponible dans `Blackboard/Architecture/pmoserver_ext.md`. Elle fournit un guide complet et pratique pour implémenter de nouvelles extensions au serveur PMOMusic en suivant les conventions établies. - -Le pattern identifié est solide, cohérent et bien adapté aux besoins du projet. La documentation créée devrait permettre à tout développeur de comprendre et d'appliquer ce pattern efficacement. - -## Fichiers créés - -1. `Blackboard/Architecture/pmoserver_ext.md` (745 lignes) - Documentation technique complète -2. `Blackboard/Report/pmoserver_ext.md` (ce fichier) - Rapport de travail - -## Prochaines étapes suggérées - -1. Révision du document par l'humain -2. Décision de classement : `Done` ou `ToDiscuss` -3. Si `Done` : Synthèse finale pour archivage -4. Si `ToDiscuss` : Annotations et travail supplémentaire +- `Blackboard/Architecture/pmoserver_ext.md` : Document complet avec OpenAPI (660 lignes) diff --git a/Blackboard/Rules.md b/Blackboard/Rules.md index be2c61f7..64afbf78 100644 --- a/Blackboard/Rules.md +++ b/Blackboard/Rules.md @@ -36,7 +36,7 @@ Blackboard - `ToThinkAbout`: Contient les tâches à réfléchir pour le projet. -Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des idées et des questions à réfléchir pour le projet. Ils sont utilisés à terme pour générer les documents de tâche qui seront placés dans le répertoire `Todo`. +Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des idées et des questions à réfléchir pour le projet. Ils sont utilisés à terme pour générer les documents de tâche qui seront placés dans le répertoire `Todo`. Les fichiers de ce répertoire sont au format Markdown et sont écrits en collaboration entre l'humain et le LLM. Les deux ont le droit de modifier les fichiers. ### Les quatres répertoires de base pour le workflow de développement. - `Todo`: Contient les tâches à faire pour le projet. @@ -44,7 +44,9 @@ Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des - `ToDiscuss`: Contient les tâches à discuter pour le projet. - `Done`: Contient les tâches terminées pour le projet. -Les tâches à faire sont décrites dans des fichiers présents dans le répertoire `Todo`. Leur réalisation conduit à la rédaction d'un rapport à placer dans le répertoire `Report`. Le rapport d'une tâche doit avoir le même nom de fichier que la tâche originale. À la suite du rapport, deux issues sont possibles. Soit la tâche est considérée comme achevée. Dans ce cas, elle est déplacée dans le répertoire `Done`. Soit la tâche est considérée comme incomplète. Dans ce cas, elle est déplacée dans le répertoire `ToDiscuss`. +Les tâches à faire sont décrites dans des fichiers présents dans le répertoire `Todo`. Leur réalisation conduit à la rédaction d'un rapport à placer dans le répertoire `Report`. Le rapport d'une tâche doit avoir le même nom de fichier que la tâche originale. Aucun autre rapport détaillé ne devra être produit à la fin de la tache dans le fil de la discussion. Juste une liste des documents créés ou midifiés sera donné. + +À la suite du rapport, deux issues sont possibles. Soit la tâche est considérée comme achevée. Dans ce cas, elle est déplacée dans le répertoire `Done`. Soit la tâche est considérée comme incomplète. Dans ce cas, elle est déplacée dans le répertoire `ToDiscuss`. C'est l'humain qui décide quand une tâche peut être considérée comme *done* ou *to discuss*. En aucun cas, l'assistant peut décider de classifier une tâche après la rédaction du rapport. diff --git a/Blackboard/ToDiscuss/pmoserver_ext.md b/Blackboard/ToDiscuss/pmoserver_ext.md index e69de29b..dfd7c55c 100644 --- a/Blackboard/ToDiscuss/pmoserver_ext.md +++ b/Blackboard/ToDiscuss/pmoserver_ext.md @@ -0,0 +1,21 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +Partir des fichiers suivants: + +- pmoapp/src/lib.rs +- pmocontrol/src/pmoserver_ext.rs +- pmoparadise/src/pmoserver_ext.rs +- pmoaudiocache/src/lib.rs +- pmomediaserver/src/paradise_streaming.rs + +réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension du PMO serveur. + +Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoserver_ext.md` + +## Round 2 + +J'ai regardé ton document généré et je trouve que tu t'élargis du sujet central documenter lecture d'une extension PMOserver. Peux-tu te recentrer sur le sujet principal. + +## Round 3 + +Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le. diff --git a/Blackboard/ToThinkAbout/PlayListSource.md b/Blackboard/ToThinkAbout/PlayListSource.md new file mode 100644 index 00000000..d280cdf6 --- /dev/null +++ b/Blackboard/ToThinkAbout/PlayListSource.md @@ -0,0 +1,570 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +# PlaylistSource : MusicSource pour playlists + +Implémenter une source PMOMusic capable de servir un catalogue de playlists hiérarchisé via UPnP. + +--- + +## 📋 Décisions de conception + +### Format pivot : JSPF (JSON) + +**Choix** : JSPF comme format interne central +- Métadonnées riches (title, creator, album, annotation, image, duration, etc.) +- JSON natif avec serde (Rust-friendly) +- Standard ouvert (Xiph.Org) +- Extensible via champ `meta` + +**Formats supportés** : +- ✅ **JSPF** (.jspf) - JSON, format natif +- ✅ **XSPF** (.xspf) - XML, conversion vers JSPF +- ✅ **M3U8** (.m3u8) - Texte, métadonnées limitées +- ✅ **PLS** (.pls) - INI-like, très basique + +**Architecture** : 1 Writer (JSPF) + 4 Readers (JSPF, XSPF, M3U8, PLS) → Structure JSPF centrale + +```mermaid +flowchart LR + JSPF[JSPF JSON] --> JR[JspfReader] + XSPF[XSPF XML] --> XR[XspfReader] + M3U8[M3U8 Text] --> MR[M3uReader] + PLS[PLS INI] --> PR[PlsReader] + + JR --> CORE[JSPF Structure] + XR --> CORE + MR --> CORE + PR --> CORE + + CORE --> W[JspfWriter] + W --> OUT[.jspf] +``` + +--- + +## 🗂️ Structure du répertoire + +``` +playlists/ +├── metadata.json # Métadonnées du conteneur racine +├── Jazz/ +│ ├── metadata.json # Métadonnées catégorie Jazz +│ ├── standards.jspf +│ ├── bebop.jspf +│ └── covers/ +│ └── standards.webp +├── Classical/ +│ ├── metadata.json +│ ├── baroque.jspf +│ └── romantic.jspf +└── Rock/ + ├── metadata.json + └── 70s.jspf +``` + +### Fichier `metadata.json` (conteneur) + +```json +{ + "container": { + "title": "Collection Jazz", + "description": "Mes playlists jazz favorites", + "creator": "John Doe", + "image": "covers/jazz-collection.webp", + "date": "2026-01-15", + "meta": [ + {"rel": "genre", "content": "Jazz"}, + {"rel": "mood", "content": "Relaxing"} + ] + } +} +``` + +--- + +## 🏗️ Composants à implémenter + +### 1. Crate `pmojspf` (parsing playlists) + +**Responsabilité** : Parser différents formats de playlist vers structure JSPF unifiée + +#### Structure + +``` +pmojspf/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # API publique +│ ├── model.rs # Structures JSPF +│ ├── writer.rs # JspfWriter +│ ├── reader/ +│ │ ├── mod.rs # Trait PlaylistReader +│ │ ├── jspf.rs # Reader JSON natif (serde_json) +│ │ ├── xspf.rs # Reader XML (xml-rs) +│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne) +│ │ └── pls.rs # Reader PLS (format INI-like) +│ └── error.rs +└── tests/ + └── fixtures/ +``` + +#### Modèle de données + +**Inspiré de la crate [xspf](https://crates.io/crates/xspf) v0.4.2** + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Jspf { + pub playlist: JspfPlaylist, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JspfPlaylist { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub attribution: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub meta: Vec, + #[serde(default)] + pub track: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JspfTrack { + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub location: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub identifier: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub creator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub album: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_num: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, // millisecondes + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub meta: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JspfAttribution { + Location { location: String }, + Identifier { identifier: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JspfMeta { + pub rel: String, + pub content: String, +} +``` + +#### Trait PlaylistReader + +```rust +use std::io::Read; + +pub trait PlaylistReader { + fn read(reader: R) -> Result; + fn from_str(s: &str) -> Result; + fn from_file>(path: P) -> Result; +} +``` + +#### Implémentations des Readers + +##### JspfReader (✅ Simple - serde_json) + +```rust +pub struct JspfReader; + +impl PlaylistReader for JspfReader { + fn read(reader: R) -> Result { + serde_json::from_reader(reader) + .map_err(|e| Error::ParseError(format!("JSON: {}", e))) + } +} +``` + +**Dépendances** : `serde_json` + +##### XspfReader (⚠️ Complexe - xml-rs) + +**Approche** : Machine à états XML pour parser ``, ``, etc. + +**Alternative** : Utiliser la crate `xspf` existante puis convertir → JSPF + +```rust +pub struct XspfReader; + +impl PlaylistReader for XspfReader { + fn read(reader: R) -> Result { + // Parser XML avec EventReader + // État : in_playlist, in_track, current_element + // Mapping: → playlist.title, <track> → JspfTrack + } +} +``` + +**Dépendances** : `xml-rs` ou réutiliser `xspf` crate + +##### M3uReader (⚙️ Modéré - ligne par ligne) + +**Format** : +```m3u +#EXTM3U +#PLAYLIST:Ma Playlist Jazz +#EXTINF:284,John Coltrane - Giant Steps +#EXTART:John Coltrane +#EXTALB:Giant Steps +file:///music/coltrane.flac +``` + +```rust +pub struct M3uReader; + +impl PlaylistReader for M3uReader { + fn read<R: Read>(reader: R) -> Result<Jspf> { + // BufReader ligne par ligne + // Parser #EXTINF:duration,artist - title + // Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG) + } +} +``` + +**Dépendances** : stdlib uniquement + +**Limitations** : Métadonnées pauvres, beaucoup de champs `None` + +##### PlsReader (⚙️ Modéré - format INI) + +**Format** : +```ini +[playlist] +NumberOfEntries=2 +File1=file:///music/coltrane.flac +Title1=John Coltrane - Giant Steps +Length1=284 +``` + +```rust +pub struct PlsReader; + +impl PlaylistReader for PlsReader { + fn read<R: Read>(reader: R) -> Result<Jspf> { + // HashMap<index, (file, title, duration)> + // Parser FileN=..., TitleN=..., LengthN=... + // Trier par index et convertir en JspfTrack + } +} +``` + +**Dépendances** : stdlib uniquement + +**Limitations** : File, Title, Length seulement + +#### JspfWriter + +```rust +pub struct JspfWriter; + +impl JspfWriter { + pub fn write<W: Write>(jspf: &Jspf, writer: W) -> Result<()>; + pub fn write_pretty<W: Write>(jspf: &Jspf, writer: W) -> Result<()>; + pub fn to_string(jspf: &Jspf) -> Result<String>; + pub fn to_string_pretty(jspf: &Jspf) -> Result<String>; +} +``` + +#### API publique + +```rust +pub use model::{Jspf, JspfPlaylist, JspfTrack, JspfMeta, JspfAttribution}; +pub use reader::{PlaylistReader, JspfReader, XspfReader, M3uReader, PlsReader}; +pub use writer::JspfWriter; + +pub enum PlaylistFormat { + Jspf, + Xspf, + M3u8, + Pls, +} + +impl PlaylistFormat { + pub fn from_extension(ext: &str) -> Option<Self>; +} + +pub fn read_playlist<R: Read>(reader: R, format: PlaylistFormat) -> Result<Jspf>; +``` + +--- + +### 2. Crate `pmoplaylists` (PlaylistSource) + +**Responsabilité** : Implémenter `MusicSource` pour servir playlists via UPnP + +#### Structures principales + +```rust +pub struct PlaylistSource { + root_path: PathBuf, + playlists: Arc<RwLock<HashMap<String, ParsedPlaylist>>>, + containers: Arc<RwLock<HashMap<PathBuf, ContainerMetadata>>>, + watcher: Option<notify::RecommendedWatcher>, + base_url: String, + update_counter: Arc<RwLock<u32>>, + last_change: Arc<RwLock<SystemTime>>, +} + +pub struct ParsedPlaylist { + pub metadata: PlaylistMetadata, + pub tracks: Vec<PlaylistTrack>, + pub source_path: PathBuf, + pub format: PlaylistFormat, +} + +pub struct ContainerMetadata { + pub title: Option<String>, + pub description: Option<String>, + pub creator: Option<String>, + pub image: Option<String>, + pub date: Option<String>, + pub meta: Vec<MetaEntry>, +} + +pub struct ContainerMetadataFile { + pub container: ContainerMetadata, +} +``` + +#### Fonctionnalités + +1. **Scan hiérarchique** : Parser récursivement dossiers + `metadata.json` + playlists +2. **Cache** : Éviter re-parsing (playlists + conteneurs) +3. **Hot reload** : `notify` pour détecter changements +4. **Browse UPnP** : Générer DIDL-Lite avec métadonnées conteneurs +5. **Content resolution** : Résoudre URIs via `SourceCacheManager` +6. **Cover art** : Servir images playlists, tracks, conteneurs + +#### Object IDs + +``` +playlists # Racine +playlists:category:{path} # Catégorie (dossier) +playlists:playlist:{id} # Playlist +playlists:playlist:{id}:track:{index} # Track dans playlist +``` + +#### Gestion `metadata.json` + +```rust +fn load_container_metadata(&self, dir_path: &Path) -> Result<ContainerMetadata> { + let metadata_path = dir_path.join("metadata.json"); + + if metadata_path.exists() { + let content = fs::read_to_string(&metadata_path)?; + let file: ContainerMetadataFile = serde_json::from_str(&content)?; + Ok(file.container) + } else { + // Fallback : nom du répertoire + Ok(ContainerMetadata { + title: Some(dir_path.file_name()?.to_str()?.to_string()), + ..Default::default() + }) + } +} +``` + +--- + +### 3. Extension pmoconfig + +**Fichier** : `pmoplaylists/src/config_ext.rs` + +**Pattern** : [pmoconfig_ext.md](../Architecture/pmoconfig_ext.md) + +```rust +use pmoconfig::Config; +use std::path::{Path, PathBuf}; + +const DEFAULT_PLAYLISTS_DIR: &str = "playlists"; + +pub trait PlaylistSourceConfigExt { + fn get_playlists_dir(&self) -> PathBuf; + fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()>; + fn get_playlists_enabled(&self) -> bool; + fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()>; + fn get_playlists_supported_formats(&self) -> Vec<String>; + fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()>; +} + +impl PlaylistSourceConfigExt for Config { + fn get_playlists_dir(&self) -> PathBuf { + self.get_managed_dir("sources.playlists.directory", DEFAULT_PLAYLISTS_DIR) + .expect("Failed to get playlists directory") + } + + fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> { + self.set_managed_dir("sources.playlists.directory", path) + } + + fn get_playlists_enabled(&self) -> bool { + self.get_value("sources.playlists.enabled") + .unwrap_or_else(|_| { + let _ = self.set_value("sources.playlists.enabled", true); + true + }) + } + + fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()> { + self.set_value("sources.playlists.enabled", enabled) + } + + fn get_playlists_supported_formats(&self) -> Vec<String> { + self.get_value("sources.playlists.formats") + .unwrap_or_else(|_| { + let default = vec!["jspf".into(), "xspf".into(), "m3u8".into(), "pls".into()]; + let _ = self.set_value("sources.playlists.formats", &default); + default + }) + } + + fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()> { + self.set_value("sources.playlists.formats", formats) + } +} +``` + +**Config YAML** : + +```yaml +sources: + playlists: + enabled: true + directory: "playlists" + formats: + - jspf + - xspf + - m3u8 + - pls +``` + +**Utilisation** : + +```rust +use pmoconfig::Config; +use pmoplaylists::config_ext::PlaylistSourceConfigExt; + +let config = Config::load()?; + +if config.get_playlists_enabled() { + let playlists_dir = config.get_playlists_dir(); + let playlist_source = PlaylistSource::new(playlists_dir, config.clone())?; +} +``` + +--- + +## 🔌 Intégration MusicBrainz (optionnelle - Phase 2) + +### Crate recommandée : `musicbrainz_rs` + +[musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) v0.5+ +- Client async/blocking +- Rate limiting automatique (1 req/sec) +- Support CoverArt Archive +- MSRV: Rust 1.71.1 + +### Cas d'usage + +1. **Résolution d'identifiants** : + ```json + {"identifier": ["musicbrainz://recording/abc123"], "title": null} + ``` + → Récupérer métadonnées depuis MusicBrainz + +2. **Enrichissement playlists pauvres** : M3U8/PLS → MusicBrainz → métadonnées complètes + +3. **Cover art** : CoverArt Archive + +### Configuration + +```yaml +sources: + playlists: + musicbrainz: + enabled: false + enrich_metadata: false + rate_limit_per_sec: 1 +``` + +**Stratégie** : +- **Phase 1 (MVP)** : Ne pas implémenter, stocker identifiants tel quel +- **Phase 2** : Dépendance optionnelle, service asynchrone, configurable + +--- + +## 📝 Prochaines étapes + +1. ✅ Choix format : JSPF central +2. ✅ Modèle données : Structures JSPF +3. ✅ Extension pmoconfig : Trait défini +4. ⏳ **Implémenter `pmojspf`** : + - `JspfReader` (serde_json) + - `XspfReader` (xml-rs ou crate xspf) + - `M3uReader` (parsing ligne par ligne) + - `PlsReader` (format INI) + - `JspfWriter` (serde_json) +5. ⏳ **Implémenter `pmoplaylists`** : + - `PlaylistSource` (trait `MusicSource`) + - Scan hiérarchique + cache + - Hot reload (notify) + - Browse UPnP (DIDL-Lite) + - Gestion `metadata.json` +6. ⏳ Tests avec clients UPnP + +--- + +## 📚 Sources + +### Spécifications +- [XSPF Spec](https://www.xspf.org/spec) +- [JSPF Spec](https://www.xspf.org/jspf) +- [M3U - Wikipedia](https://en.wikipedia.org/wiki/M3U) +- [PLS - Wikipedia](https://en.wikipedia.org/wiki/PLS_(file_format)) + +### Crates Rust +- [xspf](https://crates.io/crates/xspf) - Parser XML XSPF +- [musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) - API MusicBrainz +- [MusicBrainz API Docs](https://musicbrainz.org/doc/MusicBrainz_API) diff --git a/Blackboard/Todo/Pinnable_cache_item.md b/Blackboard/Todo/Pinnable_cache_item.md new file mode 100644 index 00000000..e6e838bb --- /dev/null +++ b/Blackboard/Todo/Pinnable_cache_item.md @@ -0,0 +1,8 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + + +La crâte PMOcache, implémente un system de cache qui pourrait être étendu pour permettre une utilisation plus large. L'idée est de modifier les règles de déletion des items. Actuellement le cache a une capacité maximale. Et les items ont des TTL, qui peuvent être non définies. Lorsque le cash est plein, les plus vieux items en termes d'utilisation ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de rajouter une fonctionnalité qui permet d'épingler certains items pour les rendre non destructibles. Ils pourraient aussi sortir du comptage général des items pour savoir si le cache est plein. + +Il faudra modifier la structure de la base de données. Ajouter une colonne indiquant cette propriété. Mettre une règle métier en disant qu'on ne peut pas être à la fois épinglés et avec un TTL. + +On se moque de maintenir la compatibilité avec la base de données actuelle, il n'y a pas à prévoir de phase de transition. Nous sommes en période de développement. diff --git a/Blackboard/Todo/WeabApp_debouncingSSE.md b/Blackboard/Todo/WeabApp_debouncingSSE.md index e69de29b..b1fd5aff 100644 --- a/Blackboard/Todo/WeabApp_debouncingSSE.md +++ b/Blackboard/Todo/WeabApp_debouncingSSE.md @@ -0,0 +1,5 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +Dans l'application web: `pmoapp/webapp`, pour sa partie point de contrôle, L'interface utilisateur se met à jour en fonction des événements qui arrivent sur un canal SSE. Actuellement, il y a une logique de débouncing sur ce canal. La logique de débouncing n'est normalement pas nécessaire pour un flux SSE qui est contrôlé par le serveur. + +- Supprimer cette logique de débouncing de l'application PMOControl. diff --git a/Blackboard/Todo/config_ext.md b/Blackboard/Todo/config_ext.md new file mode 100644 index 00000000..066f28a6 --- /dev/null +++ b/Blackboard/Todo/config_ext.md @@ -0,0 +1,17 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +Partir des fichiers suivants: + +- pmocovers/src/config_ext.rs +- pmoaudiocache/src/config_ext.rs +- pmoqobuz/src/config_ext.rs +- pmocache/src/config_ext.rs +- pmoconfig/PASSWORD_ENCRYPTION.md +- pmoupnp/src/config_ext.rs +- pmoparadise/src/config_ext.rs + +réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension de PMOConfig (pmoconfig::Config). + +Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoconfig_ext.md` + +Reste bien focalisé sur l'objectif principal. diff --git a/Blackboard/Todo/music_source.md b/Blackboard/Todo/music_source.md new file mode 100644 index 00000000..bbf8977b --- /dev/null +++ b/Blackboard/Todo/music_source.md @@ -0,0 +1,14 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +Partir des fichiers suivants: + +- pmoparadise/src/source.rs +- pmoqobuz/src/source.rs +- pmosource/README.md +- pmosource/ARCHITECTURE.md + +D'écrire dans un fichier d'architecture L'implémentation d'une nouvelle MusicSource. + +Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/music_source.md` + +Reste bien focalisé sur l'objectif principal.