← Retour à l'index

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

- XSPF Spec
- JSPF Spec
- M3U - Wikipedia
- PLS - Wikipedia

### Crates Rust

- xspf - Parser XML XSPF
- musicbrainz_rs - API MusicBrainz
- MusicBrainz API Docs