**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, #[serde(skip_serializing_if = "Option::is_none")] pub creator: Option, #[serde(skip_serializing_if = "Option::is_none")] pub annotation: Option, #[serde(skip_serializing_if = "Option::is_none")] pub info: Option, #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, #[serde(skip_serializing_if = "Option::is_none")] pub identifier: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(skip_serializing_if = "Option::is_none")] pub date: Option, #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub attribution: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub meta: Vec, #[serde(default)] pub track: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct JspfTrack { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub location: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub identifier: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub creator: Option, #[serde(skip_serializing_if = "Option::is_none")] pub annotation: Option, #[serde(skip_serializing_if = "Option::is_none")] pub info: Option, #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(skip_serializing_if = "Option::is_none")] pub album: Option, #[serde(skip_serializing_if = "Option::is_none")] pub track_num: Option, #[serde(skip_serializing_if = "Option::is_none")] pub duration: Option, // millisecondes #[serde(skip_serializing_if = "Vec::is_empty", default)] pub meta: Vec, } #[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(reader: R) -> Result; fn from_str(s: &str) -> Result; fn from_file>(path: P) -> Result; } ``` #### Implémentations des Readers ##### JspfReader (✅ Simple - serde_json) ```rust pub struct JspfReader; impl PlaylistReader for JspfReader { fn read(reader: R) -> Result { 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 ``, ``, etc. **Alternative** : Utiliser la crate `xspf` existante puis convertir → JSPF ```rust pub struct XspfReader; impl PlaylistReader for XspfReader { fn read(reader: R) -> Result { // Parser XML avec EventReader // État : in_playlist, in_track, current_element // Mapping: → 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)