Files
pmomusic/Blackboard/Architecture/music_source.md
Eric Coissac c250801a9f 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.
2026-01-15 08:18:15 +01:00

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

  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

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 sources
  • id() doit être en lowercase-kebab-case
  • default_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 :

  1. Cache audio local (si disponible)
  2. URI originale (API streaming, fichier local, etc.)

3. Support FIFO (sources dynamiques)

Si votre source est dynamique (radio, streaming live) :

use pmoplaylist::PlaylistManager;
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Clone)]
pub struct RadioSource {
    playlist_id: String,
    update_counter: Arc<RwLock<u32>>,
    last_change: Arc<RwLock<SystemTime>>,
}

#[async_trait]
impl MusicSource for RadioSource {
    fn supports_fifo(&self) -> bool {
        true  // Cette source utilise une FIFO
    }

    async fn append_track(&self, track: Item) -> Result<()> {
        // Récupérer le gestionnaire de playlist
        let manager = PlaylistManager();
        let writer = manager
            .get_persistent_write_handle(self.playlist_id.clone())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // Extraire le PK depuis l'URI du track
        let pk = self.extract_pk_from_item(&track)?;

        // Ajouter à la playlist
        writer
            .push_lazy(pk)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // Incrémenter update_id
        self.bump_update_counter().await;

        Ok(())
    }

    async fn remove_oldest(&self) -> Result<Option<Item>> {
        let manager = PlaylistManager();
        let reader = manager
            .get_read_handle(&self.playlist_id)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // Récupérer le plus ancien
        let items = reader.to_items(1).await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        if let Some(item) = items.first() {
            // Adapter l'item au schéma de la source
            let adapted = self.adapt_item_to_schema(item.clone());
            self.bump_update_counter().await;
            Ok(Some(adapted))
        } else {
            Ok(None)
        }
    }

    async fn update_id(&self) -> u32 {
        *self.update_counter.read().await
    }

    async fn last_change(&self) -> Option<SystemTime> {
        Some(*self.last_change.read().await)
    }

    async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
        let manager = PlaylistManager();
        let reader = manager
            .get_read_handle(&self.playlist_id)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // Récupérer les items
        let items = reader
            .to_items(count)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // Adapter au schéma de la source
        let adapted = items.into_iter()
            .map(|item| self.adapt_item_to_schema(item))
            .collect();

        Ok(adapted)
    }
}

impl RadioSource {
    async fn bump_update_counter(&self) {
        let mut counter = self.update_counter.write().await;
        *counter = counter.wrapping_add(1).max(1);
        let mut last = self.last_change.write().await;
        *last = SystemTime::now();
    }
}

Points clés :

  • Utiliser pmoplaylist::PlaylistManager singleton
  • Incrémenter update_id à chaque modification
  • Mettre à jour last_change à chaque modification
  • Adapter les IDs des items au schéma de la source

4. Support statique (albums, bibliothèques)

Si votre source est statique (catalogue, albums) :

#[async_trait]
impl MusicSource for CatalogSource {
    fn supports_fifo(&self) -> bool {
        false  // Pas de FIFO
    }

    async fn append_track(&self, _track: Item) -> Result<()> {
        Err(MusicSourceError::NotSupported(
            "This source is read-only".to_string()
        ))
    }

    async fn remove_oldest(&self) -> Result<Option<Item>> {
        Ok(None)  // Pas de suppression
    }

    async fn update_id(&self) -> u32 {
        0  // Jamais de changement
    }

    async fn last_change(&self) -> Option<SystemTime> {
        None  // Pas de suivi des changements
    }

    async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
        // Retourner une liste paginée depuis le catalogue
        self.get_catalog_items(offset, count).await
    }
}

Patterns d'implémentation

Pattern 1 : Source dynamique avec FIFO (Radio Paradise)

Caractéristiques :

  • Flux continu de tracks
  • Capacité limitée (50-100 tracks)
  • Suppression automatique des plus anciens
  • supports_fifo() = true

