diff --git a/Blackboard/ToThinkAbout/MusicBoxSource.md b/Blackboard/ToThinkAbout/MusicBoxSource.md new file mode 100644 index 00000000..b111c24a --- /dev/null +++ b/Blackboard/ToThinkAbout/MusicBoxSource.md @@ -0,0 +1,515 @@ +**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md** + +# MusicBoxSource : Bibliothèque musicale universelle + +Créer une **"boîte à musique"** personnelle : un catalogue unifié de morceaux provenant de n'importe quelle source (Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de tags et playlists intelligentes. + +--- + +## 🎯 Vision + +### Concept + +**MusicBoxSource** est une bibliothèque musicale curatoriale qui permet de : +- **Collecter** : Ajouter des morceaux depuis n'importe quelle source PMOMusic ou URL +- **Organiser** : Classifier avec une taxonomie de tags extensible +- **Requêter** : Créer des playlists statiques et smart playlists (requêtes dynamiques) +- **Exposer** : Servir via UPnP/DIDL-Lite avec navigation multi-axes + +### Différence avec `pmoplaylist` + +- **`pmoplaylist`** : Playlists FIFO **éphémères** pour sources live (Radio Paradise) +- **`pmomusicbox`** : Bibliothèque **persistante** cross-sources avec métadonnées enrichies + +--- + +## 🏛️ Architecture globale + +```mermaid +flowchart TB + subgraph Sources[Sources PMOMusic] + QOBUZ[pmoqobuz] + PARADISE[pmoparadise] + LOCAL[pmolocal - à créer] + URL[URLs directes] + end + + subgraph Import[Import Layer] + IMPORTER[MusicBox Importer] + JSPF[pmojspf - Parser playlists] + META[pmometadata - Extraction] + end + + subgraph Core[pmomusicbox Core] + DB[(SQLite Database)] + TAXONOMY[Taxonomie Tags] + QUERY[Smart Query Engine] + end + + subgraph Cache[Cache Layer] + AUDIO[pmoaudiocache] + COVERS[pmocovers] + end + + subgraph Export[Export UPnP] + SOURCE[MusicSource Trait] + DIDL[DIDL-Lite Generator] + BROWSE[Multi-Axis Browser] + end + + Sources --> IMPORTER + URL --> IMPORTER + JSPF --> IMPORTER + META --> IMPORTER + + IMPORTER --> DB + DB --> TAXONOMY + DB --> QUERY + + DB <--> AUDIO + DB <--> COVERS + + DB --> SOURCE + TAXONOMY --> BROWSE + QUERY --> BROWSE + SOURCE --> DIDL + BROWSE --> DIDL +``` + +--- + +## 🗄️ Modèle de données (SQLite) + +### Tables principales + +```mermaid +erDiagram + TAG_CATEGORIES ||--o{ TAGS : contient + TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent + TAGS ||--o{ ITEM_TAGS : associe + MUSIC_ITEMS ||--o{ ITEM_TAGS : a + MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans + PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient + + TAG_CATEGORIES { + text id PK "Ex: mood, genre" + text name "Nom affiché" + text parent_id FK "Hiérarchie" + text color "Hex color" + text icon "Emoji/icon" + int display_order + } + + TAGS { + text id PK "Ex: mood:energetic" + text category_id FK + text name "energetic, chill" + text description + text color "Override" + } + + MUSIC_ITEMS { + text id PK "UUID" + text source_type "qobuz, url, local" + text source_id "ID source" + text original_uri "URI source" + text cache_audio_pk FK "pmoaudiocache" + text cache_cover_pk FK "pmocovers" + text title + text artist + text album + int year + int rating "1-5 étoiles" + int play_count + } + + ITEM_TAGS { + text item_id PK,FK + text tag_id PK,FK + int added_at + text source "user, auto" + } + + PLAYLISTS { + text id PK + text name + bool is_smart + text smart_query "JSON" + } + + PLAYLIST_ITEMS { + text playlist_id PK,FK + text item_id FK + int position PK + } +``` + +### Tables d'association + +- **`item_tags`** : Liens items ↔ tags (N:M) +- **`playlist_items`** : Items dans playlists statiques (position, ordre) +- **`tag_synonyms`** : Synonymes pour recherche (ex: "jazz" → "swing") + +### Index & Recherche + +- **Indexes B-tree** : artist, album, genre, year, rating, play_count +- **FTS5 (Full-Text Search)** : title, artist, album, comment +- **Triggers** : Maintien des tables FTS en sync avec `music_items` + +--- + +## 🎨 Taxonomie par défaut + +Catégories préchargées à l'initialisation : + +| Catégorie | Description | Exemples de tags | +|-------------|----------------------------------|--------------------------------------------| +| **Mood** | État d'esprit, émotion | energetic, chill, melancholic, happy | +| **Genre** | Style musical | rock, jazz, classical, electronic, metal | +| **Era** | Période, décennie | 60s, 70s, 80s, 90s, contemporary | +| **Occasion**| Contexte d'écoute | workout, focus, party, driving, sleep | +| **Tempo** | Vitesse | slow, medium, fast | +| **Instrument** | Instrument dominant | piano, guitar, vocal, synthesizer | +| **Quality** | Qualité audio | lossless, high-res, remastered, live | +| **Origin** | Origine géographique | usa, uk, france, japan, latin, africa | + +**Extensibilité** : L'utilisateur peut créer ses propres catégories et tags. + +--- + +## 📦 Crates architecture + +### 1. **`pmojspf`** - Parser de playlists (utilitaire) + +**But** : Parser/écrire différents formats de playlists vers/depuis un format pivot JSPF (JSON). + +``` +pmojspf/ +├── model.rs # Structures JSPF (Playlist, Track, Meta) +├── reader/ +│ ├── jspf.rs # JSON natif +│ ├── xspf.rs # XML (via quick-xml ou crate xspf) +│ ├── m3u.rs # M3U/M3U8 (parsing ligne par ligne) +│ └── pls.rs # PLS (format INI-like) +└── writer.rs # Export JSPF +``` + +**Dépendances** : `serde`, `serde_json`, `quick-xml` (ou `xspf` crate) + +**Usage** : Réutilisé par `pmomusicbox` pour import/export + +--- + +### 2. **`pmomusicbox`** - Bibliothèque musicale core + +**Responsabilités** : +- Gestion base SQLite (CRUD items, tags, playlists) +- Import depuis sources PMO (Qobuz, Paradise, Local, URLs) +- Smart playlists (query builder + exécution SQL) +- Implémentation `MusicSource` trait (exposition UPnP) +- Intégration caches audio/covers + +``` +pmomusicbox/ +├── db/ +│ ├── schema.rs # DDL SQLite + migrations +│ ├── items.rs # CRUD music_items +│ ├── tags.rs # CRUD tags + taxonomie +│ ├── playlists.rs # CRUD playlists statiques +│ ├── smart.rs # Smart playlists +│ └── search.rs # Full-text search (FTS5) +│ +├── import/ +│ ├── url.rs # Import URL directe +│ ├── source.rs # Import depuis MusicSource +│ ├── local.rs # Import fichiers locaux (via pmometadata) +│ └── playlist.rs # Import JSPF/M3U8 (via pmojspf) +│ +├── export/ +│ └── playlist.rs # Export playlists (JSPF, M3U8) +│ +├── query/ +│ ├── builder.rs # SmartPlaylistQuery (DSL) +│ └── executor.rs # Génération + exécution SQL +│ +├── didl/ +│ └── generator.rs # Conversion items → DIDL-Lite +│ +├── source.rs # Impl MusicSource trait +├── taxonomy.rs # Taxonomie par défaut + CRUD +└── config_ext.rs # Extension pmoconfig +``` + +**Dépendances** : +- `pmosource`, `pmoaudiocache`, `pmocovers`, `pmodidl`, `pmometadata` +- `pmojspf` (import/export playlists) +- `rusqlite` (features: `bundled`, `serde_json`) +- `uuid`, `serde`, `tokio`, `async-trait` + +--- + +### 3. **`pmolocal`** - Source fichiers locaux (à créer) + +**But** : Scanner des répertoires locaux et exposer les fichiers audio via `MusicSource`. + +``` +pmolocal/ +├── scanner.rs # Scan récursif de répertoires +├── watcher.rs # Hot reload (notify) +├── source.rs # Impl MusicSource +└── config_ext.rs # Extension pmoconfig +``` + +**Workflow** : +1. `pmolocal` scanne `/home/user/Music` +2. `pmomusicbox` importe les items découverts +3. Tags automatiques basés sur métadonnées (genre, année) + +--- + +## 🔄 Flux d'import + +### Import depuis une source PMO (ex: Qobuz) + +```mermaid +sequenceDiagram + participant QS as Qobuz Source + participant MB as MusicBox Importer + participant DB as SQLite DB + participant AC as pmoaudiocache + participant CC as pmocovers + + QS->>MB: get_item(object_id) + MB->>QS: resolve_uri(object_id) + + Note over MB: 1. Extraire métadonnées DIDL-Lite
2. Générer UUID + + MB->>DB: INSERT INTO music_items + + opt Auto-cache activé + MB->>AC: Cache audio + MB->>CC: Cache cover + AC-->>DB: Retourner cache_audio_pk + CC-->>DB: Retourner cache_cover_pk + end + + MB-->>QS: item_id (UUID) +``` + +### Import URL directe + +```mermaid +flowchart LR + URL[URL simple] --> META["pmometadata
Extraction"] + META --> UUID[Générer UUID] + UUID --> DB[("music_items")] + DB --> CACHE{"Auto-cache?"} + CACHE -->|Oui| AC[pmoaudiocache] + CACHE -->|Non| END[Fin] + AC --> END +``` + +### Import playlist JSPF/M3U8 + +```mermaid +flowchart LR + FILE[Fichier playlist] --> JSPF["pmojspf
Parser"] + JSPF --> STRUCT[Structure JSPF] + STRUCT --> LOOP{"Pour chaque track"} + LOOP --> IMPORT[Import comme URL] + IMPORT --> DB[("music_items")] + DB --> PLAYLIST[Créer playlist statique] + PLAYLIST --> LINK[Lier tracks à playlist] +``` + +--- + +## 🔍 Smart Playlists (Query DSL) + +### Concept + +Les smart playlists sont des **requêtes sauvegardées** qui génèrent dynamiquement une liste de tracks. + +### Structure de requête (JSON) + +```json +{ + "include_all_tags": ["mood:energetic", "genre:rock"], + "exclude_tags": ["mood:melancholic"], + "year_min": 1980, + "year_max": 1989, + "min_rating": 4, + "lossless_only": true, + "order_by": "play_count", + "order": "desc", + "limit": 50 +} +``` + +### Traduction SQL + +```sql +SELECT * FROM music_items +WHERE id IN ( + SELECT item_id FROM item_tags WHERE tag_id IN ('mood:energetic', 'genre:rock') + GROUP BY item_id HAVING COUNT(DISTINCT tag_id) = 2 -- ALL tags +) +AND id NOT IN ( + SELECT item_id FROM item_tags WHERE tag_id = 'mood:melancholic' +) +AND year BETWEEN 1980 AND 1989 +AND rating >= 4 +AND codec IN ('flac', 'alac') +ORDER BY play_count DESC +LIMIT 50; +``` + +--- + +## 🎭 Exposition UPnP (MusicSource) + +### Structure de navigation + +```mermaid +graph TB + ROOT[musicbox/] --> ARTIST[by-artist/] + ROOT --> ALBUM[by-album/] + ROOT --> GENRE[by-genre/] + ROOT --> TAG[by-tag/] + ROOT --> PLAYLISTS[playlists/] + ROOT --> SMART[smart-playlists/] + ROOT --> FAV[favorites/] + ROOT --> RECENT[recent/] + + ARTIST --> PF[Pink Floyd/] + ARTIST --> Q[Queen/] + PF --> WALL[The Wall/] + PF --> WYWH[Wish You Were Here/] + WALL --> ITEM1[Another Brick... 🎵] + + TAG --> MOOD[mood/] + TAG --> OCC[occasion/] + TAG --> ERA[era/] + + MOOD --> ENRG[energetic/] + MOOD --> CHILL[chill/] + ENRG --> ITEMS1[items taggués 🎵] + + OCC --> WORK[workout/] + OCC --> FOCUS[focus/] + + ERA --> E80[80s/] + ERA --> E90[90s/] + + PLAYLISTS --> PL1[My Favorites/] + PLAYLISTS --> PL2[Summer 2024/] + + SMART --> SP1[80s Rock Workout/] + SMART --> SP2[Jazz Dinner/] + + style ITEM1 fill:#e1f5ff + style ITEMS1 fill:#e1f5ff +``` + +### Object IDs + +``` +musicbox:by-artist:{artist_name} +musicbox:by-album:{album_id} +musicbox:by-tag:{category}:{tag_name} +musicbox:playlist:{playlist_id} +musicbox:smart:{smart_playlist_id} +musicbox:item:{item_id} +``` + +--- + +## 🔌 Intégration avec l'écosystème PMOMusic + +### Avec pmoaudiocache + +- Import → Déclencher cache automatique (si `auto_cache: true`) +- `resolve_uri()` → Retourner URI cachée si disponible + +### Avec pmocovers + +- Import → Télécharger cover art +- Browse → Inclure `album_art` dans DIDL-Lite + +### Avec pmoserver (feature `server`) + +- API REST pour manipulation (CRUD items, tags, playlists) +- SSE pour notifications de changements +- Endpoints OpenAPI (utoipa) + +--- + +## 📝 Plan d'implémentation (Phases) + +### Phase 1 : Fondations +- Schéma SQLite complet +- Crate `pmojspf` (parser playlists) +- CRUD basique dans `pmomusicbox` (items, tags) +- Taxonomie par défaut +- Import URL simple +- Extension pmoconfig + +### Phase 2 : Import cross-sources +- Import depuis MusicSource (Qobuz, Paradise) +- Import playlists (JSPF/M3U8) +- Intégration caches (audio, covers) +- Crate `pmolocal` (fichiers locaux) + +### Phase 3 : Smart Playlists +- Query builder (DSL) +- Exécuteur SQL +- CRUD smart playlists +- Export JSPF + +### Phase 4 : MusicSource UPnP +- Implémentation trait `MusicSource` +- Génération DIDL-Lite +- Browse multi-axes (artist, album, tag) +- Recherche full-text (FTS5) + +### Phase 5 : Fonctionnalités avancées +- Statistiques d'écoute (play_count, last_played) +- Auto-tagging (genre depuis métadonnées) +- API REST (feature `server`) +- Recommandations (items similaires) + +--- + +## 🎯 Cas d'usage + +### Workflow typique + +1. **Découverte** : Écouter Radio Paradise, tomber sur un morceau génial +2. **Ajout** : `musicbox.import_from_source(¶dise, "track-123")` +3. **Organisation** : Ajouter tags `mood:chill`, `occasion:focus` +4. **Playlist** : Smart playlist "Focus Music" avec requête `mood:chill + occasion:focus` +5. **Écoute** : Naviguer dans UPnP → `musicbox/smart-playlists/Focus Music/` + +### Scénario : Bibliothèque mixte + +- Albums Qobuz haute résolution +- Playlists M3U8 importées depuis iTunes +- Fichiers FLAC locaux scannés +- URLs de SoundCloud +- Tracks Radio Paradise capturés + +**Tout unifié dans MusicBox, accessible via UPnP, organisé par tags.** + +--- + +## 📚 Références + +### Standards +- [JSPF Spec](https://www.xspf.org/jspf) +- [XSPF Spec](https://www.xspf.org/spec) +- [SQLite FTS5](https://www.sqlite.org/fts5.html) + +### Inspirations +- [Beets](https://beets.io/) - Music library manager +- [Navidrome](https://www.navidrome.org/) - Music server +- [MusicBrainz Picard](https://picard.musicbrainz.org/) - Tagger diff --git a/Blackboard_HTML/Architecture_music_source.html b/Blackboard_HTML/Architecture_music_source.html new file mode 100644 index 00000000..7fdf8df0 --- /dev/null +++ b/Blackboard_HTML/Architecture_music_source.html @@ -0,0 +1,934 @@ + + + + + +music_source + + + + + +
+ +

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
  2. +
  3. Structure d’une +MusicSource
  4. +
  5. Implémentation du +trait MusicSource
  6. +
  7. Patterns +d’implémentation
  8. +
  9. Intégration avec +l’écosystème PMOMusic
  10. +
  11. Checklist de mise en +œuvre
  12. +
  13. Exemples de référence
  14. +
+

Vue d’ensemble

+

Une MusicSource est une abstraction qui représente une +source de contenu musical dans PMOMusic. Elle peut être :

+ +

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<votre-source>/
+├── 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

+
[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 :

+
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.1 Container racine

+
async fn root_container(&self) -> Result<Container> {
+    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 :

+
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
+    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é :

+
<source-id>                              # Racine
+<source-id>:albums                       # Container albums
+<source-id>:album:<album_id>             # Album spécifique
+<source-id>:track:<track_id>             # Track spécifique
+<source-id>:playlist:<playlist_id>       # Playlist spécifique
+

Types de BrowseResult : - +Containers(Vec<Container>) : Liste de containers +(navigation) - Items(Vec<Item>) : Liste de tracks +(lecture) - Mixed { containers, items } : Les deux (album +avec tracks)

+

2.3 Résolution d’URI

+
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
+    // É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) :

+
use pmoplaylist::PlaylistManager;
+use std::sync::Arc;
+use tokio::sync::RwLock;
+
+#[derive(Clone)]
+pub struct RadioSource {
+    playlist_id: String,
+    update_counter: Arc<RwLock<u32>>,
+    last_change: Arc<RwLock<SystemTime>>,
+}
+
+#[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<Option<Item>> {
+        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<SystemTime> {
+        Some(*self.last_change.read().await)
+    }
+
+    async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
+        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) :

+
#[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<Option<Item>> {
+        Ok(None)  // Pas de suppression
+    }
+
+    async fn update_id(&self) -> u32 {
+        0  // Jamais de changement
+    }
+
+    async fn last_change(&self) -> Option<SystemTime> {
+        None  // Pas de suivi des changements
+    }
+
+    async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
+        // 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 :

+
#[derive(Clone)]
+pub struct RadioParadiseSource {
+    base_url: String,
+    update_counter: Arc<RwLock<u32>>,
+    last_change: Arc<RwLock<SystemTime>>,
+    callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
+    container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>,
+}
+
+impl RadioParadiseSource {
+    // Enregistrer des callbacks sur les playlists pour notifier les changements
+    pub fn attach_playlist_callbacks(self: &Arc<Self>) {
+        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<RwLock<u32>>

+

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 :

+
#[derive(Clone)]
+pub struct QobuzSource {
+    inner: Arc<QobuzSourceInner>,
+}
+
+struct QobuzSourceInner {
+    client: Arc<QobuzClient>,
+    cache_manager: SourceCacheManager,
+    base_url: String,
+    update_counter: tokio::sync::RwLock<u32>,
+    last_change: tokio::sync::RwLock<SystemTime>,
+}
+
+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<Vec<Item>> {
+        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 :

+
async fn adapt_playlist_items_to_source(
+    &self,
+    items: Vec<Item>,
+    parent_id: &str,
+) -> Result<Vec<Item>> {
+    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 :

+
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)

+
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é :

+
use pmoaudiocache::{LazyProvider, LazyProviderError};
+
+pub struct MyLazyProvider {
+    client: Arc<MyClient>,
+}
+
+#[async_trait]
+impl LazyProvider for MyLazyProvider {
+    async fn fetch_audio(&self, lazy_pk: &str) -> Result<Vec<u8>, 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 :

+
use pmodidl::{Container, Item, Resource};
+
+// Container
+pub trait ToDIDLContainer {
+    fn to_didl_container(&self, parent_id: &str) -> Result<Container>;
+}
+
+impl ToDIDLContainer for MyAlbum {
+    fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
+        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<Item>;
+}
+
+impl ToDIDLItem for MyTrack {
+    fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
+        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

+
    +
  • +
  • +
  • +
  • +
  • +
+

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

+
    +
  • +
  • +
  • +
  • +
  • +
+

Exemples de référence

+

Radio Paradise (source +dynamique FIFO)

+

Fichier : pmoparadise/src/source.rs

+

Points d’intérêt : - Structure avec +Arc<RwLock<>> 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. +
  3. Pagination : Limitez le nombre d’items retournés +(max 100)
  4. +
  5. Lazy loading : Ne chargez que ce qui est +demandé
  6. +
  7. Rate limiting : Respectez les limites API de la +source
  8. +
  9. Arc<> : Partagez les données coûteuses
  10. +
+

Compatibilité UPnP

+
    +
  1. Genre obligatoire : Certains clients (gupnp-av-cp) +requièrent <upnp:genre>
  2. +
  3. URLs absolues : Toujours retourner des URLs +complètes (pas de chemins relatifs)
  4. +
  5. Protocol Info : Utilisez +http-get:*:audio/flac:* pour FLAC
  6. +
  7. Duration : Format H:MM:SS (ex: +0:03:45)
  8. +
  9. childCount : Optionnel mais recommandé pour +l’UI
  10. +
+

Gestion d’erreurs

+
    +
  1. ObjectNotFound : ID invalide
  2. +
  3. BrowseError : Erreur générique de navigation
  4. +
  5. UriResolutionError : Impossible de résoudre +l’URI
  6. +
  7. PlaylistError : Erreur d’interaction avec +pmoplaylist
  8. +
  9. CacheError : Erreur de cache
  10. +
+

Thread Safety

+
    +
  1. Arc<RwLock<>> : Pour l’état mutable +partagé
  2. +
  3. tokio::sync::RwLock : Pour l’async
  4. +
  5. Éviter Rc<> : Pas thread-safe
  6. +
  7. Clone : Implémentez Clone pour +Arc<>
  8. +
+

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. +
  3. Implémenter la navigation : browse() +pour tous les niveaux
  4. +
  5. Résoudre les URIs : Cache local d’abord, puis +original
  6. +
  7. Gérer le cache : SourceCacheManager + +LazyProvider
  8. +
  9. Adapter les IDs : Playlist → Schema de la +source
  10. +
  11. Notifier les changements : update_id + +callbacks
  12. +
+

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_HTML/Architecture_pmoconfig_ext.html b/Blackboard_HTML/Architecture_pmoconfig_ext.html new file mode 100644 index 00000000..407d7b60 --- /dev/null +++ b/Blackboard_HTML/Architecture_pmoconfig_ext.html @@ -0,0 +1,1023 @@ + + + + + +pmoconfig_ext + + + + + +
+ +

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

+
//! 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<Type>;
+    
+    /// Documentation de la méthode setter
+    fn set_xxx_value(&self, value: Type) -> Result<()>;
+}
+
+impl XxxConfigExt for Config {
+    fn get_xxx_value(&self) -> Result<Type> {
+        // 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

+
// 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

+
// Chemins sous "accounts"
+&["accounts", "service", "username"]
+&["accounts", "service", "password"]
+&["accounts", "service", "auth_token"]
+

Configuration sources

+
// Chemins sous "sources"
+&["sources", "source_name", "enabled"]
+&["sources", "source_name", "default_channel"]
+

3. Patterns de getters

+

Getter simple avec valeur +par défaut

+
fn get_xxx_value(&self) -> Result<Type> {
+    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 :

+
fn get_xxx_enabled(&self) -> Result<bool> {
+    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) +:

+
fn get_xxx_optional(&self) -> Result<Option<String>> {
+    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
+
fn get_xxx_password(&self) -> Result<String> {
+    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
+
fn get_xxx_enum_value(&self) -> Result<EnumType> {
+    match self.get_value(&["path", "to", "value"]) {
+        Ok(Value::String(s)) => {
+            match s.parse::<EnumType>() {
+                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

+
fn set_xxx_value(&self, value: Type) -> Result<()> {
+    self.set_value(
+        &["path", "to", "value"],
+        Value::Type(value.into())
+    )
+}
+

Setter avec transformation

+
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)

+
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

+
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

+
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 +:

+
fn create_xxx_cache(&self) -> Result<Arc<Cache>> {
+    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<XxxClient> {
+    let (username, password) = self.get_xxx_credentials()?;
+    XxxClient::builder()
+        .credentials(username, password)
+        .cache_dir(self.get_xxx_cache_dir()?)
+        .build()
+}
+

Getter combiné

+
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 :

+
fn get_xxx_cache_dir(&self) -> Result<String> {
+    // 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

+
// Lecture de valeur générique
+pub fn get_value(&self, path: &[&str]) -> Result<Value>
+
+// É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<String>
+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<String>
+    pub fn decrypt_password(encrypted: &str) -> Result<String>
+    pub fn get_password(value: &str) -> Result<String>  // 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 :

+
use pmocache::CacheConfigExt;
+
+impl AudioCacheConfigExt for Config {
+    fn get_audiocache_dir(&self) -> Result<String> {
+        self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR)
+    }
+    
+    fn create_audio_cache(&self) -> Result<Arc<Cache>> {
+        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 :

+
pub trait QobuzConfigExt {
+    // Credentials de base
+    fn get_qobuz_username(&self) -> Result<String>;
+    fn get_qobuz_password(&self) -> Result<String>;  // Auto-decrypt
+    fn get_qobuz_credentials(&self) -> Result<(String, String)>;
+    
+    // Tokens d'authentification
+    fn get_qobuz_auth_token(&self) -> Result<Option<String>>;
+    fn get_qobuz_user_id(&self) -> Result<Option<String>>;
+    fn get_qobuz_token_expires_at(&self) -> Result<Option<u64>>;
+    
+    // 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 :

+
pub trait QobuzConfigExt {
+    fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>>;
+    fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>;
+    
+    fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>>;
+    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 :

+
pub trait RadioParadiseConfigExt {
+    // Juste enable/disable
+    fn get_paradise_enabled(&self) -> Result<bool>;
+    fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
+    
+    // Configuration minimale avec valeurs intelligentes par défaut
+    fn get_paradise_default_channel(&self) -> Result<u8>;
+    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 :

+
pub trait UpnpConfigExt {
+    fn get_upnp_manufacturer(&self) -> Result<String>;
+    fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()>;
+    
+    fn get_upnp_udn_prefix(&self) -> Result<String>;
+    fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()>;
+    
+    fn get_upnp_model_name_prefix(&self) -> Result<String>;
+    fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()>;
+    
+    fn get_upnp_friendly_name_prefix(&self) -> Result<String>;
+    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

+
// ✅ BON : Préfixer avec le nom du service/composant
+fn get_qobuz_username(&self) -> Result<String>
+fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result<String>
+fn get_paradise_enabled(&self) -> Result<bool>
+
+// ❌ MAUVAIS : Nom trop générique
+fn get_username(&self) -> Result<String>
+fn get_directory(&self) -> Result<String>
+fn is_enabled(&self) -> bool
+

2. Gestion des erreurs

+
// ✅ BON : Retourner Result pour les valeurs obligatoires
+fn get_xxx_username(&self) -> Result<String> {
+    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<Option<String>>
+
+// ✅ 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

+
// ✅ 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<usize>
+
+// ❌ MAUVAIS : Magic numbers
+fn get_cache_size(&self) -> Result<usize> {
+    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 :

+
/// 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<Type>;
+

5. Organisation du code

+

Structure du fichier +config_ext.rs

+
//! 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. +
  3. Getters/setters combinés
  4. +
  5. Helpers de validation
  6. +
  7. Factory methods
  8. +
  9. Méthodes de nettoyage
  10. +
+
pub trait QobuzConfigExt {
+    // 1. Getters/setters simples
+    fn get_qobuz_username(&self) -> Result<String>;
+    fn set_qobuz_username(&self, username: &str) -> Result<()>;
+    fn get_qobuz_password(&self) -> Result<String>;
+    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<QobuzClient>;
+    
+    // 5. Méthodes de nettoyage
+    fn clear_qobuz_auth_info(&self) -> Result<()>;
+}
+

6. Types de retour

+
// ✅ BON : Result<T> pour les opérations qui peuvent échouer
+fn get_xxx_username(&self) -> Result<String>
+
+// ✅ BON : Result<Option<T>> pour les valeurs optionnelles
+fn get_xxx_token(&self) -> Result<Option<String>>
+
+// ✅ 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<Result<T>> (ordre inversé)
+fn get_xxx_value(&self) -> Option<Result<String>>
+

7. Conversion de types

+
// ✅ BON : Gérer plusieurs types d'entrée
+fn get_xxx_value(&self) -> Result<u64> {
+    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::<u64>()
+            .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)

+
//! 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<String>;
+    fn set_covers_dir(&self, directory: String) -> Result<()>;
+    fn get_covers_size(&self) -> Result<usize>;
+    fn set_covers_size(&self, size: usize) -> Result<()>;
+    fn create_cover_cache(&self) -> Result<Arc<crate::Cache>>;
+}
+
+impl CoverCacheConfigExt for Config {
+    fn get_covers_dir(&self) -> Result<String> {
+        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<usize> {
+        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<Arc<crate::Cache>> {
+        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é)

+
//! 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<String>;
+    fn set_qobuz_username(&self, username: &str) -> Result<()>;
+    fn get_qobuz_password(&self) -> Result<String>;
+    fn set_qobuz_password(&self, password: &str) -> Result<()>;
+    fn get_qobuz_credentials(&self) -> Result<(String, String)>;
+    
+    // Authentification
+    fn get_qobuz_auth_token(&self) -> Result<Option<String>>;
+    fn get_qobuz_user_id(&self) -> Result<Option<String>>;
+    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<String> {
+        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<String> {
+        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<Option<String>> {
+        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<Option<String>> {
+        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)

+
//! 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<bool>;
+    fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
+    fn get_paradise_default_channel(&self) -> Result<u8>;
+    fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
+}
+
+impl RadioParadiseConfigExt for Config {
+    fn get_paradise_enabled(&self) -> Result<bool> {
+        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<u8> {
+        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

+
[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

+
// 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

+
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

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+

Philosophie du pattern

+

Avantages

+
    +
  1. Séparation des préoccupations : Chaque crate gère +sa propre configuration
  2. +
  3. Type safety : Les erreurs de type sont détectées à +la compilation
  4. +
  5. Extensibilité : Facile d’ajouter de nouveaux crates +sans modifier pmoconfig
  6. +
  7. Cohérence : Pattern uniforme dans tout le +projet
  8. +
  9. Documentation : Interface self-documenting avec +exemples
  10. +
+

Principes directeurs

+
    +
  1. Minimalisme : Ne configurer que ce qui doit +vraiment l’être
  2. +
  3. Defaults intelligents : Valeurs par défaut sensées +et documentées
  4. +
  5. Auto-persistence : Les valeurs importantes sont +persistées automatiquement
  6. +
  7. User-friendly : Noms conviviaux dans le YAML +(strings au lieu de nombres)
  8. +
  9. Fail-safe : Gestion des erreurs gracieuse avec +fallback sur défauts
  10. +
  11. Zero surprise : Comportement prévisible et +cohérent
  12. +
+

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

+
fn get_xxx_password(&self) -> Result<String> {
+    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_HTML/Architecture_pmoserver_ext.html b/Blackboard_HTML/Architecture_pmoserver_ext.html new file mode 100644 index 00000000..b1dfe099 --- /dev/null +++ b/Blackboard_HTML/Architecture_pmoserver_ext.html @@ -0,0 +1,929 @@ + + + + + +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 peut ajouter ses +propres routes HTTP sans que pmoserver ne dépende de ces +crates.

+

Principe : Définir un trait d’extension que +pmoserver::Server implémente via une feature Cargo.

+

Anatomie d’une extension

+

1. Structure du module

+

Créer un module pmoserver_ext.rs dans la crate :

+
// pmoXXX/src/pmoserver_ext.rs
+
+#[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 :

+
// 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 :

+
[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_*

+
/// 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<Arc<Resource>>;
+}
+

3. Implémenter le trait

+

Implémenter le trait pour pmoserver::Server :

+
#[cfg(feature = "pmoserver")]
+#[async_trait]
+impl XXXExt for pmoserver::Server {
+    async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>> {
+        // 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)
+    }
+}
+

4. État partagé (State)

+

Créer une structure d’état cloneable pour les handlers :

+
/// État partagé pour les handlers XXX
+#[derive(Clone)]
+pub struct XxxState {
+    resource: Arc<Resource>,
+}
+
+impl XxxState {
+    pub fn new(resource: Arc<Resource>) -> Self {
+        Self { resource }
+    }
+}
+

5. Créer le router

+

Définir les routes et handlers :

+
/// 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<XxxState>
+) -> Json<Vec<ItemSummary>> {
+    let items = state.resource.list_items();
+    Json(items)
+}
+
+async fn get_item(
+    State(state): State<XxxState>,
+    Path(id): Path<String>,
+) -> Result<Json<Item>, 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éthodeUsage
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::<W>(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 :

+
[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)] :

+
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<String>,
+    
+    /// 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<ItemInfo>,
+}
+
+/// 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<String>,
+}
+
+/// 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<T> pour les +champs optionnels

+

2. Annoter les handlers

+

Utiliser #[utoipa::path(...)] pour documenter chaque +endpoint :

+
/// GET /items - Liste tous les items
+#[utoipa::path(
+    get,
+    path = "/items",
+    params(
+        ("limit" = Option<u32>, Query, description = "Nombre max d'items à retourner"),
+        ("offset" = Option<u32>, 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 list_items(
+    State(state): State<XxxState>,
+    Query(params): Query<ListParams>,
+) -> Result<Json<ItemList>, (StatusCode, Json<ErrorResponse>)> {
+    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<XxxState>,
+    Path(id): Path<String>,
+) -> Result<Json<ItemInfo>, (StatusCode, Json<ErrorResponse>)> {
+    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<XxxState>,
+    Json(req): Json<CreateItemRequest>,
+) -> Result<(StatusCode, Json<ItemInfo>), (StatusCode, Json<ErrorResponse>)> {
+    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<XxxState>,
+    Path(id): Path<String>,
+) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
+    state.resource.delete_item(&id)
+        .map_err(|e| (
+            StatusCode::INTERNAL_SERVER_ERROR,
+            Json(ErrorResponse { error: e.to_string() })
+        ))?;
+    
+    Ok(StatusCode::NO_CONTENT)
+}
+

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

+

3. Créer la structure OpenAPI

+

Définir une structure avec #[derive(OpenApi)] :

+
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 :

+
#[async_trait]
+impl XxxExt for pmoserver::Server {
+    async fn init_xxx(&mut self) -> anyhow::Result<Arc<Resource>> {
+        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

+
/// Information sur un morceau
+#[derive(Debug, Clone, Serialize, ToSchema)]
+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<u32>,
+    /// 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<String>,
+}
+
+/// 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,
+    /// 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<u8>, 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<RadioParadiseState>,
+    Path(event_id): Path<u64>,
+    Query(params): Query<ParadiseQuery>,
+) -> Result<Json<StreamUrlResponse>, StatusCode> {
+    let client = state.client_for_params(&params).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)]
+#[openapi(
+    info(
+        title = "Radio Paradise API",
+        version = "1.0.0",
+        description = "API REST pour accéder aux métadonnées Radio Paradise"
+    ),
+    paths(
+        get_now_playing,
+        get_current_block,
+        get_stream_url,
+    ),
+    components(schemas(
+        SongInfo,
+        StreamUrlResponse,
+    )),
+    tags(
+        (name = "Radio Paradise", description = "Endpoints Radio Paradise")
+    )
+)]
+pub struct RadioParadiseApiDoc;
+

Résultat : Interface Swagger

+

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)

+
#[async_trait]
+impl RadioParadiseExt for pmoserver::Server {
+    async fn init_radioparadise(&mut self) -> anyhow::Result<State> {
+        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 2 : +Extension avec cache et fichiers

+

Exemple : pmoaudiocache +(pmoaudiocache/src/lib.rs:225-260)

+
#[async_trait]
+impl AudioCacheExt for pmoserver::Server {
+    async fn init_audio_cache(
+        &mut self,
+        cache_dir: &str,
+        limit: usize,
+    ) -> anyhow::Result<Arc<Cache>> {
+        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)

+
#[async_trait]
+impl ParadiseStreamingExt for pmoserver::Server {
+    async fn init_paradise_streaming(&mut self) -> Result<Arc<Manager>> {
+        // 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 slug = descriptor.slug;
+            
+            // Route streaming FLAC
+            let path = format!("/stream/{}/flac", slug);
+            self.add_handler_with_state(
+                &path,
+                move |State(s): State<Arc<StreamingState>>| 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<Arc<StreamingState>>| async move {
+                    stream_ogg(s.manager.clone(), descriptor.id).await
+                },
+                state.clone(),
+            ).await;
+        }
+        
+        Ok(manager)
+    }
+}
+

Gestion des opérations +longues

+

Utiliser +spawn_blocking pour le code synchrone

+

Pour éviter de bloquer le runtime Tokio avec du code synchrone :

+
async fn list_renderers(
+    State(state): State<ControlPointState>
+) -> Json<Vec<Summary>> {
+    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| Summary::from(&r))
+            .collect()
+    })
+    .await
+    .unwrap_or_default();
+    
+    Json(summaries)
+}
+

Ajouter des +timeouts pour les opérations réseau

+
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
+
+async fn play_renderer(
+    State(state): State<ControlPointState>,
+    Path(id): Path<String>,
+) -> Result<Json<Response>, (StatusCode, Json<Error>)> {
+    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(|_| (
+            StatusCode::GATEWAY_TIMEOUT,
+            Json(Error::timeout())
+        ))?
+        .map_err(|e| (
+            StatusCode::INTERNAL_SERVER_ERROR,
+            Json(Error::internal(e))
+        ))??;
+    
+    Ok(Json(Response::success()))
+}
+

Utiliser +spawn pour les tâches en arrière-plan

+

Pour les opérations qui ne nécessitent pas d’attendre le résultat +:

+
async fn trigger_action(
+    State(state): State<XxxState>,
+    Json(req): Json<Request>,
+) -> Json<Response> {
+    // Valider la requête
+    state.validate(&req)?;
+    
+    // Lancer l'action en arrière-plan
+    let state_clone = state.clone();
+    tokio::task::spawn(async move {
+        match state_clone.perform_action(req).await {
+            Ok(_) => debug!("Action completed"),
+            Err(e) => warn!("Action failed: {}", e),
+        }
+    });
+    
+    // Retourner immédiatement
+    Json(Response::accepted())
+}
+

Checklist d’implémentation

+

Configuration de base

+
    +
  • +
  • +
  • +
+

Définition du trait

+
    +
  • +
  • +
  • +
+

Documentation OpenAPI

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
+

Handlers et routes

+
    +
  • +
  • +
  • +
  • +
+

Performance et robustesse

+
    +
  • +
  • +
  • +
+

Exemple complet minimal

+
// pmoexample/src/pmoserver_ext.rs
+
+#[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;
+
+#[cfg(feature = "pmoserver")]
+#[derive(Clone)]
+pub struct ExampleState {
+    resource: Arc<ExampleResource>,
+}
+
+#[cfg(feature = "pmoserver")]
+#[async_trait]
+pub trait ExampleExt {
+    async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>>;
+}
+
+#[cfg(feature = "pmoserver")]
+#[async_trait]
+impl ExampleExt for pmoserver::Server {
+    async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>> {
+        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)
+    }
+}
+
+#[cfg(feature = "pmoserver")]
+async fn list_items(State(state): State<ExampleState>) -> Json<Vec<String>> {
+    let items = state.resource.list();
+    Json(items)
+}
+

Références

+

Exemples dans le codebase

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CrateFichierPattern
pmoparadisesrc/pmoserver_ext.rs:367-392Extension simple avec OpenAPI
pmoaudiocachesrc/lib.rs:225-260Extension avec cache et fichiers
pmomediaserversrc/paradise_streaming.rs:70-148Extension avec routes dynamiques
pmocontrolsrc/pmoserver_ext.rs:68-92Handlers avec spawn_blocking
pmoappsrc/lib.rs:145-165Extension SPA avec RustEmbed
+

Dépendances communes

+
    +
  • 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
  • +
+
+ + diff --git a/Blackboard_HTML/Done_Pinnable_cache_item.html b/Blackboard_HTML/Done_Pinnable_cache_item.html new file mode 100644 index 00000000..80736158 --- /dev/null +++ b/Blackboard_HTML/Done_Pinnable_cache_item.html @@ -0,0 +1,1034 @@ + + + + + +Pinnable_cache_item + + + + + +
+ +

Rapport +Final : Items Épinglables et TTL dans PMOcache

+

Objectif de la tâche

+

Étendre le système de cache PMOcache pour permettre un contrôle plus +fin des règles de suppression des items. L’objectif était double :

+
    +
  1. Phase 1 : Implémenter un système d’items +épinglables (pinned) protégés de l’éviction LRU, avec support du TTL +(Time To Live) pour l’expiration automatique
  2. +
  3. Phase 2 : Exposer ces fonctionnalités via une API +REST complète avec documentation OpenAPI
  4. +
+

Contexte

+

La crate PMOcache implémente un système de cache avec : - Capacité +maximale configurable - Politique d’éviction LRU (Least Recently Used) - +TTL optionnel pour les items

+

La nouvelle fonctionnalité permet de : - Épingler +des items critiques pour les rendre permanents - +Exclure les items épinglés du comptage de la limite du +cache - Définir un TTL pour supprimer automatiquement +les items temporaires - Garantir l’incompatibilité +entre pinning et TTL (règle métier)

+

Architecture de la solution

+

1. Modifications de la base +de données

+

Schéma SQL étendu

+
CREATE TABLE IF NOT EXISTS asset (
+    pk TEXT PRIMARY KEY,
+    collection TEXT,
+    id TEXT,
+    hits INTEGER DEFAULT 0,
+    last_used TEXT,
+    lazy_pk TEXT,
+    pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
+    ttl_expires_at TEXT
+)
+

Deux nouvelles colonnes : - pinned : +Booléen (0/1) indiquant si l’item est protégé - +ttl_expires_at : Date RFC3339 d’expiration +(optionnel)

+

Structure CacheEntry +enrichie

+
pub struct CacheEntry {
+    pub pk: String,
+    pub lazy_pk: Option<String>,
+    pub id: Option<String>,
+    pub collection: Option<String>,
+    pub hits: i32,
+    pub last_used: Option<String>,
+    pub pinned: bool,                    // Nouveau
+    pub ttl_expires_at: Option<String>,  // Nouveau
+    pub metadata: Option<Value>,
+}
+

2. API de base de données +(db.rs)

+

Nouvelles méthodes +implémentées

+
Gestion du comptage
+
    +
  • count_unpinned() : Compte uniquement +les items non épinglés +
      +
    • Les items épinglés sont exclus de la limite du cache
    • +
  • +
+
Gestion du pinning
+
    +
  • pin(pk) : Épingle un item

    +
      +
    • Vérifie qu’aucun TTL n’est défini (règle métier)
    • +
    • Retourne erreur si TTL présent
    • +
  • +
  • unpin(pk) : Désépingle un +item

  • +
  • is_pinned(pk) : Vérifie le statut +de pinning

  • +
+
Gestion du TTL
+
    +
  • set_ttl(pk, expires_at) : Définit +la date d’expiration

    +
      +
    • Vérifie que l’item n’est pas épinglé (règle métier)
    • +
    • Retourne erreur si épinglé
    • +
  • +
  • clear_ttl(pk) : Supprime le +TTL

  • +
  • get_expired() : Récupère tous les +items expirés

  • +
+
Modification de +get_oldest()
+

Exclusion automatique des items épinglés :

+
SELECT ... FROM asset
+WHERE pinned = 0
+ORDER BY last_used ASC, hits ASC
+LIMIT ?1
+

3. Logique du cache (cache.rs)

+

Méthodes publiques exposées

+
pub async fn pin(&self, pk: &str) -> Result<()>
+pub async fn unpin(&self, pk: &str) -> Result<()>
+pub async fn is_pinned(&self, pk: &str) -> Result<bool>
+pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
+pub async fn clear_ttl(&self, pk: &str) -> Result<()>
+

Politique d’éviction +améliorée

+

La méthode enforce_limit() a été complètement repensée +:

+
pub async fn enforce_limit(&self) -> Result<usize> {
+    // 1. Supprimer d'abord les items expirés (TTL dépassé)
+    let expired_entries = self.db.get_expired()?;
+    for entry in expired_entries {
+        // Suppression fichiers + DB
+    }
+
+    // 2. Compter UNIQUEMENT les items non épinglés
+    let count = self.db.count_unpinned()?;
+
+    // 3. Si limite dépassée, supprimer les plus vieux (non épinglés)
+    if count > self.limit {
+        let to_remove = count - self.limit;
+        let old_entries = self.db.get_oldest(to_remove)?;
+        // Suppression...
+    }
+}
+

Ordre de priorité : 1. Items expirés (TTL) → +suppression immédiate 2. Items non épinglés les plus vieux (LRU) → +suppression si limite dépassée 3. Items épinglés → jamais +supprimés automatiquement

+

4. API REST (api.rs)

+

Nouvelles structures de +données

+
#[derive(Serialize, Deserialize, ToSchema)]
+pub struct PinStatus {
+    pub pk: String,
+    pub pinned: bool,
+    pub ttl_expires_at: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, ToSchema)]
+pub struct PinResponse {
+    pub pk: String,
+    pub message: String,
+}
+
+#[derive(Serialize, Deserialize, ToSchema)]
+pub struct SetTtlRequest {
+    pub expires_at: String,  // RFC3339
+}
+

Handlers HTTP implémentés

+
get_pin_status(pk) - +GET /{pk}/pin
+

Récupère le statut actuel de pinning et TTL d’un item.

+

Réponse 200 OK :

+
{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "pinned": false,
+  "ttl_expires_at": null
+}
+
pin_item(pk) - POST +/{pk}/pin
+

Épingle un item pour le protéger de l’éviction.

+

Réponse 200 OK :

+
{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
+}
+

Réponse 409 CONFLICT (si TTL défini) :

+
{
+  "error": "CONFLICT",
+  "message": "Cannot pin an item with TTL set. Clear TTL first."
+}
+
unpin_item(pk) - +DELETE /{pk}/pin
+

Désépingle un item.

+
set_item_ttl(pk, request) +- POST /{pk}/ttl
+

Définit le TTL d’un item.

+

Requête :

+
{
+  "expires_at": "2025-01-20T10:30:00Z"
+}
+

Réponse 409 CONFLICT (si épinglé) :

+
{
+  "error": "CONFLICT",
+  "message": "Cannot set TTL on a pinned item. Unpin first."
+}
+

Réponse 400 BAD REQUEST (format invalide) :

+
{
+  "error": "INVALID_DATE",
+  "message": "Invalid RFC3339 date format"
+}
+
clear_item_ttl(pk) +- DELETE /{pk}/ttl
+

Supprime le TTL d’un item.

+

5. Routes HTTP +(pmoserver_ext.rs)

+

Routes ajoutées au router API :

+
Router::new()
+    // ... routes existantes ...
+    .route(
+        "/{pk}/pin",
+        get(api::get_pin_status::<C>)
+            .post(api::pin_item::<C>)
+            .delete(api::unpin_item::<C>),
+    )
+    .route(
+        "/{pk}/ttl",
+        post(api::set_item_ttl::<C>)
+            .delete(api::clear_item_ttl::<C>),
+    )
+

URLs complètes (exemple pour cache audio) : - +GET /api/audio/{pk}/pin - +POST /api/audio/{pk}/pin - +DELETE /api/audio/{pk}/pin - +POST /api/audio/{pk}/ttl - +DELETE /api/audio/{pk}/ttl

+

6. Documentation OpenAPI +(openapi.rs)

+

La macro create_cache_openapi! a été enrichie pour +inclure automatiquement :

+
#[openapi(
+    paths(
+        // ... paths existants ...
+        $crate::api::get_pin_status::<Self>,
+        $crate::api::pin_item::<Self>,
+        $crate::api::unpin_item::<Self>,
+        $crate::api::set_item_ttl::<Self>,
+        $crate::api::clear_item_ttl::<Self>,
+    ),
+    components(
+        schemas(
+            // ... schemas existants ...
+            $crate::api::PinStatus,
+            $crate::api::PinResponse,
+            $crate::api::SetTtlRequest,
+        )
+    ),
+)]
+

Accès Swagger UI : +/swagger-ui/{cache_name}

+

Règles métier implémentées

+

1. Incompatibilité stricte : +Pinned ↔︎ TTL

+

Un item ne peut jamais être à la fois épinglé ET +avoir un TTL :

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
État actuelActionRésultat
Aucun TTLpin()✅ Succès
TTL définipin()❌ Erreur 409
Non épingléset_ttl()✅ Succès
Épingléset_ttl()❌ Erreur 409
+

Rationale : - Épinglé = permanent, +ne doit jamais être supprimé automatiquement - TTL = +temporaire, sera supprimé à expiration - Ces deux concepts sont +sémantiquement contradictoires

+

2. Exclusion du comptage

+

Les items épinglés ne comptent pas dans la limite du +cache :

+
// Cache avec limite de 100 items
+let unpinned_count = cache.db.count_unpinned()?;  // 100
+let total_count = cache.db.count()?;              // 150
+
+// Le cache peut contenir :
+// - 100 items non épinglés (limite respectée)
+// - 50 items épinglés (hors limite)
+

3. Protection absolue +contre l’éviction

+

Les items épinglés sont jamais retournés par +get_oldest() :

+
-- Requête LRU exclut automatiquement les épinglés
+SELECT ... FROM asset
+WHERE pinned = 0  -- ← Filtre explicite
+ORDER BY last_used ASC
+

Tests et validation

+

Suite de tests dédiée +(test_pinnable.rs)

+

9 tests couvrant tous les cas d’usage :

+
    +
  1. test_pin_unpin : +Épinglage/désépinglage basique
  2. +
  3. test_pinned_excluded_from_lru : Items +épinglés protégés de l’éviction
  4. +
  5. test_pinned_count_separately : +Comptage séparé des items
  6. +
  7. test_cannot_pin_with_ttl : Règle +métier TTL → pas de pin
  8. +
  9. test_cannot_set_ttl_when_pinned : +Règle métier pin → pas de TTL
  10. +
  11. test_ttl_expiration : Suppression +automatique des items expirés
  12. +
  13. test_clear_ttl : Suppression du +TTL
  14. +
  15. test_get_expired : Récupération des +items expirés
  16. +
  17. test_cache_entry_fields : Vérification +des champs dans les entrées
  18. +
+

Résultat : ✅ 9/9 tests passent

+

Tests de non-régression

+

Tous les tests existants de test_cache.rs passent sans +modification : - Test de création de cache - Test d’ajout de fichiers - +Test de déduplication - Test de collections - Test de suppression - Test +d’éviction LRU - Test de purge - Test de consolidation

+

Résultat : ✅ Aucune régression détectée

+

Compilation

+
cargo build -p pmocache
+

Résultat : ✅ Compilation sans erreur ni warning

+

Compatibilité et migration

+

Rétrocompatibilité de +la base de données

+

Aucune migration manuelle requise. Les colonnes ont +des valeurs par défaut :

+
pinned INTEGER DEFAULT 0         -- Non épinglé par défaut
+ttl_expires_at TEXT              -- NULL par défaut
+

Les bases existantes sont automatiquement compatibles : - Tous les +items existants sont non épinglés - Aucun TTL défini par défaut - Le +comportement LRU standard reste identique

+

Rétrocompatibilité du code

+

Toutes les méthodes existantes continuent de fonctionner : - +add_from_url(), add_from_file(), +get(), etc. - Pas de changement de signature - Comportement +LRU identique pour les items non épinglés

+

Documentation API REST

+

Tableau récapitulatif des +endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MéthodeRouteDescriptionCodes retour
GET/{pk}/pinRécupère le statut de pinning200, 404
POST/{pk}/pinÉpingle un item200, 404, 409
DELETE/{pk}/pinDésépingle un item200, 404
POST/{pk}/ttlDéfinit le TTL200, 400, 404, 409
DELETE/{pk}/ttlSupprime le TTL200, 404
+

Codes de statut HTTP

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeSignificationQuand ?
200SuccèsOpération réussie
400Requête invalideFormat de date TTL incorrect
404Non trouvéPK inexistant dans le cache
409ConflitViolation de règle métier (pin+TTL)
500Erreur serveurErreur de base de données
+

Structure des erreurs

+

Format cohérent pour toutes les erreurs :

+
{
+  "error": "CODE_ERREUR",
+  "message": "Description lisible pour l'utilisateur"
+}
+

Exemples : - "CONFLICT" : Violation de règle métier - +"NOT_FOUND" : Item inexistant - "INVALID_DATE" +: Format de date RFC3339 invalide - "PIN_ERROR" / +"TTL_ERROR" : Erreur technique

+

Exemples d’utilisation

+

Utilisation programmatique +(Rust)

+
use pmocache::{Cache, CacheConfig};
+use chrono::{Duration, Utc};
+
+// Créer un cache
+let cache = Cache::<MyConfig>::new("./cache", 100)?;
+
+// Ajouter un fichier
+let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
+
+// ═══════════════════════════════════════
+// Scénario 1 : Item permanent (épinglé)
+// ═══════════════════════════════════════
+cache.pin(&pk).await?;
+
+// Vérifier le statut
+assert!(cache.is_pinned(&pk).await?);
+
+// L'item ne sera JAMAIS supprimé automatiquement
+// même si le cache est plein
+
+// ═══════════════════════════════════════
+// Scénario 2 : Item temporaire (TTL)
+// ═══════════════════════════════════════
+let pk2 = cache.add_from_url("https://example.com/temp.dat", None).await?;
+
+// Définir une expiration dans 24h
+let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
+cache.set_ttl(&pk2, &expires_at).await?;
+
+// L'item sera automatiquement supprimé après 24h
+// lors du prochain appel à enforce_limit()
+
+// ═══════════════════════════════════════
+// Scénario 3 : Conversion épinglé → TTL
+// ═══════════════════════════════════════
+cache.unpin(&pk).await?;  // Désépingler d'abord
+cache.set_ttl(&pk, &expires_at).await?;  // OK maintenant
+

Utilisation via API REST

+

Workflow complet +: Épingler un fichier important

+
# 1. Ajouter un fichier au cache
+curl -X POST http://localhost:8080/api/audio/ \
+  -H "Content-Type: application/json" \
+  -d '{"url": "https://example.com/important.flac"}'
+
+# Réponse :
+# {
+#   "pk": "abc123def456",
+#   "url": "https://example.com/important.flac",
+#   "message": "Item added successfully"
+# }
+
+# 2. Vérifier le statut actuel
+curl http://localhost:8080/api/audio/abc123def456/pin
+
+# Réponse :
+# {
+#   "pk": "abc123def456",
+#   "pinned": false,
+#   "ttl_expires_at": null
+# }
+
+# 3. Épingler le fichier
+curl -X POST http://localhost:8080/api/audio/abc123def456/pin
+
+# Réponse :
+# {
+#   "pk": "abc123def456",
+#   "message": "Item 'abc123def456' pinned successfully"
+# }
+
+# 4. Vérifier qu'il est épinglé
+curl http://localhost:8080/api/audio/abc123def456/pin
+
+# Réponse :
+# {
+#   "pk": "abc123def456",
+#   "pinned": true,
+#   "ttl_expires_at": null
+# }
+

Workflow : Fichier +temporaire avec TTL

+
# 1. Ajouter un fichier
+curl -X POST http://localhost:8080/api/audio/ \
+  -H "Content-Type: application/json" \
+  -d '{"url": "https://example.com/preview.flac"}'
+
+# Réponse : {"pk": "xyz789abc123", ...}
+
+# 2. Définir un TTL de 1 heure
+curl -X POST http://localhost:8080/api/audio/xyz789abc123/ttl \
+  -H "Content-Type: application/json" \
+  -d '{"expires_at": "2025-01-15T11:30:00Z"}'
+
+# Réponse :
+# {
+#   "pk": "xyz789abc123",
+#   "message": "TTL set successfully for item 'xyz789abc123'"
+# }
+
+# 3. Le fichier sera automatiquement supprimé après expiration
+

Gestion d’erreur : +Conflit de règle métier

+
# 1. Épingler un item
+curl -X POST http://localhost:8080/api/audio/abc123/pin
+# OK
+
+# 2. Essayer de définir un TTL (interdit)
+curl -X POST http://localhost:8080/api/audio/abc123/ttl \
+  -H "Content-Type: application/json" \
+  -d '{"expires_at": "2025-01-15T12:00:00Z"}'
+
+# Réponse 409 CONFLICT :
+# {
+#   "error": "CONFLICT",
+#   "message": "Cannot set TTL on a pinned item. Unpin first."
+# }
+
+# 3. Solution : désépingler puis définir TTL
+curl -X DELETE http://localhost:8080/api/audio/abc123/pin
+curl -X POST http://localhost:8080/api/audio/abc123/ttl \
+  -H "Content-Type: application/json" \
+  -d '{"expires_at": "2025-01-15T12:00:00Z"}'
+# OK
+

Fichiers modifiés

+

Phase 1 : Implémentation de +base

+
    +
  1. pmocache/src/db.rs (380 lignes +ajoutées) +
      +
    • Modification du schéma SQL (colonnes pinned, +ttl_expires_at)
    • +
    • Ajout de champs dans CacheEntry
    • +
    • 8 nouvelles méthodes : count_unpinned(), +pin(), unpin(), is_pinned(), +set_ttl(), clear_ttl(), +get_expired()
    • +
    • Modification de get_oldest() pour exclure les items +épinglés
    • +
    • Mise à jour de toutes les requêtes SELECT
    • +
  2. +
  3. pmocache/src/cache.rs (135 lignes +ajoutées) +
      +
    • 5 nouvelles méthodes publiques : pin(), +unpin(), is_pinned(), set_ttl(), +clear_ttl()
    • +
    • Refonte complète de enforce_limit() : +
        +
      • Suppression prioritaire des items expirés
      • +
      • Utilisation de count_unpinned()
      • +
      • Protection des items épinglés
      • +
    • +
  4. +
  5. pmocache/tests/test_pinnable.rs (280 +lignes, nouveau fichier) +
      +
    • 9 tests exhaustifs
    • +
    • Couverture complète des cas d’usage
    • +
    • Validation des règles métier
    • +
  6. +
+

Phase 2 : Enrichissement API +REST

+
    +
  1. pmocache/src/api.rs (230 lignes +ajoutées) +
      +
    • 3 nouvelles structures : SetTtlRequest, +PinResponse, PinStatus
    • +
    • 5 nouveaux handlers HTTP avec gestion d’erreurs complète
    • +
    • Validation des règles métier au niveau HTTP
    • +
    • Codes de statut appropriés (200, 400, 404, 409, 500)
    • +
  2. +
  3. pmocache/src/pmoserver_ext.rs (15 +lignes modifiées) +
      +
    • 2 nouvelles routes dans create_api_router() : +
        +
      • /{pk}/pin (GET, POST, DELETE)
      • +
      • /{pk}/ttl (POST, DELETE)
      • +
    • +
    • Documentation des routes mise à jour
    • +
  4. +
  5. pmocache/src/openapi.rs (10 lignes +modifiées) +
      +
    • Macro create_cache_openapi! enrichie
    • +
    • 5 nouveaux endpoints documentés
    • +
    • 3 nouveaux schémas de données
    • +
  6. +
  7. pmocache/src/lib.rs (5 lignes +modifiées) +
      +
    • Export des structures publiques pour l’API
    • +
  8. +
+

Total : 7 fichiers modifiés, ~1055 lignes de code +ajoutées

+

Avantages de la solution

+

1. Architecture propre et +extensible

+
    +
  • Séparation des responsabilités : +
      +
    • db.rs : logique de base de données
    • +
    • cache.rs : logique métier
    • +
    • api.rs : interface HTTP
    • +
  • +
  • Réutilisabilité : +
      +
    • Traits existants conservés
    • +
    • Pas de duplication de code
    • +
    • Pattern cohérent avec l’architecture PMOcache
    • +
  • +
+

2. Sécurité et fiabilité

+
    +
  • Règles métier strictes : +
      +
    • Incompatibilité TTL ↔︎ Pinned appliquée à tous les niveaux
    • +
    • Validation au niveau DB, cache ET API
    • +
  • +
  • Gestion d’erreurs robuste : +
      +
    • Codes HTTP sémantiques
    • +
    • Messages explicites
    • +
    • Pas d’état incohérent possible
    • +
  • +
+

3. Performance

+
    +
  • Requêtes SQL optimisées : +
      +
    • Index sur pinned pour requêtes rapides
    • +
    • WHERE pinned = 0 évite le scan complet
    • +
  • +
  • Comptage efficace : +
      +
    • count_unpinned() utilise un index
    • +
    • Pas de post-filtrage en mémoire
    • +
  • +
+

4. Expérience développeur

+
    +
  • API intuitive : +
      +
    • Méthodes async cohérentes avec l’existant
    • +
    • Nommage clair (pin(), unpin(), +set_ttl())
    • +
  • +
  • Documentation complète : +
      +
    • OpenAPI générée automatiquement
    • +
    • Swagger UI interactive
    • +
    • Exemples d’utilisation
    • +
  • +
+

5. Compatibilité

+
    +
  • Migration transparente : +
      +
    • Aucune intervention manuelle
    • +
    • Valeurs par défaut appropriées
    • +
  • +
  • Pas de breaking change : +
      +
    • API existante inchangée
    • +
    • Nouveaux champs optionnels dans CacheEntry
    • +
  • +
+

Cas d’usage concrets

+

1. Cache de couvertures +d’albums

+
// Épingler les couvertures des albums favoris
+for album in user.favorite_albums {
+    let cover_pk = covers_cache.get_cover_pk(&album.id).await?;
+    covers_cache.pin(&cover_pk).await?;
+}
+
+// → Les couvertures favorites restent toujours en cache
+// → Même si le cache se remplit de nouvelles couvertures
+

2. Cache audio avec +previews temporaires

+
// Pistes complètes : épinglées si dans la playlist courante
+for track in current_playlist.tracks {
+    audio_cache.pin(&track.pk).await?;
+}
+
+// Previews de 30 secondes : TTL de 1 heure
+let preview_pk = audio_cache.add_preview(&track_url).await?;
+let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
+audio_cache.set_ttl(&preview_pk, &expires_at).await?;
+
+// → Pistes courantes toujours disponibles
+// → Previews nettoyées automatiquement
+

3. Cache de +métadonnées avec rafraîchissement

+
// Métadonnées d'album : TTL de 24h pour forcer le rafraîchissement
+let metadata_pk = metadata_cache.add_metadata(&album).await?;
+let tomorrow = (Utc::now() + Duration::days(1)).to_rfc3339();
+metadata_cache.set_ttl(&metadata_pk, &tomorrow).await?;
+
+// → Métadonnées rafraîchies quotidiennement
+// → Pas de données obsolètes
+

Limitations et +considérations

+

1. Pas de limite sur les +items épinglés

+

Les items épinglés peuvent s’accumuler indéfiniment. Recommandations +:

+
// Surveiller le nombre d'items épinglés
+let pinned_count = cache.db.count()? - cache.db.count_unpinned()?;
+if pinned_count > MAX_PINNED_ITEMS {
+    warn!("Too many pinned items: {}", pinned_count);
+}
+

2. TTL vérifié +uniquement lors de enforce_limit()

+

Les items expirés ne sont pas supprimés immédiatement. Solutions +possibles :

+
// Option 1 : Appel périodique
+tokio::spawn(async move {
+    loop {
+        tokio::time::sleep(Duration::from_secs(3600)).await;
+        cache.enforce_limit().await?;
+    }
+});
+
+// Option 2 : Vérification à l'accès
+if let Ok(entry) = cache.db.get(&pk, false) {
+    if let Some(ttl) = entry.ttl_expires_at {
+        if Utc::now() > DateTime::parse_from_rfc3339(&ttl)? {
+            cache.delete_item(&pk).await?;
+        }
+    }
+}
+

3. Format de date RFC3339 +strict

+

L’API exige le format RFC3339. Exemples valides :

+
2025-01-15T10:30:00Z           ✅ UTC
+2025-01-15T10:30:00+01:00      ✅ Avec timezone
+2025-01-15T10:30:00.123Z       ✅ Avec millisecondes
+2025-01-15 10:30:00            ❌ Format invalide
+

Évolutions futures possibles

+

1. Gestion automatique du TTL

+

Implémenter un worker en arrière-plan :

+
pub async fn start_ttl_worker(&self) {
+    tokio::spawn(async move {
+        loop {
+            self.enforce_limit().await;
+            tokio::time::sleep(Duration::from_secs(60)).await;
+        }
+    });
+}
+

2. Pinning conditionnel

+

Épingler automatiquement selon des critères :

+
pub async fn pin_if<F>(&self, predicate: F) -> Result<Vec<String>>
+where
+    F: Fn(&CacheEntry) -> bool,
+{
+    let entries = self.db.get_all(false)?;
+    let mut pinned = Vec::new();
+    
+    for entry in entries {
+        if predicate(&entry) && !entry.pinned {
+            self.pin(&entry.pk).await?;
+            pinned.push(entry.pk);
+        }
+    }
+    
+    Ok(pinned)
+}
+
+// Utilisation
+cache.pin_if(|e| e.hits > 100).await?;  // Épingler les plus utilisés
+

3. TTL relatif

+

Faciliter la définition de TTL :

+
pub async fn set_ttl_relative(&self, pk: &str, duration: Duration) -> Result<()> {
+    let expires_at = (Utc::now() + duration).to_rfc3339();
+    self.set_ttl(pk, &expires_at).await
+}
+
+// Utilisation
+cache.set_ttl_relative(&pk, Duration::hours(24)).await?;
+

4. Statistiques de pinning

+
pub async fn get_pinning_stats(&self) -> Result<PinningStats> {
+    Ok(PinningStats {
+        total_items: self.db.count()?,
+        pinned_items: self.db.count()? - self.db.count_unpinned()?,
+        items_with_ttl: self.db.count_with_ttl()?,
+        expired_items: self.db.get_expired()?.len(),
+    })
+}
+

Résultats et métriques

+

Tests

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CatégorieTestsPassésTaux
Nouveaux tests99100%
Tests existants1515100%
Total2424100%
+

Code

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MétriqueValeur
Fichiers modifiés7
Lignes ajoutées~1055
Nouvelles méthodes DB8
Nouvelles méthodes Cache5
Nouveaux endpoints API5
Nouvelles structures3
+

Compilation

+
    +
  • ✅ Aucune erreur
  • +
  • ✅ Aucun warning
  • +
  • ✅ Toutes les features compilent
  • +
+

Conclusion

+

L’implémentation des items épinglables et du TTL dans PMOcache est +complète et production-ready. La solution répond à tous +les objectifs initiaux :

+

✅ Objectifs atteints

+
    +
  1. Items épinglables fonctionnels : +
      +
    • Protection absolue contre l’éviction LRU
    • +
    • Exclusion du comptage de la limite du cache
    • +
  2. +
  3. Système de TTL robuste : +
      +
    • Expiration automatique des items temporaires
    • +
    • Suppression prioritaire lors de l’éviction
    • +
  4. +
  5. Règle métier stricte : +
      +
    • Incompatibilité TTL ↔︎ Pinned garantie à tous les niveaux
    • +
    • Validation DB, cache et API
    • +
  6. +
  7. API REST complète : +
      +
    • 5 nouveaux endpoints documentés
    • +
    • Gestion d’erreurs cohérente
    • +
    • Documentation OpenAPI automatique
    • +
  8. +
  9. Compatibilité préservée : +
      +
    • Migration transparente des bases existantes
    • +
    • Aucun breaking change dans l’API
    • +
    • Tous les tests existants passent
    • +
  10. +
+

Points forts

+
    +
  • Architecture propre : Séparation claire des +responsabilités
  • +
  • Code maintenable : Bien documenté, testé +exhaustivement
  • +
  • Extensible : Facile d’ajouter de nouvelles +fonctionnalités
  • +
  • Performant : Requêtes SQL optimisées avec +index
  • +
  • Sécurisé : Règles métier appliquées +strictement
  • +
+

Prêt pour la production

+

La fonctionnalité peut être déployée immédiatement : - Tous les tests +passent - Documentation complète - API stable et documentée - Pas de +régression sur l’existant

+

Cette implémentation renforce significativement PMOcache en le +rendant adapté à une gamme plus large de cas d’usage, tout en maintenant +sa simplicité et sa robustesse.

+
+ + diff --git a/Blackboard_HTML/Done_WeabApp_debouncingSSE.html b/Blackboard_HTML/Done_WeabApp_debouncingSSE.html new file mode 100644 index 00000000..c8e7d6e4 --- /dev/null +++ b/Blackboard_HTML/Done_WeabApp_debouncingSSE.html @@ -0,0 +1,209 @@ + + + + + +WeabApp_debouncingSSE + + + + + +
+ +

Rapport : +Suppression de la logique de débouncing SSE

+

Date: 2026-01-12 Tâche: +WeabApp_debouncingSSE.md

+

Objectif

+

Supprimer la logique de débouncing inutile sur le canal SSE de +l’application web PMOControl, puisque le serveur contrôle déjà le flux +des événements.

+

Analyse préalable

+

J’ai identifié trois endroits avec des mécanismes de temporisation +dans l’application web :

+

1. +MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)

+
    +
  • Débouncing: 200ms après invalidation du cache
  • +
  • Cooldown: 2 secondes entre les rechargements
  • +
  • Justification originale: “dédupliquer les +événements SSE dans le même batch (polling 500ms)”
  • +
  • Problème: Cette logique est redondante puisque le +serveur contrôle déjà le flux SSE
  • +
+

2. useRenderers.ts +- Smart fetching (À CONSERVER ✓)

+
    +
  • Mécanisme: Comparaison des timestamps +lastEventAt vs lastSnapshotAt
  • +
  • But: Éviter de refetch un snapshot déjà à jour
  • +
  • Justification: Ce n’est PAS du débouncing, c’est +une optimisation intelligente qui évite des appels API inutiles
  • +
+

3. +VolumeControl.vue - UI debouncing (À CONSERVER ✓)

+
    +
  • Débouncing: 300ms sur les changements de +volume
  • +
  • But: Réduire les appels API pendant que +l’utilisateur fait glisser le curseur
  • +
  • Justification: Débouncing légitime pour l’interface +utilisateur
  • +
+

Modifications effectuées

+

Fichier +modifié: +pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue

+

1. Suppression +des variables de débouncing (ligne ~27)

+

Avant:

+
// Flags pour gérer le rechargement automatique avec debounce et cooldown
+const isRefreshing = ref(false);
+const refreshTimeoutId = ref<number | null>(null);
+const lastRefreshTime = ref<number>(0);
+const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
+

Après:

+
// Flag pour gérer le rechargement automatique
+const isRefreshing = ref(false);
+

2. Simplification +du watcher de cache (ligne ~53)

+

Avant:

+
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
+// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
+// ce qui déclenche un événement ContainersUpdated qui invalide le cache
+// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
+// et un cooldown de 5 secondes pour éviter les rechargements successifs
+watch(
+    () => browseData.value,
+    (data) => {
+        if (!data && props.containerId && !loading.value) {
+            // Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
+            const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
+            if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
+                console.log(
+                    `[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
+                );
+                return;
+            }
+
+            // Annuler tout timeout en cours
+            if (refreshTimeoutId.value !== null) {
+                clearTimeout(refreshTimeoutId.value);
+            }
+
+            // Planifier le rechargement après 200ms
+            refreshTimeoutId.value = window.setTimeout(async () => {
+                if (!isRefreshing.value) {
+                    console.log(
+                        `[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
+                    );
+                    isRefreshing.value = true;
+                    await browseContainer(
+                        props.serverId,
+                        props.containerId,
+                        false,
+                    );
+                    lastRefreshTime.value = Date.now();
+                    isRefreshing.value = false;
+                    refreshTimeoutId.value = null;
+                }
+            }, 200);
+        }
+    },
+);
+

Après:

+
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
+// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
+// ce qui déclenche un événement ContainersUpdated qui invalide le cache
+// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
+watch(
+    () => browseData.value,
+    async (data) => {
+        // Si browseData devient undefined alors que containerId est présent,
+        // et qu'on n'est pas déjà en train de charger, recharger immédiatement
+        if (!data && props.containerId && !loading.value && !isRefreshing.value) {
+            console.log(
+                `[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
+            );
+            isRefreshing.value = true;
+            await browseContainer(props.serverId, props.containerId, false);
+            isRefreshing.value = false;
+        }
+    },
+);
+

Résultats

+

Changements de comportement

+
    +
  • Avant: Délai de 200ms + cooldown de 2s entre les +rechargements de cache
  • +
  • Après: Rechargement immédiat dès l’invalidation du +cache
  • +
  • Impact: Réactivité améliorée de l’interface, les +mises à jour apparaissent immédiatement
  • +
+

Réduction de complexité

+
    +
  • 3 variables supprimées: +refreshTimeoutId, lastRefreshTime, +REFRESH_COOLDOWN_MS
  • +
  • Logique simplifiée: De ~40 lignes à ~10 lignes dans +le watcher
  • +
  • Code plus lisible: Intention claire sans mécanismes +de temporisation complexes
  • +
+

Tests

+
    +
  • ✓ Le projet compile sans erreurs TypeScript
  • +
  • ✓ Le flag isRefreshing empêche toujours les +rechargements concurrents
  • +
  • ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) +conservent leurs optimisations légitimes
  • +
+

Conclusion

+

La suppression du débouncing et du cooldown dans MediaBrowser.vue +simplifie le code tout en améliorant la réactivité de l’interface. +Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client +étaient redondants et ajoutaient une latence artificielle.

+

Le code est maintenant plus simple, plus réactif, et fait confiance +au serveur pour contrôler la fréquence des événements SSE.

+

Fichiers modifiés

+
    +
  • pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
  • +
+

Lignes de code

+
    +
  • Supprimées: ~35 lignes (logique de +débouncing/cooldown)
  • +
  • Ajoutées: ~5 lignes (logique simplifiée)
  • +
  • Net: -30 lignes
  • +
+
+ + diff --git a/Blackboard_HTML/Report_Pinnable_cache_item.html b/Blackboard_HTML/Report_Pinnable_cache_item.html new file mode 100644 index 00000000..307bb7a7 --- /dev/null +++ b/Blackboard_HTML/Report_Pinnable_cache_item.html @@ -0,0 +1,518 @@ + + + + + +Pinnable_cache_item + + + + + +
+ +

Rapport +: Implémentation des items épinglables dans PMOcache

+

Résumé

+

Implémentation réussie de la fonctionnalité d’items épinglables dans +la crate PMOcache, permettant de protéger certains items de l’éviction +automatique par la politique LRU. Cette fonctionnalité inclut également +un système de TTL (Time To Live) avec une règle métier empêchant qu’un +item soit à la fois épinglé et avec un TTL.

+

Modifications apportées

+

1. Structure +de la base de données (pmocache/src/db.rs)

+

Modification du schéma +de la table asset

+

Ajout de deux nouvelles colonnes :

+
CREATE TABLE IF NOT EXISTS asset (
+    pk TEXT PRIMARY KEY,
+    collection TEXT,
+    id TEXT,
+    hits INTEGER DEFAULT 0,
+    last_used TEXT,
+    lazy_pk TEXT,
+    pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
+    ttl_expires_at TEXT
+)
+
    +
  • pinned : Booléen (0 ou 1) indiquant si +l’item est épinglé
  • +
  • ttl_expires_at : Date/heure +d’expiration au format RFC3339 (optionnel)
  • +
+

Mise à jour de la +structure CacheEntry

+

Ajout des champs correspondants :

+
pub struct CacheEntry {
+    // ... champs existants ...
+    pub pinned: bool,
+    pub ttl_expires_at: Option<String>,
+    // ...
+}
+

Nouvelles méthodes dans +DB

+
Gestion du comptage
+
    +
  • count_unpinned() : Compte uniquement +les items non épinglés +
      +
    • Les items épinglés ne comptent pas dans la limite du cache
    • +
  • +
+
Gestion du pinning
+
    +
  • pin(pk: &str) : Épingle un +item

    +
      +
    • Vérifie que l’item n’a pas de TTL défini (règle métier)
    • +
    • Retourne une erreur si le TTL est déjà défini
    • +
  • +
  • unpin(pk: &str) : Désépingle un +item

  • +
  • is_pinned(pk: &str) : Vérifie +si un item est épinglé

  • +
+
Gestion du TTL
+
    +
  • set_ttl(pk: &str, expires_at: &str) +: Définit le TTL d’un item

    +
      +
    • Vérifie que l’item n’est pas épinglé (règle métier)
    • +
    • Retourne une erreur si l’item est épinglé
    • +
  • +
  • clear_ttl(pk: &str) : Supprime +le TTL d’un item

  • +
  • get_expired() : Récupère tous les +items dont le TTL est dépassé

  • +
+
Modification de +get_oldest()
+

La requête SQL exclut maintenant les items épinglés :

+
SELECT ... FROM asset
+WHERE pinned = 0
+ORDER BY last_used ASC, hits ASC
+LIMIT ?1
+

2. Logique du cache +(pmocache/src/cache.rs)

+

Méthodes publiques ajoutées

+
pub async fn pin(&self, pk: &str) -> Result<()>
+pub async fn unpin(&self, pk: &str) -> Result<()>
+pub async fn is_pinned(&self, pk: &str) -> Result<bool>
+pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
+pub async fn clear_ttl(&self, pk: &str) -> Result<()>
+

Modification de +enforce_limit()

+

La politique d’éviction a été améliorée :

+
    +
  1. Suppression prioritaire des items expirés : Les +items dont le TTL est dépassé sont supprimés en premier
  2. +
  3. Comptage des items non épinglés : Utilise +count_unpinned() au lieu de count()
  4. +
  5. Protection des items épinglés : Ils ne peuvent pas +être évincés par LRU
  6. +
  7. Logging amélioré : Messages distincts pour les +items expirés et l’éviction LRU
  8. +
+

3. Tests +(pmocache/tests/test_pinnable.rs)

+

Création d’une suite complète de tests (9 tests, tous passants) :

+
    +
  1. test_pin_unpin : Vérifie l’épinglage +et le désépinglage basiques
  2. +
  3. test_pinned_excluded_from_lru : +Vérifie que les items épinglés ne sont pas évincés
  4. +
  5. test_pinned_count_separately : Vérifie +le comptage séparé des items épinglés
  6. +
  7. test_cannot_pin_with_ttl : Vérifie la +règle métier TTL → pas de pinning
  8. +
  9. test_cannot_set_ttl_when_pinned : +Vérifie la règle métier pinned → pas de TTL
  10. +
  11. test_ttl_expiration : Vérifie la +suppression automatique des items expirés
  12. +
  13. test_clear_ttl : Vérifie la +suppression du TTL
  14. +
  15. test_get_expired : Vérifie la +récupération des items expirés
  16. +
  17. test_cache_entry_fields : Vérifie les +valeurs des champs dans CacheEntry
  18. +
+

Règles métier implémentées

+

Incompatibilité TTL ↔︎ Pinned

+

Un item ne peut pas être à la fois épinglé ET avoir un TTL :

+
    +
  • Si TTL défini : pin() retourne une +erreur
  • +
  • Si épinglé : set_ttl() retourne une +erreur
  • +
+

Cette règle garantit une sémantique claire : - +Épinglé = permanent, protégé de l’éviction - +TTL = temporaire, sera supprimé à expiration

+

Comptage des items

+

Les items épinglés sont exclus du comptage de la +limite du cache :

+
    +
  • Un cache de limite 100 peut contenir 100 items non épinglés + N +items épinglés
  • +
  • Seuls les items non épinglés sont pris en compte pour l’éviction +LRU
  • +
+

Ordre de suppression +lors de enforce_limit()

+
    +
  1. Items expirés (TTL dépassé) : supprimés en +priorité
  2. +
  3. Items LRU : si la limite est toujours dépassée, +suppression des plus vieux items non épinglés
  4. +
+

Compatibilité

+

Migration de base de données

+

Aucune migration nécessaire : Les colonnes +pinned et ttl_expires_at ont des valeurs par +défaut : - pinned = 0 (non épinglé) - +ttl_expires_at = NULL (pas de TTL)

+

Les bases existantes seront automatiquement mises à jour au prochain +démarrage via le CREATE TABLE IF NOT EXISTS avec les +nouvelles colonnes.

+

Rétrocompatibilité du code

+

Toutes les méthodes existantes continuent de fonctionner sans +modification : - Les items existants ne sont pas épinglés par défaut - +Le comportement LRU standard reste identique pour les items non +épinglés

+

Exemples d’utilisation

+

Utilisation programmatique +(Rust)

+
use pmocache::{Cache, CacheConfig};
+use chrono::{Duration, Utc};
+
+// Créer un cache
+let cache = Cache::<MyConfig>::new("./cache", 100).unwrap();
+
+// Ajouter un fichier
+let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
+
+// Épingler pour protéger de l'éviction
+cache.pin(&pk).await?;
+
+// Ou définir un TTL de 24 heures
+let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
+cache.set_ttl(&pk2, &expires_at).await?;
+
+// Vérifier le statut
+if cache.is_pinned(&pk).await? {
+    println!("Fichier protégé");
+}
+

Utilisation via l’API REST

+

Récupérer le statut de +pinning

+
GET /api/cache/{pk}/pin
+
+Response 200 OK:
+{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "pinned": false,
+  "ttl_expires_at": null
+}
+

Épingler un item

+
POST /api/cache/{pk}/pin
+
+Response 200 OK:
+{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
+}
+
+Response 409 CONFLICT (si TTL défini):
+{
+  "error": "CONFLICT",
+  "message": "Cannot pin an item with TTL set. Clear TTL first."
+}
+

Désépingler un item

+
DELETE /api/cache/{pk}/pin
+
+Response 200 OK:
+{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "message": "Item '1a2b3c4d5e6f7a8b' unpinned successfully"
+}
+

Définir un TTL

+
POST /api/cache/{pk}/ttl
+Content-Type: application/json
+
+{
+  "expires_at": "2025-01-20T10:30:00Z"
+}
+
+Response 200 OK:
+{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "message": "TTL set successfully for item '1a2b3c4d5e6f7a8b'"
+}
+
+Response 409 CONFLICT (si épinglé):
+{
+  "error": "CONFLICT",
+  "message": "Cannot set TTL on a pinned item. Unpin first."
+}
+
+Response 400 BAD REQUEST (format invalide):
+{
+  "error": "INVALID_DATE",
+  "message": "Invalid RFC3339 date format"
+}
+

Supprimer un TTL

+
DELETE /api/cache/{pk}/ttl
+
+Response 200 OK:
+{
+  "pk": "1a2b3c4d5e6f7a8b",
+  "message": "TTL cleared successfully for item '1a2b3c4d5e6f7a8b'"
+}
+

Fichiers modifiés

+

Phase 1 : Implémentation de +base

+
    +
  1. pmocache/src/db.rs : +
      +
    • Modification du schéma SQL
    • +
    • Ajout de champs dans CacheEntry
    • +
    • Ajout de 8 nouvelles méthodes
    • +
    • Modification de get_oldest(), get(), +get_from_id(), get_all(), +get_by_collection()
    • +
  2. +
  3. pmocache/src/cache.rs : +
      +
    • Ajout de 5 méthodes publiques
    • +
    • Modification de enforce_limit()
    • +
  4. +
  5. pmocache/tests/test_pinnable.rs : +
      +
    • Nouveau fichier de tests (9 tests)
    • +
  6. +
+

Phase 2 : Enrichissement de +l’API REST

+
    +
  1. pmocache/src/api.rs : +
      +
    • Ajout de 3 nouvelles structures de données : +SetTtlRequest, PinResponse, +PinStatus
    • +
    • Ajout de 5 nouveaux handlers d’API : +
        +
      • get_pin_status() : Récupération du statut de +pinning
      • +
      • pin_item() : Épinglage d’un item
      • +
      • unpin_item() : Désépinglage d’un item
      • +
      • set_item_ttl() : Définition du TTL
      • +
      • clear_item_ttl() : Suppression du TTL
      • +
    • +
  2. +
  3. pmocache/src/pmoserver_ext.rs : +
      +
    • Ajout de 4 nouvelles routes dans create_api_router() : +
        +
      • GET /{pk}/pin : Statut de pinning
      • +
      • POST /{pk}/pin : Épingler
      • +
      • DELETE /{pk}/pin : Désépingler
      • +
      • POST /{pk}/ttl : Définir TTL
      • +
      • DELETE /{pk}/ttl : Supprimer TTL
      • +
    • +
  4. +
  5. pmocache/src/openapi.rs : +
      +
    • Mise à jour de la macro create_cache_openapi! pour +inclure : +
        +
      • Les 5 nouveaux endpoints dans la documentation
      • +
      • Les 3 nouvelles structures dans les schémas OpenAPI
      • +
    • +
  6. +
  7. pmocache/src/lib.rs : +
      +
    • Export des nouvelles structures publiques pour l’API
    • +
  8. +
+

API REST et Documentation +OpenAPI

+

Routes disponibles

+

Toutes les routes sont préfixées par /api/{cache_name}/ +(ex: /api/covers/, /api/audio/).

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MéthodeRouteDescription
GET/{pk}/pinRécupère le statut de pinning d’un item
POST/{pk}/pinÉpingle un item (le protège de l’éviction LRU)
DELETE/{pk}/pinDésépingle un item
POST/{pk}/ttlDéfinit le TTL d’un item (expiration automatique)
DELETE/{pk}/ttlSupprime le TTL d’un item
+

Codes de statut HTTP

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeSignificationCas d’usage
200 OKOpération réussieTous les cas de succès
400 BAD REQUESTRequête invalideFormat de date TTL invalide
404 NOT FOUNDItem non trouvéPK inexistant dans le cache
409 CONFLICTConflit de règle métierTentative de pin avec TTL ou vice-versa
500 INTERNAL SERVER ERRORErreur serveurErreur de base de données
+

Documentation OpenAPI/Swagger

+

La documentation OpenAPI est automatiquement générée et inclut :

+
    +
  • Schémas de données : +
      +
    • PinStatus : Statut de pinning (pinned, +ttl_expires_at)
    • +
    • PinResponse : Réponse d’opération de pinning
    • +
    • SetTtlRequest : Requête de définition de TTL
    • +
    • CacheEntry : Mis à jour avec les champs +pinned et ttl_expires_at
    • +
  • +
  • Endpoints documentés : +
      +
    • Description détaillée de chaque route
    • +
    • Exemples de requêtes et réponses
    • +
    • Codes d’erreur possibles
    • +
  • +
  • Interface Swagger UI : +
      +
    • Accessible à /swagger-ui/{cache_name}
    • +
    • Permet de tester l’API directement depuis le navigateur
    • +
  • +
+

Gestion des erreurs

+

L’API suit une structure d’erreur cohérente :

+
{
+  "error": "CODE_ERREUR",
+  "message": "Description lisible de l'erreur"
+}
+

Les règles métier sont appliquées strictement : - 409 +CONFLICT si tentative de pin avec TTL défini - 409 +CONFLICT si tentative de set TTL sur item épinglé - Messages +d’erreur explicites guidant l’utilisateur

+

Tests

+
    +
  • Suite de tests dédiée : 9 tests, tous passants
  • +
  • Tests existants : Tous les tests de +test_cache.rs passent toujours
  • +
  • Couverture : Toutes les nouvelles fonctionnalités +sont testées
  • +
  • Compilation : Aucune erreur, tous les modules +compilent correctement
  • +
+

Résultat

+

Implémentation complète et fonctionnelle des +items épinglables avec TTL
+✅ Règle métier TTL ↔︎ Pinned correctement +implémentée
+✅ Tests exhaustifs validant tous les cas d’usage
+✅ Compatibilité avec les bases de données +existantes
+✅ Pas de régression sur les tests existants
+✅ API REST complète avec 5 nouveaux endpoints
+✅ Documentation OpenAPI automatiquement générée
+✅ Gestion d’erreurs cohérente avec codes HTTP +appropriés

+
+ + diff --git a/Blackboard_HTML/Report_WeabApp_debouncingSSE.html b/Blackboard_HTML/Report_WeabApp_debouncingSSE.html new file mode 100644 index 00000000..c8e7d6e4 --- /dev/null +++ b/Blackboard_HTML/Report_WeabApp_debouncingSSE.html @@ -0,0 +1,209 @@ + + + + + +WeabApp_debouncingSSE + + + + + +
+ +

Rapport : +Suppression de la logique de débouncing SSE

+

Date: 2026-01-12 Tâche: +WeabApp_debouncingSSE.md

+

Objectif

+

Supprimer la logique de débouncing inutile sur le canal SSE de +l’application web PMOControl, puisque le serveur contrôle déjà le flux +des événements.

+

Analyse préalable

+

J’ai identifié trois endroits avec des mécanismes de temporisation +dans l’application web :

+

1. +MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)

+
    +
  • Débouncing: 200ms après invalidation du cache
  • +
  • Cooldown: 2 secondes entre les rechargements
  • +
  • Justification originale: “dédupliquer les +événements SSE dans le même batch (polling 500ms)”
  • +
  • Problème: Cette logique est redondante puisque le +serveur contrôle déjà le flux SSE
  • +
+

2. useRenderers.ts +- Smart fetching (À CONSERVER ✓)

+
    +
  • Mécanisme: Comparaison des timestamps +lastEventAt vs lastSnapshotAt
  • +
  • But: Éviter de refetch un snapshot déjà à jour
  • +
  • Justification: Ce n’est PAS du débouncing, c’est +une optimisation intelligente qui évite des appels API inutiles
  • +
+

3. +VolumeControl.vue - UI debouncing (À CONSERVER ✓)

+
    +
  • Débouncing: 300ms sur les changements de +volume
  • +
  • But: Réduire les appels API pendant que +l’utilisateur fait glisser le curseur
  • +
  • Justification: Débouncing légitime pour l’interface +utilisateur
  • +
+

Modifications effectuées

+

Fichier +modifié: +pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue

+

1. Suppression +des variables de débouncing (ligne ~27)

+

Avant:

+
// Flags pour gérer le rechargement automatique avec debounce et cooldown
+const isRefreshing = ref(false);
+const refreshTimeoutId = ref<number | null>(null);
+const lastRefreshTime = ref<number>(0);
+const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
+

Après:

+
// Flag pour gérer le rechargement automatique
+const isRefreshing = ref(false);
+

2. Simplification +du watcher de cache (ligne ~53)

+

Avant:

+
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
+// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
+// ce qui déclenche un événement ContainersUpdated qui invalide le cache
+// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
+// et un cooldown de 5 secondes pour éviter les rechargements successifs
+watch(
+    () => browseData.value,
+    (data) => {
+        if (!data && props.containerId && !loading.value) {
+            // Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
+            const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
+            if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
+                console.log(
+                    `[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
+                );
+                return;
+            }
+
+            // Annuler tout timeout en cours
+            if (refreshTimeoutId.value !== null) {
+                clearTimeout(refreshTimeoutId.value);
+            }
+
+            // Planifier le rechargement après 200ms
+            refreshTimeoutId.value = window.setTimeout(async () => {
+                if (!isRefreshing.value) {
+                    console.log(
+                        `[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
+                    );
+                    isRefreshing.value = true;
+                    await browseContainer(
+                        props.serverId,
+                        props.containerId,
+                        false,
+                    );
+                    lastRefreshTime.value = Date.now();
+                    isRefreshing.value = false;
+                    refreshTimeoutId.value = null;
+                }
+            }, 200);
+        }
+    },
+);
+

Après:

+
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
+// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
+// ce qui déclenche un événement ContainersUpdated qui invalide le cache
+// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
+watch(
+    () => browseData.value,
+    async (data) => {
+        // Si browseData devient undefined alors que containerId est présent,
+        // et qu'on n'est pas déjà en train de charger, recharger immédiatement
+        if (!data && props.containerId && !loading.value && !isRefreshing.value) {
+            console.log(
+                `[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
+            );
+            isRefreshing.value = true;
+            await browseContainer(props.serverId, props.containerId, false);
+            isRefreshing.value = false;
+        }
+    },
+);
+

Résultats

+

Changements de comportement

+
    +
  • Avant: Délai de 200ms + cooldown de 2s entre les +rechargements de cache
  • +
  • Après: Rechargement immédiat dès l’invalidation du +cache
  • +
  • Impact: Réactivité améliorée de l’interface, les +mises à jour apparaissent immédiatement
  • +
+

Réduction de complexité

+
    +
  • 3 variables supprimées: +refreshTimeoutId, lastRefreshTime, +REFRESH_COOLDOWN_MS
  • +
  • Logique simplifiée: De ~40 lignes à ~10 lignes dans +le watcher
  • +
  • Code plus lisible: Intention claire sans mécanismes +de temporisation complexes
  • +
+

Tests

+
    +
  • ✓ Le projet compile sans erreurs TypeScript
  • +
  • ✓ Le flag isRefreshing empêche toujours les +rechargements concurrents
  • +
  • ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) +conservent leurs optimisations légitimes
  • +
+

Conclusion

+

La suppression du débouncing et du cooldown dans MediaBrowser.vue +simplifie le code tout en améliorant la réactivité de l’interface. +Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client +étaient redondants et ajoutaient une latence artificielle.

+

Le code est maintenant plus simple, plus réactif, et fait confiance +au serveur pour contrôler la fréquence des événements SSE.

+

Fichiers modifiés

+
    +
  • pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
  • +
+

Lignes de code

+
    +
  • Supprimées: ~35 lignes (logique de +débouncing/cooldown)
  • +
  • Ajoutées: ~5 lignes (logique simplifiée)
  • +
  • Net: -30 lignes
  • +
+
+ + diff --git a/Blackboard_HTML/Report_config_ext.html b/Blackboard_HTML/Report_config_ext.html new file mode 100644 index 00000000..33ce266c --- /dev/null +++ b/Blackboard_HTML/Report_config_ext.html @@ -0,0 +1,152 @@ + + + + + +config_ext + + + + + +
+ +

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. +
  3. Architecture : Structure et flux de données
  4. +
  5. Implémentation : Guide détaillé avec patterns de +code
  6. +
  7. Patterns spécialisés : Exemples pour chaque cas +d’usage
  8. +
  9. Bonnes pratiques : Nommage, erreurs, +documentation
  10. +
  11. Exemples complets : 3 implémentations complètes +commentées
  12. +
  13. Checklist : Liste de vérification pour nouveaux +traits
  14. +
  15. Philosophie : Principes directeurs et +avantages
  16. +
+

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_HTML/Report_music_source.html b/Blackboard_HTML/Report_music_source.html new file mode 100644 index 00000000..c14c40e2 --- /dev/null +++ b/Blackboard_HTML/Report_music_source.html @@ -0,0 +1,238 @@ + + + + + +music_source + + + + + +
+ +

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 :

+
update_counter: Arc<RwLock<u32>>
+last_change: Arc<RwLock<SystemTime>>
+callback_tokens: Arc<Mutex<Vec<u64>>>
+container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>
+

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 :

+
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. +
  3. Structure d’une MusicSource
  4. +
  5. Implémentation du trait MusicSource
  6. +
  7. Patterns d’implémentation
  8. +
  9. Intégration avec l’écosystème PMOMusic
  10. +
  11. Checklist de mise en œuvre
  12. +
  13. Exemples de référence
  14. +
+

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 :

+
<source-id>
+<source-id>:albums
+<source-id>:album:<album_id>
+<source-id>:track:<track_id>
+<source-id>:playlist:<playlist_id>
+

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<RwLock<>> 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. +
  3. Navigation : root_container et browse avec pattern +matching
  4. +
  5. Résolution URI : avec fallback cache → +original
  6. +
  7. FIFO : append_track, remove_oldest, callbacks
  8. +
  9. Adaptation IDs : fonction complète +d’adaptation
  10. +
  11. LazyProvider : implémentation personnalisée
  12. +
  13. Conversion DIDL : traits ToDIDLContainer et +ToDIDLItem
  14. +
+

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. +
  3. Authentification : Mentionné mais pas d’exemple +complet
  4. +
  5. Multi-format : Pas d’exemple de source supportant +plusieurs formats
  6. +
  7. Offline : Pas de pattern pour source +offline/synchronisation
  8. +
+

Améliorations possibles

+
    +
  1. Ajouter un exemple complet de search avec filtres
  2. +
  3. Documenter l’intégration avec un système d’auth OAuth
  4. +
  5. Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)
  6. +
  7. Documenter la gestion offline avec synchronisation
  8. +
+

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_HTML/Report_pmoserver_ext.html b/Blackboard_HTML/Report_pmoserver_ext.html new file mode 100644 index 00000000..2719590c --- /dev/null +++ b/Blackboard_HTML/Report_pmoserver_ext.html @@ -0,0 +1,120 @@ + + + + + +pmoserver_ext + + + + + +
+ +

Rapport : +Documentation du pattern pmoserver_ext

+

Contexte

+

Documentation du pattern d’extension du PMOServer à travers plusieurs +itérations basées sur les retours utilisateur.

+

Travail réalisé

+

Analyse des fichiers sources

+

Les fichiers suivants ont été analysés pour extraire le pattern :

+
    +
  • 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
  • +
+

Round 1 : Document initial

+

Premier jet documentant exhaustivement tous les aspects des +extensions (~850 lignes).

+

Round 2 : Recentrage sur le +pattern

+

Annotation : “se recentrer sur le sujet +principal”

+

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

+

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. +
  3. Anatomie d’une extension : 5 étapes détaillées
  4. +
  5. Méthodes disponibles du serveur : API de +pmoserver::Server
  6. +
  7. Documentation OpenAPI avec utoipa : Guide complet +en 5 étapes ⭐ Ajouté au Round 3
  8. +
  9. Patterns courants : 3 exemples concrets
  10. +
  11. Gestion des opérations longues : spawn_blocking, +timeouts, background tasks
  12. +
  13. Checklist d’implémentation : Organisée par +catégories
  14. +
  15. Exemple complet minimal : Code fonctionnel
  16. +
  17. Références : Fichiers sources et dépendances
  18. +
+

Résultat final

+

Le document est maintenant :

+
    +
  • 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
  • +
+

Taille finale : ~660 lignes (avec section OpenAPI complète)

+

Fichiers modifiés

+
    +
  • Blackboard/Architecture/pmoserver_ext.md : Document +complet avec OpenAPI (660 lignes)
  • +
+
+ + diff --git a/Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html b/Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html new file mode 100644 index 00000000..dafb1ff3 --- /dev/null +++ b/Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html @@ -0,0 +1,54 @@ + + + + + +Pinnable_cache_item + + + + + +
+ +

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_HTML/ToDiscuss_pmoserver_ext.html b/Blackboard_HTML/ToDiscuss_pmoserver_ext.html new file mode 100644 index 00000000..2db34e27 --- /dev/null +++ b/Blackboard_HTML/ToDiscuss_pmoserver_ext.html @@ -0,0 +1,59 @@ + + + + + +pmoserver_ext + + + + + +
+ +

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_HTML/ToThinkAbout_MusicBoxSource.html b/Blackboard_HTML/ToThinkAbout_MusicBoxSource.html new file mode 100644 index 00000000..93cc2e90 --- /dev/null +++ b/Blackboard_HTML/ToThinkAbout_MusicBoxSource.html @@ -0,0 +1,563 @@ + + + + + +MusicBoxSource + + + + + +
+ +

Il faut suivre les instructions générales placées dans le +fichier : Blackboard/Rules.md

+

MusicBoxSource +: Bibliothèque musicale universelle

+

Créer une “boîte à musique” personnelle : un +catalogue unifié de morceaux provenant de n’importe quelle source +(Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de +tags et playlists intelligentes.

+
+

🎯 Vision

+

Concept

+

MusicBoxSource est une bibliothèque musicale +curatoriale qui permet de : - Collecter : Ajouter des +morceaux depuis n’importe quelle source PMOMusic ou URL - +Organiser : Classifier avec une taxonomie de tags +extensible - Requêter : Créer des playlists statiques +et smart playlists (requêtes dynamiques) - Exposer : +Servir via UPnP/DIDL-Lite avec navigation multi-axes

+

Différence avec +pmoplaylist

+
    +
  • pmoplaylist : Playlists FIFO +éphémères pour sources live (Radio Paradise)
  • +
  • pmomusicbox : Bibliothèque +persistante cross-sources avec métadonnées +enrichies
  • +
+
+

🏛️ Architecture globale

+
flowchart TB
+    subgraph Sources[Sources PMOMusic]
+        QOBUZ[pmoqobuz]
+        PARADISE[pmoparadise]
+        LOCAL[pmolocal - à créer]
+        URL[URLs directes]
+    end
+    
+    subgraph Import[Import Layer]
+        IMPORTER[MusicBox Importer]
+        JSPF[pmojspf - Parser playlists]
+        META[pmometadata - Extraction]
+    end
+    
+    subgraph Core[pmomusicbox Core]
+        DB[(SQLite Database)]
+        TAXONOMY[Taxonomie Tags]
+        QUERY[Smart Query Engine]
+    end
+    
+    subgraph Cache[Cache Layer]
+        AUDIO[pmoaudiocache]
+        COVERS[pmocovers]
+    end
+    
+    subgraph Export[Export UPnP]
+        SOURCE[MusicSource Trait]
+        DIDL[DIDL-Lite Generator]
+        BROWSE[Multi-Axis Browser]
+    end
+    
+    Sources --> IMPORTER
+    URL --> IMPORTER
+    JSPF --> IMPORTER
+    META --> IMPORTER
+    
+    IMPORTER --> DB
+    DB --> TAXONOMY
+    DB --> QUERY
+    
+    DB <--> AUDIO
+    DB <--> COVERS
+    
+    DB --> SOURCE
+    TAXONOMY --> BROWSE
+    QUERY --> BROWSE
+    SOURCE --> DIDL
+    BROWSE --> DIDL
+
+

🗄️ Modèle de données (SQLite)

+

Tables principales

+
erDiagram
+    TAG_CATEGORIES ||--o{ TAGS : contient
+    TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent
+    TAGS ||--o{ ITEM_TAGS : associe
+    MUSIC_ITEMS ||--o{ ITEM_TAGS : a
+    MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans
+    PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient
+    
+    TAG_CATEGORIES {
+        text id PK "Ex: mood, genre"
+        text name "Nom affiché"
+        text parent_id FK "Hiérarchie"
+        text color "Hex color"
+        text icon "Emoji/icon"
+        int display_order
+    }
+    
+    TAGS {
+        text id PK "Ex: mood:energetic"
+        text category_id FK
+        text name "energetic, chill"
+        text description
+        text color "Override"
+    }
+    
+    MUSIC_ITEMS {
+        text id PK "UUID"
+        text source_type "qobuz, url, local"
+        text source_id "ID source"
+        text original_uri "URI source"
+        text cache_audio_pk FK "pmoaudiocache"
+        text cache_cover_pk FK "pmocovers"
+        text title
+        text artist
+        text album
+        int year
+        int rating "1-5 étoiles"
+        int play_count
+    }
+    
+    ITEM_TAGS {
+        text item_id PK,FK
+        text tag_id PK,FK
+        int added_at
+        text source "user, auto"
+    }
+    
+    PLAYLISTS {
+        text id PK
+        text name
+        bool is_smart
+        text smart_query "JSON"
+    }
+    
+    PLAYLIST_ITEMS {
+        text playlist_id PK,FK
+        text item_id FK
+        int position PK
+    }
+

Tables d’association

+
    +
  • item_tags : Liens items ↔︎ tags +(N:M)
  • +
  • playlist_items : Items dans playlists +statiques (position, ordre)
  • +
  • tag_synonyms : Synonymes pour +recherche (ex: “jazz” → “swing”)
  • +
+

Index & Recherche

+
    +
  • Indexes B-tree : artist, album, genre, year, +rating, play_count
  • +
  • FTS5 (Full-Text Search) : title, artist, album, +comment
  • +
  • Triggers : Maintien des tables FTS en sync avec +music_items
  • +
+
+

🎨 Taxonomie par défaut

+

Catégories préchargées à l’initialisation :

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CatégorieDescriptionExemples de tags
MoodÉtat d’esprit, émotionenergetic, chill, melancholic, happy
GenreStyle musicalrock, jazz, classical, electronic, metal
EraPériode, décennie60s, 70s, 80s, 90s, contemporary
OccasionContexte d’écouteworkout, focus, party, driving, sleep
TempoVitesseslow, medium, fast
InstrumentInstrument dominantpiano, guitar, vocal, synthesizer
QualityQualité audiolossless, high-res, remastered, live
OriginOrigine géographiqueusa, uk, france, japan, latin, africa
+

Extensibilité : L’utilisateur peut créer ses propres +catégories et tags.

+
+

📦 Crates architecture

+

1. +pmojspf - Parser de playlists +(utilitaire)

+

But : Parser/écrire différents formats de playlists +vers/depuis un format pivot JSPF (JSON).

+
pmojspf/
+├── model.rs        # Structures JSPF (Playlist, Track, Meta)
+├── reader/
+│   ├── jspf.rs     # JSON natif
+│   ├── xspf.rs     # XML (via quick-xml ou crate xspf)
+│   ├── m3u.rs      # M3U/M3U8 (parsing ligne par ligne)
+│   └── pls.rs      # PLS (format INI-like)
+└── writer.rs       # Export JSPF
+

Dépendances : serde, +serde_json, quick-xml (ou xspf +crate)

+

Usage : Réutilisé par pmomusicbox pour +import/export

+
+

2. +pmomusicbox - Bibliothèque musicale +core

+

Responsabilités : - Gestion base SQLite (CRUD items, +tags, playlists) - Import depuis sources PMO (Qobuz, Paradise, Local, +URLs) - Smart playlists (query builder + exécution SQL) - Implémentation +MusicSource trait (exposition UPnP) - Intégration caches +audio/covers

+
pmomusicbox/
+├── db/
+│   ├── schema.rs       # DDL SQLite + migrations
+│   ├── items.rs        # CRUD music_items
+│   ├── tags.rs         # CRUD tags + taxonomie
+│   ├── playlists.rs    # CRUD playlists statiques
+│   ├── smart.rs        # Smart playlists
+│   └── search.rs       # Full-text search (FTS5)
+│
+├── import/
+│   ├── url.rs          # Import URL directe
+│   ├── source.rs       # Import depuis MusicSource
+│   ├── local.rs        # Import fichiers locaux (via pmometadata)
+│   └── playlist.rs     # Import JSPF/M3U8 (via pmojspf)
+│
+├── export/
+│   └── playlist.rs     # Export playlists (JSPF, M3U8)
+│
+├── query/
+│   ├── builder.rs      # SmartPlaylistQuery (DSL)
+│   └── executor.rs     # Génération + exécution SQL
+│
+├── didl/
+│   └── generator.rs    # Conversion items → DIDL-Lite
+│
+├── source.rs           # Impl MusicSource trait
+├── taxonomy.rs         # Taxonomie par défaut + CRUD
+└── config_ext.rs       # Extension pmoconfig
+

Dépendances : - pmosource, +pmoaudiocache, pmocovers, +pmodidl, pmometadata - pmojspf +(import/export playlists) - rusqlite (features: +bundled, serde_json) - uuid, +serde, tokio, async-trait

+
+

3. +pmolocal - Source fichiers locaux (à +créer)

+

But : Scanner des répertoires locaux et exposer les +fichiers audio via MusicSource.

+
pmolocal/
+├── scanner.rs      # Scan récursif de répertoires
+├── watcher.rs      # Hot reload (notify)
+├── source.rs       # Impl MusicSource
+└── config_ext.rs   # Extension pmoconfig
+

Workflow : 1. pmolocal scanne +/home/user/Music 2. pmomusicbox importe les +items découverts 3. Tags automatiques basés sur métadonnées (genre, +année)

+
+

🔄 Flux d’import

+

Import depuis une source +PMO (ex: Qobuz)

+
sequenceDiagram
+    participant QS as Qobuz Source
+    participant MB as MusicBox Importer
+    participant DB as SQLite DB
+    participant AC as pmoaudiocache
+    participant CC as pmocovers
+    
+    QS->>MB: get_item(object_id)
+    MB->>QS: resolve_uri(object_id)
+    
+    Note over MB: 1. Extraire métadonnées DIDL-Lite<br/>2. Générer UUID
+    
+    MB->>DB: INSERT INTO music_items
+    
+    opt Auto-cache activé
+        MB->>AC: Cache audio
+        MB->>CC: Cache cover
+        AC-->>DB: Retourner cache_audio_pk
+        CC-->>DB: Retourner cache_cover_pk
+    end
+    
+    MB-->>QS: item_id (UUID)
+

Import URL directe

+
flowchart LR
+    URL[URL simple] --> META["pmometadata<br/>Extraction"]
+    META --> UUID[Générer UUID]
+    UUID --> DB[("music_items")]
+    DB --> CACHE{"Auto-cache?"}
+    CACHE -->|Oui| AC[pmoaudiocache]
+    CACHE -->|Non| END[Fin]
+    AC --> END
+

Import playlist JSPF/M3U8

+
flowchart LR
+    FILE[Fichier playlist] --> JSPF["pmojspf<br/>Parser"]
+    JSPF --> STRUCT[Structure JSPF]
+    STRUCT --> LOOP{"Pour chaque track"}
+    LOOP --> IMPORT[Import comme URL]
+    IMPORT --> DB[("music_items")]
+    DB --> PLAYLIST[Créer playlist statique]
+    PLAYLIST --> LINK[Lier tracks à playlist]
+
+

🔍 Smart Playlists (Query DSL)

+

Concept

+

Les smart playlists sont des requêtes sauvegardées +qui génèrent dynamiquement une liste de tracks.

+

Structure de requête (JSON)

+
{
+  "include_all_tags": ["mood:energetic", "genre:rock"],
+  "exclude_tags": ["mood:melancholic"],
+  "year_min": 1980,
+  "year_max": 1989,
+  "min_rating": 4,
+  "lossless_only": true,
+  "order_by": "play_count",
+  "order": "desc",
+  "limit": 50
+}
+

Traduction SQL

+
SELECT * FROM music_items
+WHERE id IN (
+    SELECT item_id FROM item_tags WHERE tag_id IN ('mood:energetic', 'genre:rock')
+    GROUP BY item_id HAVING COUNT(DISTINCT tag_id) = 2  -- ALL tags
+)
+AND id NOT IN (
+    SELECT item_id FROM item_tags WHERE tag_id = 'mood:melancholic'
+)
+AND year BETWEEN 1980 AND 1989
+AND rating >= 4
+AND codec IN ('flac', 'alac')
+ORDER BY play_count DESC
+LIMIT 50;
+
+

🎭 Exposition UPnP +(MusicSource)

+

Structure de navigation

+
graph TB
+    ROOT[musicbox/] --> ARTIST[by-artist/]
+    ROOT --> ALBUM[by-album/]
+    ROOT --> GENRE[by-genre/]
+    ROOT --> TAG[by-tag/]
+    ROOT --> PLAYLISTS[playlists/]
+    ROOT --> SMART[smart-playlists/]
+    ROOT --> FAV[favorites/]
+    ROOT --> RECENT[recent/]
+    
+    ARTIST --> PF[Pink Floyd/]
+    ARTIST --> Q[Queen/]
+    PF --> WALL[The Wall/]
+    PF --> WYWH[Wish You Were Here/]
+    WALL --> ITEM1[Another Brick... 🎵]
+    
+    TAG --> MOOD[mood/]
+    TAG --> OCC[occasion/]
+    TAG --> ERA[era/]
+    
+    MOOD --> ENRG[energetic/]
+    MOOD --> CHILL[chill/]
+    ENRG --> ITEMS1[items taggués 🎵]
+    
+    OCC --> WORK[workout/]
+    OCC --> FOCUS[focus/]
+    
+    ERA --> E80[80s/]
+    ERA --> E90[90s/]
+    
+    PLAYLISTS --> PL1[My Favorites/]
+    PLAYLISTS --> PL2[Summer 2024/]
+    
+    SMART --> SP1[80s Rock Workout/]
+    SMART --> SP2[Jazz Dinner/]
+    
+    style ITEM1 fill:#e1f5ff
+    style ITEMS1 fill:#e1f5ff
+

Object IDs

+
musicbox:by-artist:{artist_name}
+musicbox:by-album:{album_id}
+musicbox:by-tag:{category}:{tag_name}
+musicbox:playlist:{playlist_id}
+musicbox:smart:{smart_playlist_id}
+musicbox:item:{item_id}
+
+

🔌 Intégration avec +l’écosystème PMOMusic

+

Avec pmoaudiocache

+
    +
  • Import → Déclencher cache automatique (si +auto_cache: true)
  • +
  • resolve_uri() → Retourner URI cachée si disponible
  • +
+

Avec pmocovers

+
    +
  • Import → Télécharger cover art
  • +
  • Browse → Inclure album_art dans DIDL-Lite
  • +
+

Avec pmoserver (feature +server)

+
    +
  • API REST pour manipulation (CRUD items, tags, playlists)
  • +
  • SSE pour notifications de changements
  • +
  • Endpoints OpenAPI (utoipa)
  • +
+
+

📝 Plan d’implémentation +(Phases)

+

Phase 1 : Fondations

+
    +
  • Schéma SQLite complet
  • +
  • Crate pmojspf (parser playlists)
  • +
  • CRUD basique dans pmomusicbox (items, tags)
  • +
  • Taxonomie par défaut
  • +
  • Import URL simple
  • +
  • Extension pmoconfig
  • +
+

Phase 2 : Import +cross-sources

+
    +
  • Import depuis MusicSource (Qobuz, Paradise)
  • +
  • Import playlists (JSPF/M3U8)
  • +
  • Intégration caches (audio, covers)
  • +
  • Crate pmolocal (fichiers locaux)
  • +
+

Phase 3 : Smart Playlists

+
    +
  • Query builder (DSL)
  • +
  • Exécuteur SQL
  • +
  • CRUD smart playlists
  • +
  • Export JSPF
  • +
+

Phase 4 : MusicSource UPnP

+
    +
  • Implémentation trait MusicSource
  • +
  • Génération DIDL-Lite
  • +
  • Browse multi-axes (artist, album, tag)
  • +
  • Recherche full-text (FTS5)
  • +
+

Phase 5 : Fonctionnalités +avancées

+
    +
  • Statistiques d’écoute (play_count, last_played)
  • +
  • Auto-tagging (genre depuis métadonnées)
  • +
  • API REST (feature server)
  • +
  • Recommandations (items similaires)
  • +
+
+

🎯 Cas d’usage

+

Workflow typique

+
    +
  1. Découverte : Écouter Radio Paradise, tomber sur un +morceau génial
  2. +
  3. Ajout : +musicbox.import_from_source(&paradise, "track-123")
  4. +
  5. Organisation : Ajouter tags +mood:chill, occasion:focus
  6. +
  7. Playlist : Smart playlist “Focus Music” avec +requête mood:chill + occasion:focus
  8. +
  9. Écoute : Naviguer dans UPnP → +musicbox/smart-playlists/Focus Music/
  10. +
+

Scénario : Bibliothèque mixte

+
    +
  • Albums Qobuz haute résolution
  • +
  • Playlists M3U8 importées depuis iTunes
  • +
  • Fichiers FLAC locaux scannés
  • +
  • URLs de SoundCloud
  • +
  • Tracks Radio Paradise capturés
  • +
+

Tout unifié dans MusicBox, accessible via UPnP, organisé par +tags.

+
+

📚 Références

+

Standards

+ +

Inspirations

+ +
+ + diff --git a/Blackboard_HTML/ToThinkAbout_PlayListSource.html b/Blackboard_HTML/ToThinkAbout_PlayListSource.html new file mode 100644 index 00000000..ab35c3c3 --- /dev/null +++ b/Blackboard_HTML/ToThinkAbout_PlayListSource.html @@ -0,0 +1,539 @@ + + + + + +PlayListSource + + + + + +
+ +

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

+
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)

+
{
+  "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 v0.4.2

+
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<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub creator: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub annotation: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub info: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub location: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub identifier: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub image: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub date: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub license: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub attribution: Vec<JspfAttribution>,
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub meta: Vec<JspfMeta>,
+    #[serde(default)]
+    pub track: Vec<JspfTrack>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct JspfTrack {
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub location: Vec<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub identifier: Vec<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub title: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub creator: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub annotation: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub info: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub image: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub album: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub track_num: Option<u32>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub duration: Option<u64>,  // millisecondes
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub meta: Vec<JspfMeta>,
+}
+
+#[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

+
use std::io::Read;
+
+pub trait PlaylistReader {
+    fn read<R: Read>(reader: R) -> Result<Jspf>;
+    fn from_str(s: &str) -> Result<Jspf>;
+    fn from_file<P: AsRef<Path>>(path: P) -> Result<Jspf>;
+}
+

Implémentations des Readers

+
JspfReader (✅ Simple - +serde_json)
+
pub struct JspfReader;
+
+impl PlaylistReader for JspfReader {
+    fn read<R: Read>(reader: R) -> Result<Jspf> {
+        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 +<playlist>, <track>, etc.

+

Alternative : Utiliser la crate xspf +existante puis convertir → JSPF

+
pub struct XspfReader;
+
+impl PlaylistReader for XspfReader {
+    fn read<R: Read>(reader: R) -> Result<Jspf> {
+        // Parser XML avec EventReader
+        // État : in_playlist, in_track, current_element
+        // Mapping: <title> → playlist.title, <track> → JspfTrack
+    }
+}
+

Dépendances : xml-rs ou réutiliser +xspf crate

+
M3uReader (⚙️ Modéré - ligne +par ligne)
+

Format :

+
#EXTM3U
+#PLAYLIST:Ma Playlist Jazz
+#EXTINF:284,John Coltrane - Giant Steps
+#EXTART:John Coltrane
+#EXTALB:Giant Steps
+file:///music/coltrane.flac
+
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 :

+
[playlist]
+NumberOfEntries=2
+File1=file:///music/coltrane.flac
+Title1=John Coltrane - Giant Steps
+Length1=284
+
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

+
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

+
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

+
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. +
  3. Cache : Éviter re-parsing (playlists + +conteneurs)
  4. +
  5. Hot reload : notify pour détecter +changements
  6. +
  7. Browse UPnP : Générer DIDL-Lite avec métadonnées +conteneurs
  8. +
  9. Content resolution : Résoudre URIs via +SourceCacheManager
  10. +
  11. Cover art : Servir images playlists, tracks, +conteneurs
  12. +
+

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

+
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

+
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 :

+
sources:
+  playlists:
+    enabled: true
+    directory: "playlists"
+    formats:
+      - jspf
+      - xspf
+      - m3u8
+      - pls
+

Utilisation :

+
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 +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 :

    +
    {"identifier": ["musicbrainz://recording/abc123"], "title": null}
    +

    → Récupérer métadonnées depuis MusicBrainz

  2. +
  3. Enrichissement playlists pauvres : M3U8/PLS → +MusicBrainz → métadonnées complètes

  4. +
  5. Cover art : CoverArt Archive

  6. +
+

Configuration

+
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. +
  3. ✅ Modèle données : Structures JSPF
  4. +
  5. ✅ Extension pmoconfig : Trait défini
  6. +
  7. Implémenter pmojspf : +
      +
    • JspfReader (serde_json)
    • +
    • XspfReader (xml-rs ou crate xspf)
    • +
    • M3uReader (parsing ligne par ligne)
    • +
    • PlsReader (format INI)
    • +
    • JspfWriter (serde_json)
    • +
  8. +
  9. Implémenter pmoplaylists : +
      +
    • PlaylistSource (trait MusicSource)
    • +
    • Scan hiérarchique + cache
    • +
    • Hot reload (notify)
    • +
    • Browse UPnP (DIDL-Lite)
    • +
    • Gestion metadata.json
    • +
  10. +
  11. ⏳ Tests avec clients UPnP
  12. +
+
+

📚 Sources

+

Spécifications

+ +

Crates Rust

+ +
+ + diff --git a/Blackboard_HTML/Todo_config_ext.html b/Blackboard_HTML/Todo_config_ext.html new file mode 100644 index 00000000..00498bc0 --- /dev/null +++ b/Blackboard_HTML/Todo_config_ext.html @@ -0,0 +1,55 @@ + + + + + +config_ext + + + + + +
+ +

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_HTML/Todo_music_source.html b/Blackboard_HTML/Todo_music_source.html new file mode 100644 index 00000000..f31fc001 --- /dev/null +++ b/Blackboard_HTML/Todo_music_source.html @@ -0,0 +1,52 @@ + + + + + +music_source + + + + + +
+ +

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.

+
+ + diff --git a/Blackboard_HTML/index.html b/Blackboard_HTML/index.html new file mode 100644 index 00000000..d5e80a9e --- /dev/null +++ b/Blackboard_HTML/index.html @@ -0,0 +1,40 @@ + + +PMOMusic Blackboard + +

📋 PMOMusic Blackboard

+ + + + + + + diff --git a/Makefile b/Makefile index 3aaf0570..21cd720a 100644 --- a/Makefile +++ b/Makefile @@ -248,3 +248,48 @@ jjfetch: @jj git fetch @jj new main@origin @echo "$(GREEN)✓ Derniers commits pullés$(NC)" + +## blackboard-html: Génère les fichiers HTML du Blackboard avec support Mermaid +blackboard-html: + @echo "$(YELLOW)→ Génération des fichiers HTML du Blackboard...$(NC)" + @mkdir -p Blackboard_HTML + @echo "" > Blackboard_HTML/index.html + @echo '' >> Blackboard_HTML/index.html + @echo "PMOMusic Blackboard" >> Blackboard_HTML/index.html + @echo "" >> Blackboard_HTML/index.html + @echo '

📋 PMOMusic Blackboard

' >> Blackboard_HTML/index.html + @for category in Architecture ToThinkAbout ToDiscuss Todo Done Report; do \ + if [ -d "Blackboard/$$category" ]; then \ + echo "

$$category

    " >> Blackboard_HTML/index.html; \ + find "Blackboard/$$category" -name "*.md" -type f | sort | while read -r file; do \ + basename=$$(basename "$$file" .md); \ + relpath=$$(echo "$$file" | sed 's|Blackboard/||'); \ + htmlfile=$$(echo "$$relpath" | sed 's|/|_|g' | sed 's|\.md$$|.html|'); \ + echo "
  • $$basename
  • " >> Blackboard_HTML/index.html; \ + echo " → Conversion: $$relpath → $$htmlfile"; \ + /opt/homebrew/bin/pandoc "$$file" -o "Blackboard_HTML/$$htmlfile" \ + --standalone \ + --template=blackboard-template.html \ + --metadata title="$$basename" \ + --from markdown \ + --to html; \ + ./fix-mermaid.sh "Blackboard_HTML/$$htmlfile"; \ + done; \ + echo "
" >> Blackboard_HTML/index.html; \ + fi; \ + done + @echo "" >> Blackboard_HTML/index.html + @echo "$(GREEN)✓ Fichiers HTML générés dans Blackboard_HTML/$(NC)" + @echo "$(BLUE) Ouvrir: open Blackboard_HTML/index.html$(NC)" + +## blackboard-clean: Nettoie les fichiers HTML générés +blackboard-clean: + @echo "$(YELLOW)→ Nettoyage des fichiers HTML du Blackboard...$(NC)" + @rm -rf Blackboard_HTML + @echo "$(GREEN)✓ Fichiers HTML supprimés$(NC)" diff --git a/blackboard-template.html b/blackboard-template.html new file mode 100644 index 00000000..bed4f4ac --- /dev/null +++ b/blackboard-template.html @@ -0,0 +1,38 @@ + + + + + +$title$ + + + + + + + + diff --git a/fix-mermaid.sh b/fix-mermaid.sh new file mode 100755 index 00000000..fadee52f --- /dev/null +++ b/fix-mermaid.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Fix Mermaid blocks in HTML generated by Pandoc +# Pandoc wraps mermaid code in
...
+# But Mermaid.js needs
...
without the tags + +sed -i '' 's|
|
|g' "$1"
+sed -i '' 's|
|
|g' "$1" diff --git a/test-mermaid.md b/test-mermaid.md new file mode 100644 index 00000000..747db36d --- /dev/null +++ b/test-mermaid.md @@ -0,0 +1,15 @@ +# Test Mermaid + +```mermaid +flowchart TB + A[Start] --> B[End] +``` + +```mermaid +erDiagram + CUSTOMER ||--o{ ORDER : places + CUSTOMER { + string name + string id + } +``` diff --git a/test-output.html b/test-output.html new file mode 100644 index 00000000..af95e962 --- /dev/null +++ b/test-output.html @@ -0,0 +1,46 @@ + + + + + +Test + + + + + +
+ +

Test Mermaid

+
flowchart TB
+    A[Start] --> B[End]
+
erDiagram
+    CUSTOMER ||--o{ ORDER : places
+    CUSTOMER {
+        string name
+        string id
+    }
+
+ +