Documentation complète des patterns d'extension pmoconfig, pmoserver_ext et implémentation MusicSource
Ajout de la documentation complète pour les patterns d'extension pmoconfig, pmoserver_ext et l'implémentation d'une nouvelle MusicSource, incluant les guides détaillés, exemples de code et checklists d'implémentation.
This commit is contained in:
921
Blackboard/Architecture/music_source.md
Normal file
921
Blackboard/Architecture/music_source.md
Normal file
@@ -0,0 +1,921 @@
|
||||
# Guide d'implémentation d'une nouvelle MusicSource
|
||||
|
||||
Ce document décrit comment implémenter une nouvelle source musicale dans l'écosystème PMOMusic en suivant le trait `MusicSource` défini dans le crate `pmosource`.
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Vue d'ensemble](#vue-densemble)
|
||||
2. [Structure d'une MusicSource](#structure-dune-musicsource)
|
||||
3. [Implémentation du trait MusicSource](#implémentation-du-trait-musicsource)
|
||||
4. [Patterns d'implémentation](#patterns-dimplémentation)
|
||||
5. [Intégration avec l'écosystème PMOMusic](#intégration-avec-lécosystème-pmomusic)
|
||||
6. [Checklist de mise en œuvre](#checklist-de-mise-en-œuvre)
|
||||
7. [Exemples de référence](#exemples-de-référence)
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Une `MusicSource` est une abstraction qui représente une source de contenu musical dans PMOMusic. Elle peut être :
|
||||
|
||||
- **Dynamique (FIFO)** : Radio Paradise, streaming radio, playlists live
|
||||
- **Statique** : Albums Qobuz, bibliothèque locale, playlists fixes
|
||||
|
||||
Le trait `MusicSource` définit une interface unifiée pour :
|
||||
- La navigation UPnP ContentDirectory (browse)
|
||||
- La résolution d'URI audio (avec cache)
|
||||
- La gestion de playlists FIFO (pour les sources dynamiques)
|
||||
- Le suivi des changements (update_id, last_change)
|
||||
|
||||
## Structure d'une MusicSource
|
||||
|
||||
### Organisation du code
|
||||
|
||||
```
|
||||
pmo<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
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pmosource = { path = "../pmosource" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
pmoplaylist = { path = "../pmoplaylist", optional = true } # Si FIFO
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true } # Si cache
|
||||
pmocovers = { path = "../pmocovers", optional = true } # Si cache
|
||||
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
default = ["cache"]
|
||||
cache = ["pmoaudiocache", "pmocovers"]
|
||||
playlist = ["pmoplaylist"]
|
||||
```
|
||||
|
||||
## Implémentation du trait MusicSource
|
||||
|
||||
### 1. Informations de base
|
||||
|
||||
Chaque source doit fournir :
|
||||
|
||||
```rust
|
||||
use pmosource::{async_trait, MusicSource};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MyMusicSource {
|
||||
// Champs internes
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for MyMusicSource {
|
||||
fn name(&self) -> &str {
|
||||
"Ma Source Musicale" // Nom affiché dans l'UI
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"my-music-source" // ID unique (format: lowercase-kebab-case)
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
// Logo WebP 300x300px inclus dans le binaire
|
||||
include_bytes!("../assets/default.webp")
|
||||
}
|
||||
|
||||
fn default_image_mime_type(&self) -> &str {
|
||||
"image/webp" // Toujours WebP
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Règles :**
|
||||
- `id()` doit être unique parmi toutes les sources
|
||||
- `id()` doit être en lowercase-kebab-case
|
||||
- `default_image()` doit être un WebP 300x300px
|
||||
|
||||
### 2. Navigation ContentDirectory
|
||||
|
||||
#### 2.1 Container racine
|
||||
|
||||
```rust
|
||||
async fn root_container(&self) -> Result<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 :
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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) :
|
||||
|
||||
```rust
|
||||
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) :
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl MusicSource for CatalogSource {
|
||||
fn supports_fifo(&self) -> bool {
|
||||
false // Pas de FIFO
|
||||
}
|
||||
|
||||
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||
Err(MusicSourceError::NotSupported(
|
||||
"This source is read-only".to_string()
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_oldest(&self) -> Result<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 :**
|
||||
|
||||
```rust
|
||||
#[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 :**
|
||||
|
||||
```rust
|
||||
#[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 :
|
||||
|
||||
```rust
|
||||
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 :
|
||||
|
||||
```rust
|
||||
use pmoplaylist::{PlaylistManager, PlaylistRole};
|
||||
|
||||
// Créer une playlist persistante
|
||||
let manager = PlaylistManager();
|
||||
let writer = manager
|
||||
.create_persistent_playlist_with_role(
|
||||
"my-source-album-123".to_string(),
|
||||
PlaylistRole::Album,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Configurer metadata
|
||||
writer.set_title("Album Title".to_string()).await?;
|
||||
writer.set_artist(Some("Artist Name".to_string())).await?;
|
||||
writer.set_cover_pk(Some("cover-pk".to_string())).await?;
|
||||
|
||||
// Ajouter tracks avec cache lazy
|
||||
writer.push_lazy_batch(vec!["pk1", "pk2", "pk3"]).await?;
|
||||
|
||||
// Activer mode lazy (lookahead 2 tracks)
|
||||
manager.enable_lazy_mode("my-source-album-123", 2);
|
||||
```
|
||||
|
||||
### Avec pmoaudiocache et pmocovers (via SourceCacheManager)
|
||||
|
||||
```rust
|
||||
use pmosource::SourceCacheManager;
|
||||
|
||||
// Créer le manager centralisé
|
||||
let cache_manager = SourceCacheManager::from_registry("my-source".to_string())?;
|
||||
|
||||
// Enregistrer un LazyProvider
|
||||
cache_manager.register_lazy_provider(Arc::new(MyLazyProvider::new(client)));
|
||||
|
||||
// Cache eager (cover)
|
||||
let cover_pk = cache_manager.cache_cover("https://example.com/cover.jpg").await?;
|
||||
|
||||
// Cache lazy (audio)
|
||||
let audio_pk = cache_manager
|
||||
.cache_audio_lazy_with_provider(
|
||||
"MY-SOURCE:123", // Lazy PK
|
||||
Some(metadata),
|
||||
Some(cover_pk),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Récupérer metadata
|
||||
let value = cache_manager.get_audio_metadata(&audio_pk, "key").await?;
|
||||
```
|
||||
|
||||
**LazyProvider personnalisé :**
|
||||
|
||||
```rust
|
||||
use pmoaudiocache::{LazyProvider, LazyProviderError};
|
||||
|
||||
pub struct MyLazyProvider {
|
||||
client: Arc<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 :
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
- [ ] Créer le crate `pmo<votre-source>`
|
||||
- [ ] Ajouter les dépendances dans `Cargo.toml`
|
||||
- [ ] Créer le logo WebP 300x300px dans `assets/`
|
||||
- [ ] Définir la structure principale
|
||||
- [ ] Implémenter `name()`, `id()`, `default_image()`
|
||||
|
||||
### Phase 2 : Navigation ContentDirectory
|
||||
|
||||
- [ ] Définir le schéma d'Object ID
|
||||
- [ ] Implémenter `root_container()`
|
||||
- [ ] Implémenter `browse()` pour la racine
|
||||
- [ ] Implémenter `browse()` pour les sous-containers
|
||||
- [ ] Implémenter `browse()` pour les items
|
||||
- [ ] Tester la navigation avec un client UPnP
|
||||
|
||||
### Phase 3 : Résolution d'URI
|
||||
|
||||
- [ ] Implémenter `resolve_uri()` avec fallback
|
||||
- [ ] Intégrer avec `SourceCacheManager`
|
||||
- [ ] Implémenter `LazyProvider` si cache lazy
|
||||
- [ ] Tester la lecture audio
|
||||
|
||||
### Phase 4 : Support FIFO (si dynamique)
|
||||
|
||||
- [ ] Décider de la stratégie FIFO
|
||||
- [ ] Implémenter `supports_fifo() = true`
|
||||
- [ ] Implémenter `append_track()`
|
||||
- [ ] Implémenter `remove_oldest()`
|
||||
- [ ] Implémenter `update_id()` et `last_change()`
|
||||
- [ ] Enregistrer callbacks sur playlists
|
||||
- [ ] Tester ajout/suppression de tracks
|
||||
|
||||
### Phase 5 : Support statique (si catalogue)
|
||||
|
||||
- [ ] Implémenter `supports_fifo() = false`
|
||||
- [ ] Implémenter `get_items()` avec pagination
|
||||
- [ ] Implémenter `search()` si applicable
|
||||
- [ ] Tester browsing du catalogue
|
||||
|
||||
### Phase 6 : Intégration avancée
|
||||
|
||||
- [ ] Implémenter `get_item()` pour metadata
|
||||
- [ ] Implémenter `capabilities()`
|
||||
- [ ] Implémenter `get_available_formats()`
|
||||
- [ ] Ajouter gestion d'erreurs robuste
|
||||
- [ ] Documenter le code
|
||||
|
||||
### Phase 7 : Tests et validation
|
||||
|
||||
- [ ] Écrire tests unitaires
|
||||
- [ ] Écrire tests d'intégration
|
||||
- [ ] Tester avec différents clients UPnP
|
||||
- [ ] Valider les performances
|
||||
- [ ] Documenter les limitations
|
||||
|
||||
## Exemples de référence
|
||||
|
||||
### Radio Paradise (source dynamique FIFO)
|
||||
|
||||
**Fichier :** `pmoparadise/src/source.rs`
|
||||
|
||||
**Points d'intérêt :**
|
||||
- Structure avec `Arc<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. **Pagination** : Limitez le nombre d'items retournés (max 100)
|
||||
3. **Lazy loading** : Ne chargez que ce qui est demandé
|
||||
4. **Rate limiting** : Respectez les limites API de la source
|
||||
5. **Arc<>** : Partagez les données coûteuses
|
||||
|
||||
### Compatibilité UPnP
|
||||
|
||||
1. **Genre obligatoire** : Certains clients (gupnp-av-cp) requièrent `<upnp:genre>`
|
||||
2. **URLs absolues** : Toujours retourner des URLs complètes (pas de chemins relatifs)
|
||||
3. **Protocol Info** : Utilisez `http-get:*:audio/flac:*` pour FLAC
|
||||
4. **Duration** : Format `H:MM:SS` (ex: `0:03:45`)
|
||||
5. **childCount** : Optionnel mais recommandé pour l'UI
|
||||
|
||||
### Gestion d'erreurs
|
||||
|
||||
1. **ObjectNotFound** : ID invalide
|
||||
2. **BrowseError** : Erreur générique de navigation
|
||||
3. **UriResolutionError** : Impossible de résoudre l'URI
|
||||
4. **PlaylistError** : Erreur d'interaction avec pmoplaylist
|
||||
5. **CacheError** : Erreur de cache
|
||||
|
||||
### Thread Safety
|
||||
|
||||
1. **Arc<RwLock<>>** : Pour l'état mutable partagé
|
||||
2. **tokio::sync::RwLock** : Pour l'async
|
||||
3. **Éviter Rc<>** : Pas thread-safe
|
||||
4. **Clone** : Implémentez `Clone` pour `Arc<>`
|
||||
|
||||
## Conclusion
|
||||
|
||||
L'implémentation d'une nouvelle `MusicSource` suit ces étapes :
|
||||
|
||||
1. **Définir le schéma d'Object ID** : Hiérarchie claire et cohérente
|
||||
2. **Implémenter la navigation** : `browse()` pour tous les niveaux
|
||||
3. **Résoudre les URIs** : Cache local d'abord, puis original
|
||||
4. **Gérer le cache** : `SourceCacheManager` + `LazyProvider`
|
||||
5. **Adapter les IDs** : Playlist → Schema de la source
|
||||
6. **Notifier les changements** : `update_id` + callbacks
|
||||
|
||||
Les exemples Radio Paradise et Qobuz couvrent les deux patterns principaux :
|
||||
- **Dynamique FIFO** : Radio Paradise
|
||||
- **Catalogue lazy** : Qobuz
|
||||
|
||||
En suivant ces patterns, vous obtiendrez une source musicale performante, compatible UPnP, et bien intégrée dans l'écosystème PMOMusic.
|
||||
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
96
Blackboard/Report/config_ext.md
Normal file
96
Blackboard/Report/config_ext.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Rapport : Documentation du pattern d'extension pmoconfig
|
||||
|
||||
## Objectif de la tâche
|
||||
|
||||
Créer une fiche descriptive documentant le pattern d'implémentation des traits d'extension de `pmoconfig::Config` en analysant les implémentations existantes dans les différents crates du projet.
|
||||
|
||||
## Travail réalisé
|
||||
|
||||
### 1. Analyse des fichiers source
|
||||
|
||||
Les fichiers suivants ont été analysés :
|
||||
|
||||
- `pmocovers/src/config_ext.rs` - Pattern cache avec conversion WebP
|
||||
- `pmoaudiocache/src/config_ext.rs` - Pattern cache avec conversion FLAC
|
||||
- `pmoqobuz/src/config_ext.rs` - Pattern authentification et rate limiting
|
||||
- `pmocache/src/config_ext.rs` - Trait générique de cache et macro
|
||||
- `pmoconfig/PASSWORD_ENCRYPTION.md` - Documentation du chiffrement
|
||||
- `pmoupnp/src/config_ext.rs` - Pattern configuration UPnP
|
||||
- `pmoparadise/src/config_ext.rs` - Pattern configuration minimale
|
||||
|
||||
### 2. Patterns identifiés
|
||||
|
||||
#### Pattern de base
|
||||
Tous les traits d'extension suivent la même structure :
|
||||
- Trait public avec méthodes getter/setter
|
||||
- Implémentation pour `pmoconfig::Config`
|
||||
- Utilisation de `get_value`/`set_value` génériques
|
||||
- Constantes pour valeurs par défaut
|
||||
|
||||
#### Patterns spécialisés
|
||||
- **Cache** : Utilisation de `CacheConfigExt` et factory methods
|
||||
- **Authentification** : Getters combinés, helpers de validation, déchiffrement automatique
|
||||
- **Rate limiting** : Configuration des limites avec valeurs par défaut
|
||||
- **Configuration minimale** : Auto-persistence des valeurs par défaut
|
||||
- **UPnP** : Configuration des identifiants devices
|
||||
|
||||
### 3. Structure de la documentation
|
||||
|
||||
La documentation créée couvre :
|
||||
|
||||
1. **Vue d'ensemble** : Objectif et principe du pattern
|
||||
2. **Architecture** : Structure et flux de données
|
||||
3. **Implémentation** : Guide détaillé avec patterns de code
|
||||
4. **Patterns spécialisés** : Exemples pour chaque cas d'usage
|
||||
5. **Bonnes pratiques** : Nommage, erreurs, documentation
|
||||
6. **Exemples complets** : 3 implémentations complètes commentées
|
||||
7. **Checklist** : Liste de vérification pour nouveaux traits
|
||||
8. **Philosophie** : Principes directeurs et avantages
|
||||
|
||||
### 4. Contenu clé
|
||||
|
||||
#### Patterns de getters
|
||||
- Getter simple avec valeur par défaut
|
||||
- Getter avec auto-persistence
|
||||
- Getter optionnel
|
||||
- Getter avec déchiffrement
|
||||
- Getter avec parsing et fallback
|
||||
|
||||
#### Patterns de setters
|
||||
- Setter simple
|
||||
- Setter avec transformation
|
||||
- Setter multiple (transaction)
|
||||
- Setter de nettoyage
|
||||
|
||||
#### Helpers
|
||||
- Factory methods
|
||||
- Getters combinés
|
||||
- Helpers de validation
|
||||
|
||||
### 5. Hiérarchie de configuration YAML
|
||||
|
||||
Documentation des chemins standards :
|
||||
- `host.*` : Configuration hôte/système
|
||||
- `accounts.*` : Comptes et services
|
||||
- `sources.*` : Sources de médias
|
||||
|
||||
## Résultat
|
||||
|
||||
Le document `Blackboard/Architecture/pmoconfig_ext.md` a été créé avec :
|
||||
- 800+ lignes de documentation complète
|
||||
- 3 exemples d'implémentation complète
|
||||
- Patterns pour tous les cas d'usage identifiés
|
||||
- Bonnes pratiques et anti-patterns
|
||||
- Checklist d'implémentation
|
||||
|
||||
## Fichiers créés ou modifiés
|
||||
|
||||
- **Créé** : `Blackboard/Architecture/pmoconfig_ext.md` - Documentation complète du pattern
|
||||
- **Créé** : `Blackboard/Report/config_ext.md` - Ce rapport
|
||||
|
||||
## Conformité avec Rules.md
|
||||
|
||||
- Documentation placée dans `Blackboard/Architecture/` comme demandé
|
||||
- Rapport créé dans `Blackboard/Report/` avec le même nom de fichier
|
||||
- Analyse focalisée sur l'objectif principal
|
||||
- Documentation prête pour classification (Done/ToDiscuss) par l'humain
|
||||
227
Blackboard/Report/music_source.md
Normal file
227
Blackboard/Report/music_source.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Rapport : Documentation d'implémentation d'une nouvelle MusicSource
|
||||
|
||||
## Objectif
|
||||
|
||||
Créer une documentation complète et pratique pour guider l'implémentation d'une nouvelle source musicale dans l'écosystème PMOMusic.
|
||||
|
||||
## Travail réalisé
|
||||
|
||||
### 1. Analyse des sources existantes
|
||||
|
||||
J'ai analysé deux implémentations de référence :
|
||||
|
||||
- **pmoparadise/src/source.rs** : Source dynamique avec FIFO (radio streaming)
|
||||
- **pmoqobuz/src/source.rs** : Source catalogue avec playlists lazy
|
||||
|
||||
Ainsi que la documentation du trait :
|
||||
|
||||
- **pmosource/README.md** : Vue d'ensemble du trait MusicSource
|
||||
- **pmosource/ARCHITECTURE.md** : Architecture et design decisions
|
||||
|
||||
### 2. Identification des patterns principaux
|
||||
|
||||
Deux patterns majeurs ont été identifiés :
|
||||
|
||||
#### Pattern 1 : Source dynamique FIFO (Radio Paradise)
|
||||
|
||||
**Caractéristiques :**
|
||||
- Flux continu de tracks avec capacité limitée
|
||||
- Suppression automatique des plus anciens
|
||||
- Callbacks sur playlists pour détecter les changements
|
||||
- Notification du ContentDirectory via notifier injecté
|
||||
- Adaptation des IDs playlist → schema source
|
||||
|
||||
**Éléments clés :**
|
||||
```rust
|
||||
update_counter: Arc<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 :**
|
||||
```rust
|
||||
SourceCacheManager centralisé
|
||||
QobuzLazyProvider implémentant LazyProvider
|
||||
Playlists avec rôle Album et TTL de 7 jours
|
||||
Adaptation IDs avec metadata source_track_id
|
||||
```
|
||||
|
||||
### 3. Structure du document créé
|
||||
|
||||
Le document `Blackboard/Architecture/music_source.md` contient :
|
||||
|
||||
#### Table des matières
|
||||
1. Vue d'ensemble
|
||||
2. Structure d'une MusicSource
|
||||
3. Implémentation du trait MusicSource
|
||||
4. Patterns d'implémentation
|
||||
5. Intégration avec l'écosystème PMOMusic
|
||||
6. Checklist de mise en œuvre
|
||||
7. Exemples de référence
|
||||
|
||||
#### Sections détaillées
|
||||
|
||||
**Section 1 : Vue d'ensemble**
|
||||
- Définition d'une MusicSource
|
||||
- Types de sources (dynamique vs statique)
|
||||
- Capacités du trait
|
||||
|
||||
**Section 2 : Structure**
|
||||
- Organisation du code
|
||||
- Dépendances recommandées
|
||||
- Features Cargo
|
||||
|
||||
**Section 3 : Implémentation du trait**
|
||||
- Informations de base (name, id, default_image)
|
||||
- Navigation ContentDirectory (root_container, browse, resolve_uri)
|
||||
- Support FIFO (append_track, remove_oldest, update_id)
|
||||
- Support statique (get_items, search)
|
||||
|
||||
**Section 4 : Patterns**
|
||||
- Pattern 1 : Source dynamique avec FIFO (code complet)
|
||||
- Pattern 2 : Source catalogue avec playlists lazy (code complet)
|
||||
- Pattern 3 : Adaptation des IDs entre playlist et source
|
||||
|
||||
**Section 5 : Intégration écosystème**
|
||||
- pmoplaylist : création et gestion de playlists
|
||||
- pmoaudiocache/pmocovers via SourceCacheManager
|
||||
- pmodidl : conversion vers DIDL-Lite
|
||||
- LazyProvider personnalisé
|
||||
|
||||
**Section 6 : Checklist**
|
||||
- Phase 1 : Structure de base
|
||||
- Phase 2 : Navigation ContentDirectory
|
||||
- Phase 3 : Résolution d'URI
|
||||
- Phase 4 : Support FIFO (si dynamique)
|
||||
- Phase 5 : Support statique (si catalogue)
|
||||
- Phase 6 : Intégration avancée
|
||||
- Phase 7 : Tests et validation
|
||||
|
||||
**Section 7 : Exemples de référence**
|
||||
- Radio Paradise (source dynamique FIFO)
|
||||
- Qobuz (source catalogue lazy)
|
||||
- Schemas d'Object ID détaillés
|
||||
|
||||
### 4. Points techniques importants documentés
|
||||
|
||||
#### Schema d'Object ID
|
||||
|
||||
Format recommandé hiérarchique :
|
||||
```
|
||||
<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. **Navigation** : root_container et browse avec pattern matching
|
||||
3. **Résolution URI** : avec fallback cache → original
|
||||
4. **FIFO** : append_track, remove_oldest, callbacks
|
||||
5. **Adaptation IDs** : fonction complète d'adaptation
|
||||
6. **LazyProvider** : implémentation personnalisée
|
||||
7. **Conversion DIDL** : traits ToDIDLContainer et ToDIDLItem
|
||||
|
||||
## Couverture des besoins
|
||||
|
||||
### Sources couvertes
|
||||
|
||||
- ✅ Radio Paradise : source dynamique FIFO
|
||||
- ✅ Qobuz : source catalogue lazy
|
||||
- ✅ Patterns génériques applicables à d'autres sources
|
||||
|
||||
### Cas d'usage couverts
|
||||
|
||||
- ✅ Source radio/streaming live
|
||||
- ✅ Source catalogue de streaming (Spotify, Deezer, etc.)
|
||||
- ✅ Source bibliothèque locale
|
||||
- ✅ Source playlists fixes
|
||||
- ✅ Source avec authentification (via client)
|
||||
|
||||
### Intégrations couvertes
|
||||
|
||||
- ✅ pmoplaylist (FIFO et persistant)
|
||||
- ✅ pmoaudiocache (cache audio)
|
||||
- ✅ pmocovers (cache covers)
|
||||
- ✅ SourceCacheManager (centralisé)
|
||||
- ✅ LazyProvider (téléchargement lazy)
|
||||
- ✅ pmodidl (DIDL-Lite)
|
||||
|
||||
## Limitations et améliorations futures
|
||||
|
||||
### Limitations actuelles
|
||||
|
||||
1. **Search** : Pas d'exemple détaillé de search (optionnel dans le trait)
|
||||
2. **Authentification** : Mentionné mais pas d'exemple complet
|
||||
3. **Multi-format** : Pas d'exemple de source supportant plusieurs formats
|
||||
4. **Offline** : Pas de pattern pour source offline/synchronisation
|
||||
|
||||
### Améliorations possibles
|
||||
|
||||
1. Ajouter un exemple complet de search avec filtres
|
||||
2. Documenter l'intégration avec un système d'auth OAuth
|
||||
3. Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)
|
||||
4. Documenter la gestion offline avec synchronisation
|
||||
|
||||
## Fichiers créés
|
||||
|
||||
- `Blackboard/Architecture/music_source.md` : Documentation complète (15 sections, ~800 lignes)
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le document créé fournit un guide complet et pratique pour implémenter une nouvelle MusicSource. Il combine :
|
||||
|
||||
- **Théorie** : Architecture, design patterns, principes
|
||||
- **Pratique** : Code complet, exemples réels, checklist
|
||||
- **Référence** : Schemas d'Object ID, intégrations, compatibilité
|
||||
|
||||
Un développeur peut suivre ce guide étape par étape pour créer une nouvelle source musicale compatible avec l'écosystème PMOMusic, en s'inspirant des patterns éprouvés de Radio Paradise et Qobuz.
|
||||
@@ -1,157 +1,77 @@
|
||||
# Rapport : Documentation du pattern pmoserver_ext
|
||||
|
||||
## Date
|
||||
2026-01-12
|
||||
## Contexte
|
||||
|
||||
## Tâche originale
|
||||
Réaliser une fiche descriptive sur le pattern à suivre pour implémenter un trait d'extension du PMO serveur, en analysant les fichiers :
|
||||
- pmoapp/src/lib.rs
|
||||
- pmocontrol/src/pmoserver_ext.rs
|
||||
- pmoparadise/src/pmoserver_ext.rs
|
||||
- pmoaudiocache/src/lib.rs
|
||||
- pmomediaserver/src/paradise_streaming.rs
|
||||
Documentation du pattern d'extension du PMOServer à travers plusieurs itérations basées sur les retours utilisateur.
|
||||
|
||||
## Travail réalisé
|
||||
|
||||
### 1. Analyse des fichiers sources
|
||||
J'ai analysé les cinq fichiers sources mentionnés pour identifier les patterns récurrents :
|
||||
### Analyse des fichiers sources
|
||||
|
||||
- **pmoapp/src/lib.rs** : Illustre le pattern d'intégration d'une Single Page Application (Vue.js) via RustEmbed
|
||||
- **pmocontrol/src/pmoserver_ext.rs** : Montre une API REST complète avec gestion d'état, timeouts, spawn_blocking pour opérations synchrones
|
||||
- **pmoparadise/src/pmoserver_ext.rs** : Démontre l'intégration d'un client externe avec documentation OpenAPI
|
||||
- **pmoaudiocache/src/lib.rs** : Présente le pattern de cache avec routes de fichiers et registre singleton
|
||||
- **pmomediaserver/src/paradise_streaming.rs** : Illustre une extension complexe avec streaming, gestion de caches multiples et async-trait
|
||||
Les fichiers suivants ont été analysés pour extraire le pattern :
|
||||
|
||||
### 2. Identification des patterns communs
|
||||
- `pmoapp/src/lib.rs` : Pattern SPA avec RustEmbed
|
||||
- `pmocontrol/src/pmoserver_ext.rs` : API REST avec Control Point (1506+ lignes)
|
||||
- `pmoparadise/src/pmoserver_ext.rs` : API REST simple avec client externe
|
||||
- `pmoaudiocache/src/lib.rs` : Extension avec cache et fichiers
|
||||
- `pmomediaserver/src/paradise_streaming.rs` : Extension complexe avec streaming
|
||||
|
||||
#### Architecture de base
|
||||
Tous les exemples suivent une architecture similaire :
|
||||
1. Définition d'un trait d'extension (ex: `AudioCacheExt`, `RadioParadiseExt`)
|
||||
2. Implémentation du trait pour `pmoserver::Server`
|
||||
3. Utilisation de feature gates `#[cfg(feature = "pmoserver")]`
|
||||
### Round 1 : Document initial
|
||||
|
||||
#### Composants récurrents
|
||||
- **Trait d'extension** : Interface publique avec méthodes `init_*` ou `add_*`
|
||||
- **État partagé** : Structure `{Domaine}State` avec `Clone` et `Arc<T>`
|
||||
- **Handlers HTTP** : Fonctions async avec extracteurs Axum
|
||||
- **Documentation OpenAPI** : Annotations `utoipa` pour Swagger
|
||||
- **Router Axum** : Sous-routers réutilisables
|
||||
Premier jet documentant exhaustivement tous les aspects des extensions (~850 lignes).
|
||||
|
||||
#### Patterns avancés identifiés
|
||||
- Utilisation de `spawn_blocking` pour opérations synchrones UPnP
|
||||
- Timeouts systématiques pour opérations réseau
|
||||
- Spawn en arrière-plan pour opérations longues
|
||||
- Registres globaux (singletons) avec `OnceCell`
|
||||
- Intégration SPA avec RustEmbed
|
||||
### Round 2 : Recentrage sur le pattern
|
||||
|
||||
### 3. Rédaction de la documentation
|
||||
**Annotation** : "se recentrer sur le sujet principal"
|
||||
|
||||
Le document créé (`Blackboard/Architecture/pmoserver_ext.md`) contient :
|
||||
**Actions** :
|
||||
- Réduction de ~850 à ~400 lignes
|
||||
- Suppression des digressions (OpenAPI détaillé, handlers spécifiques)
|
||||
- Focus sur l'anatomie du pattern en 5 étapes
|
||||
- Ajout d'une checklist et d'un exemple minimal
|
||||
|
||||
#### Structure principale
|
||||
1. **Vue d'ensemble** : Principe et architecture du pattern
|
||||
2. **Composants du pattern** : 6 composants détaillés avec exemples
|
||||
3. **Pattern avancé** : Utilisation d'async-trait
|
||||
4. **Patterns d'intégration** : Control Point et WebApp
|
||||
5. **Registres globaux** : Pattern singleton avec OnceCell
|
||||
6. **Checklist d'implémentation** : Guide pas à pas
|
||||
7. **Bonnes pratiques** : 5 sections (erreurs, performance, concurrence, documentation, features)
|
||||
8. **Exemples d'utilisation** : 3 exemples concrets
|
||||
**Résultat** : Document focalisé sur l'implémentation du pattern uniquement.
|
||||
|
||||
### Round 3 : Réintégration OpenAPI
|
||||
|
||||
**Annotation** : "Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le."
|
||||
|
||||
**Actions** :
|
||||
- Ajout d'une section complète "Documentation OpenAPI avec utoipa" (~260 lignes)
|
||||
- 5 sous-sections détaillées :
|
||||
1. Configuration de base (dépendances Cargo)
|
||||
2. Définition des schémas avec `#[derive(ToSchema)]`
|
||||
3. Annotation des handlers avec `#[utoipa::path]`
|
||||
4. Création de la structure `#[derive(OpenApi)]`
|
||||
5. Exemple complet extrait de Radio Paradise
|
||||
- Mise à jour de la checklist avec section "Documentation OpenAPI"
|
||||
- Ajout des dépendances `utoipa` et `serde` dans la section références
|
||||
|
||||
**Positionnement** : Section insérée après "Méthodes disponibles du serveur" et avant "Patterns courants", car elle fait partie intégrante de l'implémentation.
|
||||
|
||||
## Structure finale du document
|
||||
|
||||
1. **Vue d'ensemble** : Principe du pattern
|
||||
2. **Anatomie d'une extension** : 5 étapes détaillées
|
||||
3. **Méthodes disponibles du serveur** : API de `pmoserver::Server`
|
||||
4. **Documentation OpenAPI avec utoipa** : Guide complet en 5 étapes ⭐ *Ajouté au Round 3*
|
||||
5. **Patterns courants** : 3 exemples concrets
|
||||
6. **Gestion des opérations longues** : spawn_blocking, timeouts, background tasks
|
||||
7. **Checklist d'implémentation** : Organisée par catégories
|
||||
8. **Exemple complet minimal** : Code fonctionnel
|
||||
9. **Références** : Fichiers sources et dépendances
|
||||
|
||||
#### Points forts de la documentation
|
||||
## Résultat final
|
||||
|
||||
**Exemples de code concrets** : Chaque concept est illustré par des extraits de code réels avec références aux fichiers sources (ex: `pmoaudiocache/src/lib.rs:200-215`).
|
||||
Le document est maintenant :
|
||||
|
||||
**Patterns avancés documentés** :
|
||||
- Gestion des timeouts pour éviter les blocages réseau
|
||||
- Utilisation de `spawn_blocking` pour les opérations synchrones
|
||||
- Spawn en arrière-plan pour retour immédiat à l'utilisateur
|
||||
- Registres singleton pour partage de ressources entre extensions
|
||||
- **Complet** : Couvre tous les aspects essentiels incluant OpenAPI
|
||||
- **Structuré** : Progression logique de la configuration à l'implémentation
|
||||
- **Pratique** : Exemples de code concrets extraits du codebase
|
||||
- **Actionnable** : Checklist détaillée en 4 catégories
|
||||
|
||||
**Checklist pratique** : Liste de 7 sections avec cases à cocher pour guider l'implémentation d'une nouvelle extension.
|
||||
Taille finale : ~660 lignes (avec section OpenAPI complète)
|
||||
|
||||
**Bonnes pratiques** : Section dédiée couvrant la gestion d'erreurs, performance, concurrence, documentation et features Cargo.
|
||||
## Fichiers modifiés
|
||||
|
||||
**Exemples d'utilisation** : Trois scénarios d'utilisation progressive (simple, avec configuration, avec état partagé).
|
||||
|
||||
### 4. Organisation des livrables
|
||||
|
||||
#### Document d'architecture
|
||||
- **Emplacement** : `Blackboard/Architecture/pmoserver_ext.md`
|
||||
- **Taille** : 745 lignes
|
||||
- **Format** : Markdown structuré avec syntaxe code Rust
|
||||
|
||||
#### Rapport de travail
|
||||
- **Emplacement** : `Blackboard/Report/pmoserver_ext.md`
|
||||
- **Contenu** : Ce document
|
||||
|
||||
## Observations techniques
|
||||
|
||||
### Cohérence architecturale
|
||||
Tous les modules analysés suivent une architecture très cohérente :
|
||||
- Même convention de nommage (`{Domaine}Ext`, `{Domaine}State`)
|
||||
- Même structure d'implémentation (trait → implémentation → handlers)
|
||||
- Même gestion des erreurs (`anyhow::Result` pour init, `Result<T, StatusCode>` pour handlers)
|
||||
|
||||
### Patterns de concurrence
|
||||
La codebase fait un excellent usage des primitives Tokio :
|
||||
- `spawn_blocking` pour isoler les opérations synchrones UPnP
|
||||
- `spawn` pour les tâches en arrière-plan (ex: seek_queue_index)
|
||||
- `Arc` plutôt que `Mutex` pour le partage de ressources
|
||||
- Timeouts systématiques pour éviter les blocages
|
||||
|
||||
### Documentation OpenAPI
|
||||
L'utilisation de `utoipa` est systématique et bien structurée :
|
||||
- Annotations `#[utoipa::path(...)]` sur tous les handlers
|
||||
- Schémas `#[derive(ToSchema)]` pour tous les types exposés
|
||||
- Documentation complète avec exemples dans les structures `OpenApi`
|
||||
|
||||
## Qualité du résultat
|
||||
|
||||
### Points forts
|
||||
1. **Exhaustivité** : Tous les aspects du pattern sont couverts
|
||||
2. **Exemples concrets** : Extraits de code réels avec références aux fichiers
|
||||
3. **Praticité** : Checklist et bonnes pratiques directement applicables
|
||||
4. **Pédagogie** : Structure progressive du simple au complexe
|
||||
|
||||
### Ce qui pourrait être amélioré
|
||||
1. **Diagrammes** : Ajout de diagrammes de séquence pour les patterns complexes
|
||||
2. **Tests** : Exemples de tests unitaires pour les handlers
|
||||
3. **Cas d'erreur** : Documentation des cas d'erreur fréquents et leurs solutions
|
||||
4. **Performance** : Benchmarks ou métriques de performance
|
||||
|
||||
## Recommandations
|
||||
|
||||
### Pour l'utilisation de cette documentation
|
||||
1. Utiliser la checklist comme guide lors de l'implémentation d'une nouvelle extension
|
||||
2. Se référer aux exemples de code pour les patterns spécifiques (timeouts, spawn, etc.)
|
||||
3. Consulter les bonnes pratiques avant chaque implémentation
|
||||
|
||||
### Pour l'évolution de la documentation
|
||||
1. Ajouter des exemples de tests à mesure que le projet mûrit
|
||||
2. Documenter les problèmes rencontrés et leurs solutions
|
||||
3. Mettre à jour avec de nouveaux patterns si l'architecture évolue
|
||||
|
||||
### Pour le projet PMOMusic
|
||||
1. Considérer l'extraction de macros pour réduire le boilerplate
|
||||
2. Envisager un générateur de code pour les extensions simples
|
||||
3. Documenter les décisions d'architecture dans ce répertoire
|
||||
|
||||
## Conclusion
|
||||
|
||||
La documentation du pattern `pmoserver_ext` est maintenant disponible dans `Blackboard/Architecture/pmoserver_ext.md`. Elle fournit un guide complet et pratique pour implémenter de nouvelles extensions au serveur PMOMusic en suivant les conventions établies.
|
||||
|
||||
Le pattern identifié est solide, cohérent et bien adapté aux besoins du projet. La documentation créée devrait permettre à tout développeur de comprendre et d'appliquer ce pattern efficacement.
|
||||
|
||||
## Fichiers créés
|
||||
|
||||
1. `Blackboard/Architecture/pmoserver_ext.md` (745 lignes) - Documentation technique complète
|
||||
2. `Blackboard/Report/pmoserver_ext.md` (ce fichier) - Rapport de travail
|
||||
|
||||
## Prochaines étapes suggérées
|
||||
|
||||
1. Révision du document par l'humain
|
||||
2. Décision de classement : `Done` ou `ToDiscuss`
|
||||
3. Si `Done` : Synthèse finale pour archivage
|
||||
4. Si `ToDiscuss` : Annotations et travail supplémentaire
|
||||
- `Blackboard/Architecture/pmoserver_ext.md` : Document complet avec OpenAPI (660 lignes)
|
||||
|
||||
@@ -36,7 +36,7 @@ Blackboard
|
||||
|
||||
- `ToThinkAbout`: Contient les tâches à réfléchir pour le projet.
|
||||
|
||||
Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des idées et des questions à réfléchir pour le projet. Ils sont utilisés à terme pour générer les documents de tâche qui seront placés dans le répertoire `Todo`.
|
||||
Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des idées et des questions à réfléchir pour le projet. Ils sont utilisés à terme pour générer les documents de tâche qui seront placés dans le répertoire `Todo`. Les fichiers de ce répertoire sont au format Markdown et sont écrits en collaboration entre l'humain et le LLM. Les deux ont le droit de modifier les fichiers.
|
||||
|
||||
### Les quatres répertoires de base pour le workflow de développement.
|
||||
- `Todo`: Contient les tâches à faire pour le projet.
|
||||
@@ -44,7 +44,9 @@ Les documents dans le répertoire `ToThinkAbout` sont des fichiers contenant des
|
||||
- `ToDiscuss`: Contient les tâches à discuter pour le projet.
|
||||
- `Done`: Contient les tâches terminées pour le projet.
|
||||
|
||||
Les tâches à faire sont décrites dans des fichiers présents dans le répertoire `Todo`. Leur réalisation conduit à la rédaction d'un rapport à placer dans le répertoire `Report`. Le rapport d'une tâche doit avoir le même nom de fichier que la tâche originale. À la suite du rapport, deux issues sont possibles. Soit la tâche est considérée comme achevée. Dans ce cas, elle est déplacée dans le répertoire `Done`. Soit la tâche est considérée comme incomplète. Dans ce cas, elle est déplacée dans le répertoire `ToDiscuss`.
|
||||
Les tâches à faire sont décrites dans des fichiers présents dans le répertoire `Todo`. Leur réalisation conduit à la rédaction d'un rapport à placer dans le répertoire `Report`. Le rapport d'une tâche doit avoir le même nom de fichier que la tâche originale. Aucun autre rapport détaillé ne devra être produit à la fin de la tache dans le fil de la discussion. Juste une liste des documents créés ou midifiés sera donné.
|
||||
|
||||
À la suite du rapport, deux issues sont possibles. Soit la tâche est considérée comme achevée. Dans ce cas, elle est déplacée dans le répertoire `Done`. Soit la tâche est considérée comme incomplète. Dans ce cas, elle est déplacée dans le répertoire `ToDiscuss`.
|
||||
|
||||
C'est l'humain qui décide quand une tâche peut être considérée comme *done* ou *to discuss*. En aucun cas, l'assistant peut décider de classifier une tâche après la rédaction du rapport.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
Partir des fichiers suivants:
|
||||
|
||||
- pmoapp/src/lib.rs
|
||||
- pmocontrol/src/pmoserver_ext.rs
|
||||
- pmoparadise/src/pmoserver_ext.rs
|
||||
- pmoaudiocache/src/lib.rs
|
||||
- pmomediaserver/src/paradise_streaming.rs
|
||||
|
||||
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension du PMO serveur.
|
||||
|
||||
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoserver_ext.md`
|
||||
|
||||
## Round 2
|
||||
|
||||
J'ai regardé ton document généré et je trouve que tu t'élargis du sujet central documenter lecture d'une extension PMOserver. Peux-tu te recentrer sur le sujet principal.
|
||||
|
||||
## Round 3
|
||||
|
||||
Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le.
|
||||
|
||||
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
@@ -0,0 +1,570 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
# PlaylistSource : MusicSource pour playlists
|
||||
|
||||
Implémenter une source PMOMusic capable de servir un catalogue de playlists hiérarchisé via UPnP.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Décisions de conception
|
||||
|
||||
### Format pivot : JSPF (JSON)
|
||||
|
||||
**Choix** : JSPF comme format interne central
|
||||
- Métadonnées riches (title, creator, album, annotation, image, duration, etc.)
|
||||
- JSON natif avec serde (Rust-friendly)
|
||||
- Standard ouvert (Xiph.Org)
|
||||
- Extensible via champ `meta`
|
||||
|
||||
**Formats supportés** :
|
||||
- ✅ **JSPF** (.jspf) - JSON, format natif
|
||||
- ✅ **XSPF** (.xspf) - XML, conversion vers JSPF
|
||||
- ✅ **M3U8** (.m3u8) - Texte, métadonnées limitées
|
||||
- ✅ **PLS** (.pls) - INI-like, très basique
|
||||
|
||||
**Architecture** : 1 Writer (JSPF) + 4 Readers (JSPF, XSPF, M3U8, PLS) → Structure JSPF centrale
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
JSPF[JSPF JSON] --> JR[JspfReader]
|
||||
XSPF[XSPF XML] --> XR[XspfReader]
|
||||
M3U8[M3U8 Text] --> MR[M3uReader]
|
||||
PLS[PLS INI] --> PR[PlsReader]
|
||||
|
||||
JR --> CORE[JSPF Structure]
|
||||
XR --> CORE
|
||||
MR --> CORE
|
||||
PR --> CORE
|
||||
|
||||
CORE --> W[JspfWriter]
|
||||
W --> OUT[.jspf]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Structure du répertoire
|
||||
|
||||
```
|
||||
playlists/
|
||||
├── metadata.json # Métadonnées du conteneur racine
|
||||
├── Jazz/
|
||||
│ ├── metadata.json # Métadonnées catégorie Jazz
|
||||
│ ├── standards.jspf
|
||||
│ ├── bebop.jspf
|
||||
│ └── covers/
|
||||
│ └── standards.webp
|
||||
├── Classical/
|
||||
│ ├── metadata.json
|
||||
│ ├── baroque.jspf
|
||||
│ └── romantic.jspf
|
||||
└── Rock/
|
||||
├── metadata.json
|
||||
└── 70s.jspf
|
||||
```
|
||||
|
||||
### Fichier `metadata.json` (conteneur)
|
||||
|
||||
```json
|
||||
{
|
||||
"container": {
|
||||
"title": "Collection Jazz",
|
||||
"description": "Mes playlists jazz favorites",
|
||||
"creator": "John Doe",
|
||||
"image": "covers/jazz-collection.webp",
|
||||
"date": "2026-01-15",
|
||||
"meta": [
|
||||
{"rel": "genre", "content": "Jazz"},
|
||||
{"rel": "mood", "content": "Relaxing"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Composants à implémenter
|
||||
|
||||
### 1. Crate `pmojspf` (parsing playlists)
|
||||
|
||||
**Responsabilité** : Parser différents formats de playlist vers structure JSPF unifiée
|
||||
|
||||
#### Structure
|
||||
|
||||
```
|
||||
pmojspf/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs # API publique
|
||||
│ ├── model.rs # Structures JSPF
|
||||
│ ├── writer.rs # JspfWriter
|
||||
│ ├── reader/
|
||||
│ │ ├── mod.rs # Trait PlaylistReader
|
||||
│ │ ├── jspf.rs # Reader JSON natif (serde_json)
|
||||
│ │ ├── xspf.rs # Reader XML (xml-rs)
|
||||
│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne)
|
||||
│ │ └── pls.rs # Reader PLS (format INI-like)
|
||||
│ └── error.rs
|
||||
└── tests/
|
||||
└── fixtures/
|
||||
```
|
||||
|
||||
#### Modèle de données
|
||||
|
||||
**Inspiré de la crate [xspf](https://crates.io/crates/xspf) v0.4.2**
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Jspf {
|
||||
pub playlist: JspfPlaylist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JspfPlaylist {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<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
|
||||
|
||||
```rust
|
||||
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)
|
||||
|
||||
```rust
|
||||
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
|
||||
|
||||
```rust
|
||||
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** :
|
||||
```m3u
|
||||
#EXTM3U
|
||||
#PLAYLIST:Ma Playlist Jazz
|
||||
#EXTINF:284,John Coltrane - Giant Steps
|
||||
#EXTART:John Coltrane
|
||||
#EXTALB:Giant Steps
|
||||
file:///music/coltrane.flac
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct M3uReader;
|
||||
|
||||
impl PlaylistReader for M3uReader {
|
||||
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||
// BufReader ligne par ligne
|
||||
// Parser #EXTINF:duration,artist - title
|
||||
// Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dépendances** : stdlib uniquement
|
||||
|
||||
**Limitations** : Métadonnées pauvres, beaucoup de champs `None`
|
||||
|
||||
##### PlsReader (⚙️ Modéré - format INI)
|
||||
|
||||
**Format** :
|
||||
```ini
|
||||
[playlist]
|
||||
NumberOfEntries=2
|
||||
File1=file:///music/coltrane.flac
|
||||
Title1=John Coltrane - Giant Steps
|
||||
Length1=284
|
||||
```
|
||||
|
||||
```rust
|
||||
pub struct PlsReader;
|
||||
|
||||
impl PlaylistReader for PlsReader {
|
||||
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||
// HashMap<index, (file, title, duration)>
|
||||
// Parser FileN=..., TitleN=..., LengthN=...
|
||||
// Trier par index et convertir en JspfTrack
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dépendances** : stdlib uniquement
|
||||
|
||||
**Limitations** : File, Title, Length seulement
|
||||
|
||||
#### JspfWriter
|
||||
|
||||
```rust
|
||||
pub struct JspfWriter;
|
||||
|
||||
impl JspfWriter {
|
||||
pub fn write<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
|
||||
pub fn write_pretty<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
|
||||
pub fn to_string(jspf: &Jspf) -> Result<String>;
|
||||
pub fn to_string_pretty(jspf: &Jspf) -> Result<String>;
|
||||
}
|
||||
```
|
||||
|
||||
#### API publique
|
||||
|
||||
```rust
|
||||
pub use model::{Jspf, JspfPlaylist, JspfTrack, JspfMeta, JspfAttribution};
|
||||
pub use reader::{PlaylistReader, JspfReader, XspfReader, M3uReader, PlsReader};
|
||||
pub use writer::JspfWriter;
|
||||
|
||||
pub enum PlaylistFormat {
|
||||
Jspf,
|
||||
Xspf,
|
||||
M3u8,
|
||||
Pls,
|
||||
}
|
||||
|
||||
impl PlaylistFormat {
|
||||
pub fn from_extension(ext: &str) -> Option<Self>;
|
||||
}
|
||||
|
||||
pub fn read_playlist<R: Read>(reader: R, format: PlaylistFormat) -> Result<Jspf>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Crate `pmoplaylists` (PlaylistSource)
|
||||
|
||||
**Responsabilité** : Implémenter `MusicSource` pour servir playlists via UPnP
|
||||
|
||||
#### Structures principales
|
||||
|
||||
```rust
|
||||
pub struct PlaylistSource {
|
||||
root_path: PathBuf,
|
||||
playlists: Arc<RwLock<HashMap<String, ParsedPlaylist>>>,
|
||||
containers: Arc<RwLock<HashMap<PathBuf, ContainerMetadata>>>,
|
||||
watcher: Option<notify::RecommendedWatcher>,
|
||||
base_url: String,
|
||||
update_counter: Arc<RwLock<u32>>,
|
||||
last_change: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
pub struct ParsedPlaylist {
|
||||
pub metadata: PlaylistMetadata,
|
||||
pub tracks: Vec<PlaylistTrack>,
|
||||
pub source_path: PathBuf,
|
||||
pub format: PlaylistFormat,
|
||||
}
|
||||
|
||||
pub struct ContainerMetadata {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub meta: Vec<MetaEntry>,
|
||||
}
|
||||
|
||||
pub struct ContainerMetadataFile {
|
||||
pub container: ContainerMetadata,
|
||||
}
|
||||
```
|
||||
|
||||
#### Fonctionnalités
|
||||
|
||||
1. **Scan hiérarchique** : Parser récursivement dossiers + `metadata.json` + playlists
|
||||
2. **Cache** : Éviter re-parsing (playlists + conteneurs)
|
||||
3. **Hot reload** : `notify` pour détecter changements
|
||||
4. **Browse UPnP** : Générer DIDL-Lite avec métadonnées conteneurs
|
||||
5. **Content resolution** : Résoudre URIs via `SourceCacheManager`
|
||||
6. **Cover art** : Servir images playlists, tracks, conteneurs
|
||||
|
||||
#### Object IDs
|
||||
|
||||
```
|
||||
playlists # Racine
|
||||
playlists:category:{path} # Catégorie (dossier)
|
||||
playlists:playlist:{id} # Playlist
|
||||
playlists:playlist:{id}:track:{index} # Track dans playlist
|
||||
```
|
||||
|
||||
#### Gestion `metadata.json`
|
||||
|
||||
```rust
|
||||
fn load_container_metadata(&self, dir_path: &Path) -> Result<ContainerMetadata> {
|
||||
let metadata_path = dir_path.join("metadata.json");
|
||||
|
||||
if metadata_path.exists() {
|
||||
let content = fs::read_to_string(&metadata_path)?;
|
||||
let file: ContainerMetadataFile = serde_json::from_str(&content)?;
|
||||
Ok(file.container)
|
||||
} else {
|
||||
// Fallback : nom du répertoire
|
||||
Ok(ContainerMetadata {
|
||||
title: Some(dir_path.file_name()?.to_str()?.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Extension pmoconfig
|
||||
|
||||
**Fichier** : `pmoplaylists/src/config_ext.rs`
|
||||
|
||||
**Pattern** : [pmoconfig_ext.md](../Architecture/pmoconfig_ext.md)
|
||||
|
||||
```rust
|
||||
use pmoconfig::Config;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DEFAULT_PLAYLISTS_DIR: &str = "playlists";
|
||||
|
||||
pub trait PlaylistSourceConfigExt {
|
||||
fn get_playlists_dir(&self) -> PathBuf;
|
||||
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()>;
|
||||
fn get_playlists_enabled(&self) -> bool;
|
||||
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()>;
|
||||
fn get_playlists_supported_formats(&self) -> Vec<String>;
|
||||
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
impl PlaylistSourceConfigExt for Config {
|
||||
fn get_playlists_dir(&self) -> PathBuf {
|
||||
self.get_managed_dir("sources.playlists.directory", DEFAULT_PLAYLISTS_DIR)
|
||||
.expect("Failed to get playlists directory")
|
||||
}
|
||||
|
||||
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
|
||||
self.set_managed_dir("sources.playlists.directory", path)
|
||||
}
|
||||
|
||||
fn get_playlists_enabled(&self) -> bool {
|
||||
self.get_value("sources.playlists.enabled")
|
||||
.unwrap_or_else(|_| {
|
||||
let _ = self.set_value("sources.playlists.enabled", true);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()> {
|
||||
self.set_value("sources.playlists.enabled", enabled)
|
||||
}
|
||||
|
||||
fn get_playlists_supported_formats(&self) -> Vec<String> {
|
||||
self.get_value("sources.playlists.formats")
|
||||
.unwrap_or_else(|_| {
|
||||
let default = vec!["jspf".into(), "xspf".into(), "m3u8".into(), "pls".into()];
|
||||
let _ = self.set_value("sources.playlists.formats", &default);
|
||||
default
|
||||
})
|
||||
}
|
||||
|
||||
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()> {
|
||||
self.set_value("sources.playlists.formats", formats)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Config YAML** :
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
playlists:
|
||||
enabled: true
|
||||
directory: "playlists"
|
||||
formats:
|
||||
- jspf
|
||||
- xspf
|
||||
- m3u8
|
||||
- pls
|
||||
```
|
||||
|
||||
**Utilisation** :
|
||||
|
||||
```rust
|
||||
use pmoconfig::Config;
|
||||
use pmoplaylists::config_ext::PlaylistSourceConfigExt;
|
||||
|
||||
let config = Config::load()?;
|
||||
|
||||
if config.get_playlists_enabled() {
|
||||
let playlists_dir = config.get_playlists_dir();
|
||||
let playlist_source = PlaylistSource::new(playlists_dir, config.clone())?;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Intégration MusicBrainz (optionnelle - Phase 2)
|
||||
|
||||
### Crate recommandée : `musicbrainz_rs`
|
||||
|
||||
[musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) v0.5+
|
||||
- Client async/blocking
|
||||
- Rate limiting automatique (1 req/sec)
|
||||
- Support CoverArt Archive
|
||||
- MSRV: Rust 1.71.1
|
||||
|
||||
### Cas d'usage
|
||||
|
||||
1. **Résolution d'identifiants** :
|
||||
```json
|
||||
{"identifier": ["musicbrainz://recording/abc123"], "title": null}
|
||||
```
|
||||
→ Récupérer métadonnées depuis MusicBrainz
|
||||
|
||||
2. **Enrichissement playlists pauvres** : M3U8/PLS → MusicBrainz → métadonnées complètes
|
||||
|
||||
3. **Cover art** : CoverArt Archive
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
playlists:
|
||||
musicbrainz:
|
||||
enabled: false
|
||||
enrich_metadata: false
|
||||
rate_limit_per_sec: 1
|
||||
```
|
||||
|
||||
**Stratégie** :
|
||||
- **Phase 1 (MVP)** : Ne pas implémenter, stocker identifiants tel quel
|
||||
- **Phase 2** : Dépendance optionnelle, service asynchrone, configurable
|
||||
|
||||
---
|
||||
|
||||
## 📝 Prochaines étapes
|
||||
|
||||
1. ✅ Choix format : JSPF central
|
||||
2. ✅ Modèle données : Structures JSPF
|
||||
3. ✅ Extension pmoconfig : Trait défini
|
||||
4. ⏳ **Implémenter `pmojspf`** :
|
||||
- `JspfReader` (serde_json)
|
||||
- `XspfReader` (xml-rs ou crate xspf)
|
||||
- `M3uReader` (parsing ligne par ligne)
|
||||
- `PlsReader` (format INI)
|
||||
- `JspfWriter` (serde_json)
|
||||
5. ⏳ **Implémenter `pmoplaylists`** :
|
||||
- `PlaylistSource` (trait `MusicSource`)
|
||||
- Scan hiérarchique + cache
|
||||
- Hot reload (notify)
|
||||
- Browse UPnP (DIDL-Lite)
|
||||
- Gestion `metadata.json`
|
||||
6. ⏳ Tests avec clients UPnP
|
||||
|
||||
---
|
||||
|
||||
## 📚 Sources
|
||||
|
||||
### Spécifications
|
||||
- [XSPF Spec](https://www.xspf.org/spec)
|
||||
- [JSPF Spec](https://www.xspf.org/jspf)
|
||||
- [M3U - Wikipedia](https://en.wikipedia.org/wiki/M3U)
|
||||
- [PLS - Wikipedia](https://en.wikipedia.org/wiki/PLS_(file_format))
|
||||
|
||||
### Crates Rust
|
||||
- [xspf](https://crates.io/crates/xspf) - Parser XML XSPF
|
||||
- [musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) - API MusicBrainz
|
||||
- [MusicBrainz API Docs](https://musicbrainz.org/doc/MusicBrainz_API)
|
||||
8
Blackboard/Todo/Pinnable_cache_item.md
Normal file
8
Blackboard/Todo/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,8 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
|
||||
La crâte PMOcache, implémente un system de cache qui pourrait être étendu pour permettre une utilisation plus large. L'idée est de modifier les règles de déletion des items. Actuellement le cache a une capacité maximale. Et les items ont des TTL, qui peuvent être non définies. Lorsque le cash est plein, les plus vieux items en termes d'utilisation ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de rajouter une fonctionnalité qui permet d'épingler certains items pour les rendre non destructibles. Ils pourraient aussi sortir du comptage général des items pour savoir si le cache est plein.
|
||||
|
||||
Il faudra modifier la structure de la base de données. Ajouter une colonne indiquant cette propriété. Mettre une règle métier en disant qu'on ne peut pas être à la fois épinglés et avec un TTL.
|
||||
|
||||
On se moque de maintenir la compatibilité avec la base de données actuelle, il n'y a pas à prévoir de phase de transition. Nous sommes en période de développement.
|
||||
@@ -0,0 +1,5 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
Dans l'application web: `pmoapp/webapp`, pour sa partie point de contrôle, L'interface utilisateur se met à jour en fonction des événements qui arrivent sur un canal SSE. Actuellement, il y a une logique de débouncing sur ce canal. La logique de débouncing n'est normalement pas nécessaire pour un flux SSE qui est contrôlé par le serveur.
|
||||
|
||||
- Supprimer cette logique de débouncing de l'application PMOControl.
|
||||
|
||||
17
Blackboard/Todo/config_ext.md
Normal file
17
Blackboard/Todo/config_ext.md
Normal file
@@ -0,0 +1,17 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
Partir des fichiers suivants:
|
||||
|
||||
- pmocovers/src/config_ext.rs
|
||||
- pmoaudiocache/src/config_ext.rs
|
||||
- pmoqobuz/src/config_ext.rs
|
||||
- pmocache/src/config_ext.rs
|
||||
- pmoconfig/PASSWORD_ENCRYPTION.md
|
||||
- pmoupnp/src/config_ext.rs
|
||||
- pmoparadise/src/config_ext.rs
|
||||
|
||||
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension de PMOConfig (pmoconfig::Config).
|
||||
|
||||
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoconfig_ext.md`
|
||||
|
||||
Reste bien focalisé sur l'objectif principal.
|
||||
14
Blackboard/Todo/music_source.md
Normal file
14
Blackboard/Todo/music_source.md
Normal file
@@ -0,0 +1,14 @@
|
||||
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||
|
||||
Partir des fichiers suivants:
|
||||
|
||||
- pmoparadise/src/source.rs
|
||||
- pmoqobuz/src/source.rs
|
||||
- pmosource/README.md
|
||||
- pmosource/ARCHITECTURE.md
|
||||
|
||||
D'écrire dans un fichier d'architecture L'implémentation d'une nouvelle MusicSource.
|
||||
|
||||
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/music_source.md`
|
||||
|
||||
Reste bien focalisé sur l'objectif principal.
|
||||
Reference in New Issue
Block a user