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.
29 KiB
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
- Vue d'ensemble
- Structure d'une MusicSource
- Implémentation du trait MusicSource
- Patterns d'implémentation
- Intégration avec l'écosystème PMOMusic
- Checklist de mise en œuvre
- 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
[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 sourcesid()doit être en lowercase-kebab-casedefault_image()doit être un WebP 300x300px
2. Navigation ContentDirectory
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 :
- Cache audio local (si disponible)
- 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::PlaylistManagersingleton - 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
pmoplaylistpour détecter les changements - Notification du ContentDirectory via un notifier injecté
update_counterpartagé viaArc<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)
LazyProviderpour 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_iddans 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
- 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
LazyProvidersi 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()etlast_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 :
SourceCacheManagercentralisé- Cache lazy pour audio, eager pour covers
LazyProviderpersonnalisé- 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
- Cache agressif : Utilisez
SourceCacheManagerpour tout - Pagination : Limitez le nombre d'items retournés (max 100)
- Lazy loading : Ne chargez que ce qui est demandé
- Rate limiting : Respectez les limites API de la source
- Arc<> : Partagez les données coûteuses
Compatibilité UPnP
- Genre obligatoire : Certains clients (gupnp-av-cp) requièrent
<upnp:genre> - URLs absolues : Toujours retourner des URLs complètes (pas de chemins relatifs)
- Protocol Info : Utilisez
http-get:*:audio/flac:*pour FLAC - Duration : Format
H:MM:SS(ex:0:03:45) - childCount : Optionnel mais recommandé pour l'UI
Gestion d'erreurs
- ObjectNotFound : ID invalide
- BrowseError : Erreur générique de navigation
- UriResolutionError : Impossible de résoudre l'URI
- PlaylistError : Erreur d'interaction avec pmoplaylist
- CacheError : Erreur de cache
Thread Safety
- Arc<RwLock<>> : Pour l'état mutable partagé
- tokio::sync::RwLock : Pour l'async
- Éviter Rc<> : Pas thread-safe
- Clone : Implémentez
ClonepourArc<>
Conclusion
L'implémentation d'une nouvelle MusicSource suit ces étapes :
- Définir le schéma d'Object ID : Hiérarchie claire et cohérente
- Implémenter la navigation :
browse()pour tous les niveaux - Résoudre les URIs : Cache local d'abord, puis original
- Gérer le cache :
SourceCacheManager+LazyProvider - Adapter les IDs : Playlist → Schema de la source
- 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.