Structure :

#[derive(Clone)]
pub struct RadioParadiseSource {
    base_url: String,
    update_counter: Arc<RwLock<u32>>,
    last_change: Arc<RwLock<SystemTime>>,
    callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
    container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>,
}

impl RadioParadiseSource {
    // Enregistrer des callbacks sur les playlists pour notifier les changements
    pub fn attach_playlist_callbacks(self: &Arc<Self>) {
        let playlist_ids = vec![
            self.live_playlist_id(),
            self.history_playlist_id(),
        ];

        let manager = PlaylistManager();
        let mut tokens = self.callback_tokens.lock().unwrap();

        for pid in playlist_ids {
            let weak = Arc::downgrade(self);
            let pid_clone = pid.clone();
            let token = manager.register_callback(move |event| {
                if event.playlist_id == pid_clone {
                    if let Some(strong) = weak.upgrade() {
                        tokio::spawn(async move {
                            strong.bump_update_counter().await;
                            // Notifier ContentDirectory
                            if let Some(notifier) = strong.container_notifier.as_ref() {
                                notifier(&[format!("radio-paradise:history")]);
                            }
                        });
                    }
                }
            });
            tokens.push(token);
        }
    }
}

Points clés :

  • Callbacks sur pmoplaylist pour détecter les changements
  • Notification du ContentDirectory via un notifier injecté
  • update_counter partagé via Arc<RwLock<u32>>

Pattern 2 : Source catalogue avec playlists lazy (Qobuz)

Caractéristiques :

  • Catalogue vaste (millions de tracks)
  • Playlists créées à la demande
  • Cache lazy (cover eager, audio lazy)
  • supports_fifo() = false

Structure :

#[derive(Clone)]
pub struct QobuzSource {
    inner: Arc<QobuzSourceInner>,
}

struct QobuzSourceInner {
    client: Arc<QobuzClient>,
    cache_manager: SourceCacheManager,
    base_url: String,
    update_counter: tokio::sync::RwLock<u32>,
    last_change: tokio::sync::RwLock<SystemTime>,
}

impl QobuzSource {
    // Ajouter un track avec cache lazy
    pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
        let track_id = format!("qobuz://track/{}", track.id);
        let lazy_pk = format!("QOBUZ:{}", track.id);

        // 1. Cache cover EAGERLY (petit, UI en a besoin)
        let cached_cover_pk = if let Some(ref image_url) = track.album.as_ref()
            .and_then(|a| a.image.as_ref()) {
            self.inner.cache_manager.cache_cover(image_url).await.ok()
        } else {
            None
        };

        // 2. Préparer metadata
        let metadata = AudioMetadata {
            title: Some(track.title.clone()),
            artist: track.performer.as_ref().map(|p| p.name.clone()),
            album: track.album.as_ref().map(|a| a.title.clone()),
            duration_secs: Some(track.duration as u64),
            // ... autres champs
        };

        // 3. Cache audio LAZILY (grand, téléchargé à la demande)
        let cached_audio_pk = self
            .inner
            .cache_manager
            .cache_audio_lazy_with_provider(
                &lazy_pk,
                Some(metadata.clone()),
                cached_cover_pk.clone(),
            )
            .await?;

        // 4. Stocker metadata
        self.inner.cache_manager.update_metadata(
            track_id.clone(),
            pmosource::TrackMetadata {
                original_uri: stream_url,
                cached_audio_pk: Some(cached_audio_pk.clone()),
                cached_cover_pk,
            },
        ).await;

