Files
pmomusic/Blackboard/ToThinkAbout/PlayListSource.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

15 KiB

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

PlaylistSource : MusicSource pour playlists

Implémenter une source PMOMusic capable de servir un catalogue de playlists hiérarchisé via UPnP.


📋 Décisions de conception

Format pivot : JSPF (JSON)

Choix : JSPF comme format interne central

  • Métadonnées riches (title, creator, album, annotation, image, duration, etc.)
  • JSON natif avec serde (Rust-friendly)
  • Standard ouvert (Xiph.Org)
  • Extensible via champ meta

Formats supportés :

  • JSPF (.jspf) - JSON, format natif
  • XSPF (.xspf) - XML, conversion vers JSPF
  • M3U8 (.m3u8) - Texte, métadonnées limitées
  • PLS (.pls) - INI-like, très basique

Architecture : 1 Writer (JSPF) + 4 Readers (JSPF, XSPF, M3U8, PLS) → Structure JSPF centrale

flowchart LR
    JSPF[JSPF JSON] --> JR[JspfReader]
    XSPF[XSPF XML] --> XR[XspfReader]
    M3U8[M3U8 Text] --> MR[M3uReader]
    PLS[PLS INI] --> PR[PlsReader]
    
    JR --> CORE[JSPF Structure]
    XR --> CORE
    MR --> CORE
    PR --> CORE
    
    CORE --> W[JspfWriter]
    W --> OUT[.jspf]

🗂️ Structure du répertoire

playlists/
├── metadata.json              # Métadonnées du conteneur racine
├── Jazz/
│   ├── metadata.json          # Métadonnées catégorie Jazz
│   ├── standards.jspf
│   ├── bebop.jspf
│   └── covers/
│       └── standards.webp
├── Classical/
│   ├── metadata.json
│   ├── baroque.jspf
│   └── romantic.jspf
└── Rock/
    ├── metadata.json
    └── 70s.jspf

Fichier metadata.json (conteneur)

{
  "container": {
    "title": "Collection Jazz",
    "description": "Mes playlists jazz favorites",
    "creator": "John Doe",
    "image": "covers/jazz-collection.webp",
    "date": "2026-01-15",
    "meta": [
      {"rel": "genre", "content": "Jazz"},
      {"rel": "mood", "content": "Relaxing"}
    ]
  }
}

🏗️ Composants à implémenter

1. Crate pmojspf (parsing playlists)

Responsabilité : Parser différents formats de playlist vers structure JSPF unifiée

Structure

pmojspf/
├── Cargo.toml
├── src/
│   ├── lib.rs           # API publique
│   ├── model.rs         # Structures JSPF
│   ├── writer.rs        # JspfWriter
│   ├── reader/
│   │   ├── mod.rs       # Trait PlaylistReader
│   │   ├── jspf.rs      # Reader JSON natif (serde_json)
│   │   ├── xspf.rs      # Reader XML (xml-rs)
│   │   ├── m3u.rs       # Reader M3U8 (parsing ligne par ligne)
│   │   └── pls.rs       # Reader PLS (format INI-like)
│   └── error.rs
└── tests/
    └── fixtures/

Modèle de données

