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.
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
- Scan hiérarchique : Parser récursivement dossiers +
metadata.json+ playlists - Cache : Éviter re-parsing (playlists + conteneurs)
- Hot reload :
notifypour détecter changements - Browse UPnP : Générer DIDL-Lite avec métadonnées conteneurs
- Content resolution : Résoudre URIs via
SourceCacheManager - 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
-
Résolution d'identifiants :
{"identifier": ["musicbrainz://recording/abc123"], "title": null}→ Récupérer métadonnées depuis MusicBrainz
-
Enrichissement playlists pauvres : M3U8/PLS → MusicBrainz → métadonnées complètes
-
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
- ✅ Choix format : JSPF central
- ✅ Modèle données : Structures JSPF
- ✅ Extension pmoconfig : Trait défini
- ⏳ Implémenter
pmojspf:JspfReader(serde_json)XspfReader(xml-rs ou crate xspf)M3uReader(parsing ligne par ligne)PlsReader(format INI)JspfWriter(serde_json)
- ⏳ Implémenter
pmoplaylists:PlaylistSource(traitMusicSource)- Scan hiérarchique + cache
- Hot reload (notify)
- Browse UPnP (DIDL-Lite)
- Gestion
metadata.json
- ⏳ Tests avec clients UPnP
📚 Sources
Spécifications
Crates Rust
- xspf - Parser XML XSPF
- musicbrainz_rs - API MusicBrainz
- MusicBrainz API Docs