        Ok((track_id, cached_audio_pk))
    }

    // Créer une playlist d'album avec TTL
    async fn get_or_create_album_playlist_items(
        &self,
        album_id: &str,
        limit: usize,
    ) -> Result<Vec<Item>> {
        const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);

        let playlist_id = format!("qobuz-album-{}", album_id);
        let playlist_manager = PlaylistManager();

        // Vérifier validité (existe ET non expirée ET non vide)
        let is_valid = self.is_album_playlist_valid(&playlist_id).await?;

        if is_valid {
            // Récupérer depuis playlist existante
            let reader = playlist_manager.get_read_handle(&playlist_id).await?;
            let items = reader.to_items(limit).await?;
            return self.adapt_playlist_items_to_qobuz(items, album_id).await;
        }

        // Créer nouvelle playlist
        let writer = playlist_manager
            .create_persistent_playlist_with_role(
                playlist_id.clone(),
                pmoplaylist::PlaylistRole::Album,
            )
            .await?;

        // Ajouter tracks avec cache lazy
        self.add_album_to_playlist(&playlist_id, album_id).await?;

        // Récupérer items
        let reader = playlist_manager.get_read_handle(&playlist_id).await?;
        let items = reader.to_items(limit).await?;
        self.adapt_playlist_items_to_qobuz(items, album_id).await
    }
}

Points clés :

  • Cache lazy pour l'audio (téléchargé à la demande)
  • Cache eager pour les covers (petit, UI en a besoin)
  • Playlists avec TTL (7 jours)
  • LazyProvider pour télécharger l'audio lors de la lecture

Pattern 3 : Adaptation des IDs entre playlist et source

Lorsqu'une source utilise pmoplaylist, les items retournés ont des IDs génériques. Il faut les adapter au schéma de la source :

async fn adapt_playlist_items_to_source(
    &self,
    items: Vec<Item>,
    parent_id: &str,
) -> Result<Vec<Item>> {
    let mut adapted = Vec::with_capacity(items.len());

    for mut item in items {
        // Extraire cache_pk depuis l'URL du resource
        let cache_pk = if let Some(resource) = item.resources.first() {
            resource
                .url
                .strip_prefix("/audio/flac/")
                .map(|s| s.to_string())
        } else {
            None
        };

        if let Some(pk) = cache_pk {
            // Récupérer source_track_id depuis metadata
            if let Ok(Some(track_id_value)) = self
                .cache_manager
                .get_audio_metadata(&pk, "source_track_id")
            {
                if let Some(track_id) = track_id_value.as_str() {
                    item.id = format!("my-source:track:{}", track_id);
                }
            }

            // Convertir URL relative en absolue
            if let Some(resource) = item.resources.first_mut() {
                if resource.url.starts_with('/') {
                    resource.url = format!("{}{}", self.base_url, resource.url);
                }
            }
        }

        item.parent_id = parent_id.to_string();

        // Normaliser album art
        if let Some(art) = item.album_art.as_mut() {
            if art.starts_with('/') {
                *art = format!("{}{}", self.base_url, art);
            }
        } else {
            item.album_art = Some(self.default_cover_url());
        }

        // Ajouter genre par défaut si absent (requis par certains clients)
        if item.genre.is_none() {
            item.genre = Some("Music".to_string());
        }

        adapted.push(item);
    }

    Ok(adapted)
}

Points clés :

  • Stocker source_track_id dans les metadata du cache audio
  • Reconstituer l'ID correct lors de la récupération depuis playlist
  • Normaliser URLs (relatives → absolues)
  • Ajouter champs requis par certains clients UPnP

Intégration avec l'écosystème PMOMusic

Avec pmoplaylist

Pour les sources dynamiques et les catalogues :

use pmoplaylist::{PlaylistManager, PlaylistRole};

// Créer une playlist persistante
let manager = PlaylistManager();
let writer = manager
    .create_persistent_playlist_with_role(
        "my-source-album-123".to_string(),
        PlaylistRole::Album,
    )
    .await?;

// Configurer metadata
writer.set_title("Album Title".to_string()).await?;
writer.set_artist(Some("Artist Name".to_string())).await?;
writer.set_cover_pk(Some("cover-pk".to_string())).await?;

// Ajouter tracks avec cache lazy
writer.push_lazy_batch(vec!["pk1", "pk2", "pk3"]).await?;