Inspiré de la crate xspf v0.4.2

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jspf {
    pub playlist: JspfPlaylist,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JspfPlaylist {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub creator: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub annotation: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub info: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identifier: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub attribution: Vec<JspfAttribution>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub meta: Vec<JspfMeta>,
    #[serde(default)]
    pub track: Vec<JspfTrack>,
}

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

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JspfAttribution {
    Location { location: String },
    Identifier { identifier: String },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JspfMeta {
    pub rel: String,
    pub content: String,
}

Trait PlaylistReader

use std::io::Read;

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

Implémentations des Readers

JspfReader ( Simple - serde_json)
pub struct JspfReader;

impl PlaylistReader for JspfReader {
    fn read<R: Read>(reader: R) -> Result<Jspf> {
        serde_json::from_reader(reader)
            .map_err(|e| Error::ParseError(format!("JSON: {}", e)))
    }
}

Dépendances : serde_json

XspfReader (⚠️ Complexe - xml-rs)

Approche : Machine à états XML pour parser <playlist>, <track>, etc.

Alternative : Utiliser la crate xspf existante puis convertir → JSPF

pub struct XspfReader;

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

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

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

Format :

#EXTM3U
#PLAYLIST:Ma Playlist Jazz
#EXTINF:284,John Coltrane - Giant Steps
#EXTART:John Coltrane
#EXTALB:Giant Steps
file:///music/coltrane.flac
pub struct M3uReader;

impl PlaylistReader for M3uReader {
    fn read<R: Read>(reader: R) -> Result<Jspf> {
        // BufReader ligne par ligne
        // Parser #EXTINF:duration,artist - title
        // Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)
    }
}

Dépendances : stdlib uniquement

Limitations : Métadonnées pauvres, beaucoup de champs None

PlsReader (⚙️ Modéré - format INI)

Format :

[playlist]
NumberOfEntries=2
File1=file:///music/coltrane.flac
Title1=John Coltrane - Giant Steps
Length1=284
pub struct PlsReader;

impl PlaylistReader for PlsReader {
    fn read<R: Read>(reader: R) -> Result<Jspf> {
        // HashMap<index, (file, title, duration)>
        // Parser FileN=..., TitleN=..., LengthN=...
        // Trier par index et convertir en JspfTrack
    }
}

Dépendances : stdlib uniquement

Limitations : File, Title, Length seulement

JspfWriter

pub struct JspfWriter;

impl JspfWriter {
    pub fn write<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
    pub fn write_pretty<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
    pub fn to_string(jspf: &Jspf) -> Result<String>;
    pub fn to_string_pretty(jspf: &Jspf) -> Result<String>;
}

API publique

pub use model::{Jspf, JspfPlaylist, JspfTrack, JspfMeta, JspfAttribution};
pub use reader::{PlaylistReader, JspfReader, XspfReader, M3uReader, PlsReader};
pub use writer::JspfWriter;

pub enum PlaylistFormat {
    Jspf,
    Xspf,
    M3u8,
    Pls,
}

impl PlaylistFormat {
    pub fn from_extension(ext: &str) -> Option<Self>;
}

pub fn read_playlist<R: Read>(reader: R, format: PlaylistFormat) -> Result<Jspf>;

2. Crate pmoplaylists (PlaylistSource)

Responsabilité : Implémenter MusicSource pour servir playlists via UPnP

Structures principales

pub struct PlaylistSource {
    root_path: PathBuf,
    playlists: Arc<RwLock<HashMap<String, ParsedPlaylist>>>,
    containers: Arc<RwLock<HashMap<PathBuf, ContainerMetadata>>>,
    watcher: Option<notify::RecommendedWatcher>,
    base_url: String,
    update_counter: Arc<RwLock<u32>>,
    last_change: Arc<RwLock<SystemTime>>,
}

pub struct ParsedPlaylist {
    pub metadata: PlaylistMetadata,
    pub tracks: Vec<PlaylistTrack>,
    pub source_path: PathBuf,
    pub format: PlaylistFormat,
}

pub struct ContainerMetadata {
    pub title: Option<String>,
    pub description: Option<String>,
    pub creator: Option<String>,
    pub image: Option<String>,
    pub date: Option<String>,
    pub meta: Vec<MetaEntry>,
}

pub struct ContainerMetadataFile {
    pub container: ContainerMetadata,
}

Fonctionnalités

  1. Scan hiérarchique : Parser récursivement dossiers + metadata.json + playlists
  2. 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

fn load_container_metadata(&self, dir_path: &Path) -> Result<ContainerMetadata> {
    let metadata_path = dir_path.join("metadata.json");
    
    if metadata_path.exists() {
        let content = fs::read_to_string(&metadata_path)?;
        let file: ContainerMetadataFile = serde_json::from_str(&content)?;
        Ok(file.container)
    } else {
        // Fallback : nom du répertoire
        Ok(ContainerMetadata {
            title: Some(dir_path.file_name()?.to_str()?.to_string()),
            ..Default::default()
        })
    }
}

3. Extension pmoconfig

Fichier : pmoplaylists/src/config_ext.rs

Pattern : pmoconfig_ext.md

use pmoconfig::Config;
use std::path::{Path, PathBuf};

const DEFAULT_PLAYLISTS_DIR: &str = "playlists";

pub trait PlaylistSourceConfigExt {
    fn get_playlists_dir(&self) -> PathBuf;
    fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()>;
    fn get_playlists_enabled(&self) -> bool;
    fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()>;
    fn get_playlists_supported_formats(&self) -> Vec<String>;
    fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()>;
}

impl PlaylistSourceConfigExt for Config {
    fn get_playlists_dir(&self) -> PathBuf {
        self.get_managed_dir("sources.playlists.directory", DEFAULT_PLAYLISTS_DIR)
            .expect("Failed to get playlists directory")
    }
    
    fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
        self.set_managed_dir("sources.playlists.directory", path)
    }
    
    fn get_playlists_enabled(&self) -> bool {
        self.get_value("sources.playlists.enabled")
            .unwrap_or_else(|_| {
                let _ = self.set_value("sources.playlists.enabled", true);
                true
            })
    }
    
    fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()> {
        self.set_value("sources.playlists.enabled", enabled)
    }
    
    fn get_playlists_supported_formats(&self) -> Vec<String> {
        self.get_value("sources.playlists.formats")
            .unwrap_or_else(|_| {
                let default = vec!["jspf".into(), "xspf".into(), "m3u8".into(), "pls".into()];
                let _ = self.set_value("sources.playlists.formats", &default);
                default
            })
    }
    
    fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()> {
        self.set_value("sources.playlists.formats", formats)
    }
}

Config YAML :

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

Utilisation :

use pmoconfig::Config;
use pmoplaylists::config_ext::PlaylistSourceConfigExt;

let config = Config::load()?;

if config.get_playlists_enabled() {
    let playlists_dir = config.get_playlists_dir();
    let playlist_source = PlaylistSource::new(playlists_dir, config.clone())?;
}

🔌 Intégration MusicBrainz (optionnelle - Phase 2)

Crate recommandée : musicbrainz_rs

musicbrainz_rs v0.5+

  • Client async/blocking
  • Rate limiting automatique (1 req/sec)
  • Support CoverArt Archive
  • MSRV: Rust 1.71.1

Cas d'usage

  1. Résolution d'identifiants :

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

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

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

  3. Cover art : CoverArt Archive

Configuration

sources:
  playlists:
    musicbrainz:
      enabled: false
      enrich_metadata: false
      rate_limit_per_sec: 1

Stratégie :

  • Phase 1 (MVP) : Ne pas implémenter, stocker identifiants tel quel
  • Phase 2 : Dépendance optionnelle, service asynchrone, configurable

📝 Prochaines étapes

  1. Choix format : JSPF central
  2. 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

Crates Rust