// Activer mode lazy (lookahead 2 tracks)
manager.enable_lazy_mode("my-source-album-123", 2);

Avec pmoaudiocache et pmocovers (via SourceCacheManager)

use pmosource::SourceCacheManager;

// Créer le manager centralisé
let cache_manager = SourceCacheManager::from_registry("my-source".to_string())?;

// Enregistrer un LazyProvider
cache_manager.register_lazy_provider(Arc::new(MyLazyProvider::new(client)));

// Cache eager (cover)
let cover_pk = cache_manager.cache_cover("https://example.com/cover.jpg").await?;

// Cache lazy (audio)
let audio_pk = cache_manager
    .cache_audio_lazy_with_provider(
        "MY-SOURCE:123",  // Lazy PK
        Some(metadata),
        Some(cover_pk),
    )
    .await?;

// Récupérer metadata
let value = cache_manager.get_audio_metadata(&audio_pk, "key").await?;

LazyProvider personnalisé :

use pmoaudiocache::{LazyProvider, LazyProviderError};

pub struct MyLazyProvider {
    client: Arc<MyClient>,
}

#[async_trait]
impl LazyProvider for MyLazyProvider {
    async fn fetch_audio(&self, lazy_pk: &str) -> Result<Vec<u8>, LazyProviderError> {
        // Extraire l'ID depuis le lazy_pk
        let id = lazy_pk
            .strip_prefix("MY-SOURCE:")
            .ok_or_else(|| LazyProviderError::InvalidKey)?;

        // Récupérer l'URL de streaming
        let stream_url = self.client.get_stream_url(id).await
            .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;

        // Télécharger l'audio
        let response = reqwest::get(&stream_url).await
            .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;

        let bytes = response.bytes().await
            .map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;

        Ok(bytes.to_vec())
    }
}

Avec pmodidl

Conversion de vos structures en DIDL-Lite :

use pmodidl::{Container, Item, Resource};

// Container
pub trait ToDIDLContainer {
    fn to_didl_container(&self, parent_id: &str) -> Result<Container>;
}

impl ToDIDLContainer for MyAlbum {
    fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
        Ok(Container {
            id: format!("my-source:album:{}", self.id),
            parent_id: parent_id.to_string(),
            restricted: Some("1".to_string()),
            child_count: self.tracks_count.map(|c| c.to_string()),
            searchable: Some("1".to_string()),
            title: self.title.clone(),
            class: "object.container.album.musicAlbum".to_string(),
            artist: Some(self.artist.name.clone()),
            album_art: self.cover_url.clone(),
            containers: vec![],
            items: vec![],
        })
    }
}

// Item
pub trait ToDIDLItem {
    fn to_didl_item(&self, parent_id: &str) -> Result<Item>;
}

impl ToDIDLItem for MyTrack {
    fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
        Ok(Item {
            id: format!("my-source:track:{}", self.id),
            parent_id: parent_id.to_string(),
            restricted: Some("1".to_string()),
            title: self.title.clone(),
            creator: self.artist.as_ref().map(|a| a.name.clone()),
            class: "object.item.audioItem.musicTrack".to_string(),
            artist: self.artist.as_ref().map(|a| a.name.clone()),
            album: self.album.as_ref().map(|a| a.title.clone()),
            genre: Some("Music".to_string()),
            album_art: self.cover_url.clone(),
            album_art_pk: self.cover_pk.clone(),
            date: self.release_date.clone(),
            original_track_number: Some(self.track_number),
            resources: vec![Resource {
                protocol_info: "http-get:*:audio/flac:*".to_string(),
                bits_per_sample: self.bit_depth.map(|b| b.to_string()),
                sample_frequency: self.sample_rate.map(|s| s.to_string()),
                nr_audio_channels: Some("2".to_string()),
                duration: self.duration_as_upnp_format(),
                url: format!("/audio/flac/{}", self.cache_pk),
            }],
            descriptions: vec![],
        })
    }
}

Checklist de mise en œuvre

Phase 1 : Structure de base

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