Crée la crate pmoplaylist

This commit is contained in:
2025-10-16 22:13:00 +02:00
parent e0fbb11475
commit 5c06a0f0e9
25 changed files with 5482 additions and 189 deletions

519
pmoplaylist/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,519 @@
# Architecture de pmoplaylist
## Vue d'ensemble
`pmoplaylist` est une bibliothèque Rust qui fournit une abstraction de playlist FIFO (First-In-First-Out) thread-safe pour des MediaServers UPnP/OpenHome. Elle gère la logique de playlist pure sans aucune dépendance réseau ou protocole UPnP.
## Design Patterns
### 1. Arc + RwLock Pattern (Thread Safety)
```rust
pub struct FifoPlaylist {
inner: Arc<RwLock<FifoPlaylistInner>>,
}
```
**Raison** : Permet le clonage léger de `FifoPlaylist` et le partage entre threads/tasks tout en garantissant un accès concurrent sécurisé.
**Avantages** :
- Clone peu coûteux (clone uniquement le `Arc`, pas les données)
- Accès concurrent : plusieurs lecteurs simultanés, un seul écrivain
- Compatible avec tokio et les runtimes asynchrones
**Exemple d'utilisation** :
```rust
let playlist = FifoPlaylist::new(...);
let p1 = playlist.clone(); // Pour un thread
let p2 = playlist.clone(); // Pour un autre thread
```
### 2. Builder Pattern pour Track
```rust
Track::new("id", "title", "uri")
.with_artist("Artist")
.with_album("Album")
.with_duration(300)
.with_image("url");
```
**Raison** : Facilite la création de tracks avec métadonnées optionnelles de manière fluide et lisible.
### 3. FIFO avec VecDeque
```rust
struct FifoPlaylistInner {
queue: VecDeque<Track>,
capacity: usize,
// ...
}
```
**Raison** : `VecDeque` offre des opérations O(1) pour `push_back` et `pop_front`, parfait pour une FIFO.
**Gestion de la capacité** :
- Lors de `append_track()`, si `len >= capacity`, on appelle `pop_front()` automatiquement
- Garantit que la playlist ne dépasse jamais la capacité configurée
## Structures de données
### Track
```rust
pub struct Track {
pub id: String, // Identifiant unique
pub title: String, // Titre du morceau
pub artist: Option<String>, // Artiste
pub album: Option<String>, // Album
pub duration: Option<u32>, // Durée en secondes
pub uri: String, // URI du fichier/flux
pub image: Option<String>, // URL de la cover
}
```
**Sérialisation** : Implémente `Serialize` et `Deserialize` pour faciliter l'export JSON/autre.
### FifoPlaylistInner
```rust
struct FifoPlaylistInner {
id: String, // ID unique de la playlist
title: String, // Titre de la playlist
default_image: &'static [u8], // Image par défaut embarquée
capacity: usize, // Capacité max de la FIFO
queue: VecDeque<Track>, // Queue des tracks
update_id: u32, // Compteur de modifications
last_change: SystemTime, // Timestamp dernière modif
}
```
**update_id** :
- Incrémenté à chaque modification (append, remove, clear)
- Permet aux clients UPnP de détecter les changements
- Utilise `wrapping_add()` pour éviter les débordements
## Intégration DIDL-Lite
### Génération de Container
```rust
pub async fn as_container(&self) -> Container
```
**Produit** :
```xml
<container id="playlist-id" parentID="0" childCount="5">
<dc:title>My Playlist</dc:title>
<upnp:class>object.container.playlistContainer</upnp:class>
</container>
```
**Utilisation** : Pour exposer la playlist comme container dans le ContentDirectory UPnP.
### Génération d'Items
```rust
pub async fn as_objects(
offset: usize,
count: usize,
default_image_url: Option<&str>
) -> Vec<Item>
```
**Produit** : Un vecteur d'objets `pmodidl::Item` représentant les tracks.
**Mapping Track → DIDL Item** :
- `track.id``item.id`
- `track.title``item.title`
- `track.artist``item.artist` et `item.creator`
- `track.album``item.album`
- `track.uri``resource.url`
- `track.duration` (secondes) → `resource.duration` (format "H:MM:SS")
- `track.image` ou `default_image_url``item.album_art`
**Classe UPnP** : Tous les items ont la classe `object.item.audioItem.musicTrack`.
## Gestion de l'image par défaut
### Intégration avec `include_bytes!`
```rust
pub const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
```
**Avantages** :
- L'image est compilée directement dans le binaire
- Pas de dépendance au système de fichiers à l'exécution
- Accès instantané et thread-safe
### Format WebP
**Raison du choix** :
- Format moderne et efficace
- Meilleure compression que JPEG/PNG
- Support alpha (transparence)
- Largement supporté par les navigateurs et clients modernes
**Spécifications** :
- Dimension : 300x300 pixels
- Format : WebP
- Qualité : 85
- Taille : ~9-10 KB
### Utilisation
```rust
let image_bytes = playlist.default_image().await;
// Servir via HTTP avec Content-Type: image/webp
```
## Concurrence et Thread Safety
### Scenario 1 : Lecture concurrente
```rust
// Thread 1
let len = playlist.len().await;
// Thread 2 (simultané)
let items = playlist.get_items(0, 10).await;
```
**Comportement** : Les deux opérations peuvent s'exécuter simultanément car `RwLock` permet plusieurs lecteurs.
### Scenario 2 : Écriture exclusive
```rust
// Thread 1
playlist.append_track(track1).await;
// Thread 2 (simultané)
playlist.append_track(track2).await;
```
**Comportement** : Les opérations sont sérialisées. Un seul thread écrit à la fois.
### Scenario 3 : Lecture pendant écriture
```rust
// Thread 1 : Écriture
playlist.append_track(track).await;
// Thread 2 : Lecture (simultané)
let len = playlist.len().await;
```
**Comportement** : La lecture attend que l'écriture se termine.
## Gestion de l'Update ID
### Algorithme
```rust
// À chaque modification
inner.update_id = inner.update_id.wrapping_add(1);
inner.last_change = SystemTime::now();
```
**Opérations qui incrémentent l'update_id** :
- `append_track()` → +1
- `remove_oldest()` → +1 (si un track est supprimé)
- `remove_by_id()` → +1 (si un track est trouvé et supprimé)
- `clear()` → +1 (si la playlist n'était pas vide)
**Opérations qui ne l'incrémentent PAS** :
- `get_items()` (lecture seule)
- `len()`, `is_empty()` (lecture seule)
- `as_container()`, `as_objects()` (lecture seule)
### Utilisation dans UPnP
Les clients UPnP peuvent :
1. Interroger l'`update_id` initial
2. Mémoriser cette valeur
3. Ré-interroger périodiquement
4. Si `update_id` a changé → rafraîchir l'affichage
## Cas d'usage
### 1. Radio en streaming
**Caractéristiques** :
- Capacité limitée (ex: 20 tracks)
- Ajouts fréquents de nouveaux tracks
- Les anciens tracks sont automatiquement supprimés
**Configuration recommandée** :
```rust
let radio = FifoPlaylist::new(
"radio-paradise",
"Radio Paradise",
20, // Historique limité à 20 tracks
DEFAULT_IMAGE,
);
```
### 2. Album statique
**Caractéristiques** :
- Capacité large (ex: 100 tracks)
- Tous les tracks ajoutés une seule fois
- Pas de rotation automatique
**Configuration recommandée** :
```rust
let album = FifoPlaylist::new(
"album-dsotm",
"The Dark Side of the Moon",
100, // Capacité large pour tout l'album
DEFAULT_IMAGE,
);
```
### 3. Playlist locale modifiable
**Caractéristiques** :
- Capacité moyenne (ex: 50 tracks)
- Ajouts et suppressions manuels
- Utilisation de `remove_by_id()` pour contrôle précis
**Configuration recommandée** :
```rust
let playlist = FifoPlaylist::new(
"my-playlist",
"My Favorites",
50,
DEFAULT_IMAGE,
);
```
## Intégration avec un MediaServer
### Architecture typique
```
┌─────────────────┐
│ UPnP Client │
│ (Control Point)│
└────────┬────────┘
│ HTTP/SOAP
┌─────────────────────┐
│ MediaServer UPnP │
│ ┌───────────────┐ │
│ │ ContentDirectory│ │
│ │ Service │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ pmoplaylist │ │ ← Cette crate
│ │ (FIFO) │ │
│ └───────────────┘ │
└─────────────────────┘
```
### Exemple d'endpoints
```rust
// GET /ContentDirectory/Browse?ObjectID=playlist-id
async fn browse_container(playlist: Arc<FifoPlaylist>) -> Response {
let container = playlist.as_container().await;
// Convertir en XML DIDL-Lite et retourner
}
// GET /ContentDirectory/Browse?ObjectID=playlist-id&StartingIndex=0&RequestedCount=10
async fn browse_items(
playlist: Arc<FifoPlaylist>,
offset: usize,
count: usize
) -> Response {
let items = playlist.as_objects(offset, count, Some(DEFAULT_IMAGE_URL)).await;
// Convertir en XML DIDL-Lite et retourner
}
// GET /SystemUpdateID
async fn get_update_id(playlist: Arc<FifoPlaylist>) -> Response {
let update_id = playlist.update_id().await;
// Retourner l'update_id
}
```
## Tests
### Couverture
La crate inclut 11 tests unitaires + 8 doctests couvrant :
1. **Création et état initial**
- `test_create_playlist`
2. **Ajout de tracks**
- `test_append_track`
- `test_fifo_capacity`
3. **Suppression de tracks**
- `test_remove_oldest`
- `test_remove_by_id`
- `test_clear`
4. **Navigation**
- `test_get_items_pagination`
5. **Génération DIDL-Lite**
- `test_as_container`
- `test_as_objects`
6. **Builder pattern**
- `test_track_builder`
7. **Update ID**
- `test_update_id_increments`
### Exécution
```bash
# Tests unitaires
cargo test -p pmoplaylist
# Tests avec doctests
cargo test -p pmoplaylist --doc
# Tous les tests
cargo test -p pmoplaylist --all-targets
```
## Exemples fournis
### 1. basic_usage.rs
Démontre :
- Création d'une playlist
- Ajout et suppression de tracks
- Comportement FIFO
- Génération DIDL-Lite
- Gestion de l'update_id
```bash
cargo run -p pmoplaylist --example basic_usage
```
### 2. radio_streaming.rs
Démontre :
- Utilisation multi-thread
- Simulation d'un flux radio continu
- Surveillance des changements via update_id
- Consultation de l'historique
```bash
cargo run -p pmoplaylist --example radio_streaming
```
### 3. http_server_integration.rs
Démontre :
- Intégration avec un serveur HTTP
- Endpoints REST simulés
- Partage de playlist avec `Arc`
- Serving de l'image par défaut
```bash
cargo run -p pmoplaylist --example http_server_integration
```
## Dépendances
### Runtime
- **pmodidl** (path = "../pmodidl")
- Structures DIDL-Lite (Container, Item, Resource)
- Nécessaire pour la génération d'objets UPnP
- **tokio** (1.42.0, features: sync, time, macros, rt, rt-multi-thread)
- RwLock asynchrone pour thread safety
- Runtime asynchrone pour les méthodes async
- **serde** (1.0.228, features: derive)
- Sérialisation/désérialisation de Track
- Support JSON/autres formats si nécessaire
### Build-time
- **include_bytes!** (macro std)
- Intégration de l'image par défaut dans le binaire
## Performance
### Complexité algorithmique
- `append_track()` : O(1) amorti (VecDeque::push_back + potentiel pop_front)
- `remove_oldest()` : O(1) (VecDeque::pop_front)
- `remove_by_id()` : O(n) (recherche linéaire + VecDeque::remove)
- `get_items()` : O(k) où k = count (iteration + clone)
- `clear()` : O(n) (libération de tous les tracks)
### Allocation mémoire
- Chaque `Track` : ~100-200 bytes (selon la taille des strings)
- VecDeque overhead : ~24 bytes + capacity
- RwLock overhead : ~40 bytes
- Arc overhead : ~16 bytes
**Exemple** : Une playlist de 20 tracks ≈ 2-4 KB
### Lock contention
**Read-heavy workload** : Excellent (RwLock permet plusieurs lecteurs)
**Write-heavy workload** : Acceptable (les écritures sont généralement peu fréquentes pour une playlist)
**Recommandation** : Pour des milliers d'écritures/seconde, envisager un design lock-free ou sharding.
## Extensions futures possibles
### 1. Persistence
```rust
impl FifoPlaylist {
pub async fn save_to_disk(&self, path: &Path) -> io::Result<()>;
pub async fn load_from_disk(path: &Path) -> io::Result<Self>;
}
```
### 2. Événements et callbacks
```rust
pub enum PlaylistEvent {
TrackAdded(Track),
TrackRemoved(String),
Cleared,
}
impl FifoPlaylist {
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<PlaylistEvent>;
}
```
### 3. Indexation et recherche
```rust
impl FifoPlaylist {
pub async fn find_by_artist(&self, artist: &str) -> Vec<Track>;
pub async fn find_by_title(&self, title: &str) -> Vec<Track>;
}
```
### 4. Statistiques
```rust
impl FifoPlaylist {
pub async fn total_duration(&self) -> u32;
pub async fn most_common_artist(&self) -> Option<String>;
}
```
## Licence
Ce projet fait partie du workspace PMOMusic.

100
pmoplaylist/CHANGELOG.md Normal file
View File

@@ -0,0 +1,100 @@
# Changelog
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/).
## [Non publié]
## [0.1.0] - 2025-10-16
### Ajouté
#### Structures de base
- Struct `Track` pour représenter un track audio avec :
- Identifiant unique
- Métadonnées (titre, artiste, album, durée)
- URI du fichier/flux
- URL optionnelle pour l'image/cover
- Struct `FifoPlaylist` pour gérer une playlist FIFO avec :
- Capacité configurable
- Gestion automatique de la rotation (suppression des anciens tracks)
- Thread-safety via `Arc<RwLock>`
- Support asynchrone avec tokio
#### Fonctionnalités principales
- **Gestion FIFO** :
- `append_track()` : Ajoute un track (supprime le plus ancien si capacité atteinte)
- `remove_oldest()` : Supprime le track le plus ancien
- `remove_by_id()` : Supprime un track par son ID
- `clear()` : Vide complètement la playlist
- `get_items()` : Navigation partielle avec offset/count
- **Détection de changements** :
- `update_id()` : Compteur incrémenté à chaque modification
- `last_change()` : Timestamp de la dernière modification
- Compatibilité avec le protocole UPnP ContentDirectory
- **Génération DIDL-Lite** :
- `as_container()` : Génère un Container DIDL-Lite pour ContentDirectory
- `as_container_with_parent()` : Génère un Container avec parent_id personnalisé
- `as_objects()` : Génère des Items DIDL-Lite avec pagination
- Mapping complet Track → DIDL Item (métadonnées, ressources, images)
- **Image par défaut** :
- Image WebP 300x300 intégrée au binaire
- Note de musique néon sur fond de briques
- Taille optimisée (~10 KB)
- Accès via `default_image()`
#### API ergonomique
- Builder pattern pour `Track` :
- `with_artist()`, `with_album()`, `with_duration()`, `with_image()`
- Méthodes utilitaires :
- `len()`, `is_empty()`, `id()`, `title()`
- Toutes les méthodes sont asynchrones et thread-safe
#### Documentation
- Documentation complète avec rustdoc
- README.md avec :
- Guide d'installation
- Exemples d'utilisation
- API complète
- Cas d'usage (radio, album, playlist)
- ARCHITECTURE.md avec :
- Détails d'implémentation
- Design patterns utilisés
- Guide d'intégration
- Performance et complexité algorithmique
#### Exemples
- `basic_usage.rs` : Utilisation basique de toutes les fonctionnalités
- `radio_streaming.rs` : Simulation d'une radio en streaming multi-thread
- `http_server_integration.rs` : Intégration avec un serveur HTTP
#### Tests
- 11 tests unitaires couvrant :
- Création et état initial
- Ajout de tracks
- Suppression de tracks (oldest, by_id, clear)
- Navigation et pagination
- Génération DIDL-Lite
- Builder pattern
- Gestion de l'update_id
- 8 doctests intégrés dans la documentation
- 100% de réussite des tests
### Dépendances
- `pmodidl` (local) : Structures DIDL-Lite pour UPnP
- `tokio` 1.42.0 : Runtime asynchrone et RwLock
- `serde` 1.0.228 : Sérialisation de Track
### Notes techniques
- Edition Rust : 2024
- MSRV (Minimum Supported Rust Version) : Non spécifié (version stable recommandée)
- Thread-safe : Oui (Arc + RwLock)
- Async-first : Toutes les méthodes publiques sont async
[Non publié]: https://github.com/user/repo/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/user/repo/releases/tag/v0.1.0

9
pmoplaylist/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "pmoplaylist"
version = "0.1.0"
edition = "2024"
[dependencies]
pmodidl = { path = "../pmodidl" }
tokio = { version = "1.42.0", features = ["sync", "time", "macros", "rt", "rt-multi-thread"] }
serde = { version = "1.0.228", features = ["derive"] }

507
pmoplaylist/README.md Normal file
View File

@@ -0,0 +1,507 @@
# pmoplaylist
FIFO Audio Universelle pour MediaServer UPnP/OpenHome en Rust.
## Description
`pmoplaylist` fournit une abstraction de playlist/container audio avec :
- ✅ Gestion de FIFO audio avec capacité configurable
- ✅ Exposition d'objets DIDL-Lite via `pmodidl`
- ✅ Support `update_id` et `last_change` pour signaler les modifications
- ✅ Image par défaut intégrée pour le container racine (WebP)
- ✅ Thread-safe avec `tokio` et `Arc<RwLock>`
- ✅ API asynchrone compatible avec les MediaServers UPnP
## Installation
Ajoutez cette crate à votre `Cargo.toml` :
```toml
[dependencies]
pmoplaylist = { path = "../pmoplaylist" }
tokio = { version = "1.42.0", features = ["full"] }
```
## Utilisation de base
### Créer une playlist FIFO
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
// Créer une FIFO avec capacité de 10 tracks
let playlist = FifoPlaylist::new(
"radio-1".to_string(),
"Ma Radio Préférée".to_string(),
10,
pmoplaylist::DEFAULT_IMAGE,
);
// Vérifier l'état initial
assert_eq!(playlist.len().await, 0);
assert!(playlist.is_empty().await);
}
```
### Ajouter des tracks
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"my-playlist".to_string(),
"My Playlist".to_string(),
50,
pmoplaylist::DEFAULT_IMAGE,
);
// Méthode simple
let track1 = Track::new(
"track-1",
"Bohemian Rhapsody",
"http://example.com/queen/bohemian.flac"
);
playlist.append_track(track1).await;
// Avec builder pattern pour métadonnées complètes
let track2 = Track::new("track-2", "Stairway to Heaven", "http://example.com/zeppelin/stairway.mp3")
.with_artist("Led Zeppelin")
.with_album("Led Zeppelin IV")
.with_duration(482)
.with_image("http://example.com/covers/lz4.jpg");
playlist.append_track(track2).await;
println!("Nombre de tracks: {}", playlist.len().await);
}
```
### Gestion FIFO automatique
La FIFO supprime automatiquement les tracks les plus anciens quand la capacité est atteinte :
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
// Créer une FIFO avec capacité de 3 tracks seulement
let playlist = FifoPlaylist::new(
"small-fifo".to_string(),
"Petite FIFO".to_string(),
3,
pmoplaylist::DEFAULT_IMAGE,
);
// Ajouter 5 tracks
for i in 0..5 {
playlist.append_track(Track::new(
format!("track-{}", i),
format!("Song {}", i),
format!("http://example.com/{}.mp3", i)
)).await;
}
// Seuls les 3 derniers restent (tracks 2, 3, 4)
assert_eq!(playlist.len().await, 3);
let items = playlist.get_items(0, 10).await;
assert_eq!(items[0].id, "track-2");
assert_eq!(items[2].id, "track-4");
}
```
### Navigation et pagination
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"big-playlist".to_string(),
"Grande Playlist".to_string(),
100,
pmoplaylist::DEFAULT_IMAGE,
);
// Ajouter 50 tracks
for i in 0..50 {
playlist.append_track(Track::new(
format!("track-{}", i),
format!("Song {}", i),
format!("http://example.com/{}.mp3", i)
)).await;
}
// Récupérer les tracks 10 à 19 (navigation paginée)
let page = playlist.get_items(10, 10).await;
assert_eq!(page.len(), 10);
assert_eq!(page[0].id, "track-10");
}
```
### Supprimer des tracks
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"playlist-1".to_string(),
"My Playlist".to_string(),
10,
pmoplaylist::DEFAULT_IMAGE,
);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await;
// Supprimer le plus ancien (FIFO)
let removed = playlist.remove_oldest().await;
assert_eq!(removed.unwrap().id, "track-1");
// Supprimer par ID
playlist.remove_by_id("track-2").await;
// Vider complètement
playlist.clear().await;
assert!(playlist.is_empty().await);
}
```
### Détection de changements (update_id)
L'`update_id` est incrémenté à chaque modification de la playlist :
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"watched-playlist".to_string(),
"Watched Playlist".to_string(),
10,
pmoplaylist::DEFAULT_IMAGE,
);
let initial_id = playlist.update_id().await;
assert_eq!(initial_id, 0);
// Chaque opération incrémente l'update_id
playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await;
assert_eq!(playlist.update_id().await, 1);
playlist.append_track(Track::new("track-2", "Song", "http://example.com/2.mp3")).await;
assert_eq!(playlist.update_id().await, 2);
playlist.remove_oldest().await;
assert_eq!(playlist.update_id().await, 3);
// Timestamp de dernière modification
let last_change = playlist.last_change().await;
println!("Dernière modification: {:?}", last_change);
}
```
## Intégration UPnP/DIDL-Lite
### Générer un Container DIDL-Lite
```rust
use pmoplaylist::FifoPlaylist;
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise".to_string(),
20,
pmoplaylist::DEFAULT_IMAGE,
);
// Générer le container pour ContentDirectory
let container = playlist.as_container().await;
println!("Container ID: {}", container.id);
println!("Title: {}", container.title);
println!("Child count: {:?}", container.child_count);
println!("Class: {}", container.class); // "object.container.playlistContainer"
}
```
### Générer des Items DIDL-Lite
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"radio-1".to_string(),
"Ma Radio".to_string(),
10,
pmoplaylist::DEFAULT_IMAGE,
);
// Ajouter des tracks
let track = Track::new("track-1", "Bohemian Rhapsody", "http://example.com/song.mp3")
.with_artist("Queen")
.with_album("A Night at the Opera")
.with_duration(354);
playlist.append_track(track).await;
// Générer les items DIDL-Lite avec URL de l'image par défaut
let items = playlist.as_objects(
0, // offset
10, // count
Some("http://myserver/default.webp") // URL pour l'image par défaut
).await;
for item in items {
println!("Item: {}", item.title);
println!(" Artist: {:?}", item.artist);
println!(" Album: {:?}", item.album);
println!(" URI: {}", item.resources[0].url);
println!(" Class: {}", item.class); // "object.item.audioItem.musicTrack"
}
}
```
### Servir l'image par défaut
```rust
use pmoplaylist::FifoPlaylist;
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"radio-1".to_string(),
"Ma Radio".to_string(),
10,
pmoplaylist::DEFAULT_IMAGE,
);
// Récupérer les bytes de l'image par défaut
let image_bytes = playlist.default_image().await;
// Peut être servi via un endpoint HTTP, par exemple avec Axum:
// Response::builder()
// .status(200)
// .header("Content-Type", "image/webp")
// .body(image_bytes.to_vec())
}
```
## Cas d'usage
### Radio dynamique en streaming
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
// Radio avec historique limité à 20 tracks
let radio = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise".to_string(),
20,
pmoplaylist::DEFAULT_IMAGE,
);
// Simuler l'ajout de tracks au fur et à mesure du streaming
// Les anciens tracks sont automatiquement supprimés
for i in 0..100 {
let track = Track::new(
format!("track-{}", i),
format!("Now Playing: Song {}", i),
format!("http://stream.radio.com/track/{}", i)
);
radio.append_track(track).await;
// La radio conserve toujours les 20 derniers tracks
assert!(radio.len().await <= 20);
}
}
```
### Album statique
```rust
use pmoplaylist::{FifoPlaylist, Track};
#[tokio::main]
async fn main() {
// Album avec tous les tracks
let album = FifoPlaylist::new(
"album-dsotm".to_string(),
"The Dark Side of the Moon".to_string(),
100, // Capacité large pour un album complet
pmoplaylist::DEFAULT_IMAGE,
);
// Ajouter tous les tracks de l'album
let tracks = vec![
("1", "Speak to Me", 90),
("2", "Breathe", 163),
("3", "On the Run", 216),
("4", "Time", 413),
("5", "The Great Gig in the Sky", 283),
("6", "Money", 382),
("7", "Us and Them", 462),
("8", "Any Colour You Like", 205),
("9", "Brain Damage", 228),
("10", "Eclipse", 123),
];
for (track_num, title, duration) in tracks {
album.append_track(
Track::new(
format!("dsotm-{}", track_num),
title,
format!("http://library.local/floyd/dsotm/{}.flac", track_num)
)
.with_artist("Pink Floyd")
.with_album("The Dark Side of the Moon")
.with_duration(duration)
).await;
}
}
```
## Thread Safety
`FifoPlaylist` est thread-safe et peut être cloné et partagé entre plusieurs threads/tasks :
```rust
use pmoplaylist::{FifoPlaylist, Track};
use tokio::task;
#[tokio::main]
async fn main() {
let playlist = FifoPlaylist::new(
"shared-playlist".to_string(),
"Shared Playlist".to_string(),
100,
pmoplaylist::DEFAULT_IMAGE,
);
// Cloner pour partager entre threads
let playlist_writer = playlist.clone();
let playlist_reader = playlist.clone();
// Thread d'écriture
let writer = task::spawn(async move {
for i in 0..10 {
playlist_writer.append_track(Track::new(
format!("track-{}", i),
format!("Song {}", i),
format!("http://example.com/{}.mp3", i)
)).await;
}
});
// Thread de lecture
let reader = task::spawn(async move {
loop {
let len = playlist_reader.len().await;
if len >= 10 {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}
println!("Playlist complète!");
});
writer.await.unwrap();
reader.await.unwrap();
}
```
## API complète
### `Track`
- `Track::new(id, title, uri)` - Crée un nouveau track
- `.with_artist(artist)` - Définit l'artiste
- `.with_album(album)` - Définit l'album
- `.with_duration(seconds)` - Définit la durée en secondes
- `.with_image(url)` - Définit l'URL de l'image
### `FifoPlaylist`
#### Création
- `FifoPlaylist::new(id, title, capacity, default_image)` - Crée une nouvelle playlist
#### Modification
- `.append_track(track)` - Ajoute un track (supprime le plus ancien si capacité atteinte)
- `.remove_oldest()` - Supprime le track le plus ancien
- `.remove_by_id(id)` - Supprime un track par son ID
- `.clear()` - Vide complètement la playlist
#### Lecture
- `.len()` - Nombre de tracks
- `.is_empty()` - Vérifie si vide
- `.get_items(offset, count)` - Récupère une portion des tracks
- `.id()` - Retourne l'ID de la playlist
- `.title()` - Retourne le titre de la playlist
#### Méta-données
- `.update_id()` - Retourne l'update_id actuel (incrémenté à chaque modification)
- `.last_change()` - Retourne le timestamp de dernière modification
#### DIDL-Lite
- `.as_container()` - Génère un Container DIDL-Lite (parent_id = "0")
- `.as_container_with_parent(parent_id)` - Génère un Container avec parent_id personnalisé
- `.as_objects(offset, count, default_image_url)` - Génère des Items DIDL-Lite
- `.default_image()` - Retourne les bytes de l'image par défaut
## Architecture
```
FifoPlaylist
├── Arc<RwLock<FifoPlaylistInner>>
│ ├── id: String
│ ├── title: String
│ ├── default_image: &'static [u8]
│ ├── capacity: usize
│ ├── queue: VecDeque<Track>
│ ├── update_id: u32
│ └── last_change: SystemTime
Track
├── id: String
├── title: String
├── artist: Option<String>
├── album: Option<String>
├── duration: Option<u32>
├── uri: String
└── image: Option<String>
```
## Dépendances
- `pmodidl` - Génération DIDL-Lite
- `tokio` - Runtime asynchrone et synchronisation
- `serde` - Sérialisation
## Tests
```bash
cargo test -p pmoplaylist
```
Tous les tests (unitaires et doctests) sont inclus et validés.
## Licence
Ce projet fait partie du workspace PMOMusic.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,149 @@
//! Exemple d'utilisation basique de pmoplaylist
//!
//! Pour exécuter cet exemple :
//! ```bash
//! cargo run -p pmoplaylist --example basic_usage
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
#[tokio::main]
async fn main() {
println!("=== Exemple pmoplaylist ===\n");
// 1. Créer une playlist FIFO
println!("1. Création d'une playlist avec capacité de 5 tracks...");
let playlist = FifoPlaylist::new(
"my-radio".to_string(),
"Ma Radio Préférée".to_string(),
5,
DEFAULT_IMAGE,
);
println!(" ✓ Playlist créée: {}", playlist.title().await);
println!(" ✓ ID: {}", playlist.id().await);
println!(" ✓ Capacité: 5 tracks");
println!(" ✓ Update ID initial: {}\n", playlist.update_id().await);
// 2. Ajouter des tracks
println!("2. Ajout de 3 tracks...");
let tracks = vec![
Track::new("track-1", "Bohemian Rhapsody", "http://example.com/queen/bohemian.flac")
.with_artist("Queen")
.with_album("A Night at the Opera")
.with_duration(354)
.with_image("http://example.com/covers/queen-anato.jpg"),
Track::new("track-2", "Stairway to Heaven", "http://example.com/zeppelin/stairway.mp3")
.with_artist("Led Zeppelin")
.with_album("Led Zeppelin IV")
.with_duration(482),
Track::new("track-3", "Hotel California", "http://example.com/eagles/hotel.flac")
.with_artist("Eagles")
.with_album("Hotel California")
.with_duration(391),
];
for track in tracks {
playlist.append_track(track.clone()).await;
println!(" ✓ Ajouté: {} - {}", track.title, track.artist.unwrap_or_default());
}
println!("\n Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 3. Tester le comportement FIFO
println!("3. Test du comportement FIFO (capacité = 5)...");
println!(" Ajout de 4 tracks supplémentaires...");
for i in 4..=7 {
let track = Track::new(
format!("track-{}", i),
format!("Song Number {}", i),
format!("http://example.com/songs/{}.mp3", i)
);
playlist.append_track(track).await;
}
println!(" ✓ Total tracks (limité par capacité): {}", playlist.len().await);
// Afficher les tracks actuels
let items = playlist.get_items(0, 10).await;
println!("\n Tracks actuels dans la FIFO:");
for (idx, track) in items.iter().enumerate() {
println!(" {}. {} ({})", idx + 1, track.title, track.id);
}
println!(" (Les tracks 1 et 2 ont été supprimés automatiquement)\n");
// 4. Supprimer le plus ancien
println!("4. Suppression du track le plus ancien...");
if let Some(removed) = playlist.remove_oldest().await {
println!(" ✓ Supprimé: {} ({})", removed.title, removed.id);
}
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 5. Supprimer par ID
println!("5. Suppression d'un track par ID (track-5)...");
if playlist.remove_by_id("track-5").await {
println!(" ✓ Track supprimé");
}
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 6. Générer un Container DIDL-Lite
println!("6. Génération du Container DIDL-Lite...");
let container = playlist.as_container().await;
println!(" Container:");
println!(" - ID: {}", container.id);
println!(" - Parent ID: {}", container.parent_id);
println!(" - Title: {}", container.title);
println!(" - Class: {}", container.class);
println!(" - Child Count: {}\n", container.child_count.unwrap_or_default());
// 7. Générer des Items DIDL-Lite
println!("7. Génération des Items DIDL-Lite...");
let didl_items = playlist.as_objects(
0,
10,
Some("http://myserver/api/default-image")
).await;
println!(" Items DIDL-Lite:");
for (idx, item) in didl_items.iter().enumerate() {
println!("\n Item {}:", idx + 1);
println!(" - ID: {}", item.id);
println!(" - Title: {}", item.title);
println!(" - Artist: {}", item.artist.as_deref().unwrap_or("N/A"));
println!(" - Album: {}", item.album.as_deref().unwrap_or("N/A"));
println!(" - Class: {}", item.class);
println!(" - Parent ID: {}", item.parent_id);
if !item.resources.is_empty() {
println!(" - Resource URI: {}", item.resources[0].url);
if let Some(ref duration) = item.resources[0].duration {
println!(" - Duration: {}", duration);
}
}
if let Some(ref art) = item.album_art {
println!(" - Album Art: {}", art);
}
}
// 8. Image par défaut
println!("\n8. Image par défaut...");
let default_image = playlist.default_image().await;
println!(" ✓ Taille de l'image par défaut: {} bytes", default_image.len());
println!(" (Cette image peut être servie via un endpoint HTTP)\n");
// 9. Vider la playlist
println!("9. Vidage de la playlist...");
playlist.clear().await;
println!(" ✓ Playlist vidée");
println!(" Total tracks: {}", playlist.len().await);
println!(" Is empty: {}", playlist.is_empty().await);
println!(" Update ID final: {}\n", playlist.update_id().await);
println!("=== Exemple terminé ===");
}

View File

@@ -0,0 +1,207 @@
//! Exemple d'intégration avec un serveur HTTP
//!
//! Cet exemple montre comment exposer une playlist FIFO via des endpoints HTTP simples.
//! Dans un vrai MediaServer UPnP, ces endpoints seraient appelés par le protocole ContentDirectory.
//!
//! Pour exécuter :
//! ```bash
//! cargo run -p pmoplaylist --example http_server_integration
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
use std::sync::Arc;
#[tokio::main]
async fn main() {
println!("=== Intégration HTTP Server ===\n");
// Créer une playlist partagée
let playlist = Arc::new(FifoPlaylist::new(
"my-radio".to_string(),
"My Internet Radio".to_string(),
20,
DEFAULT_IMAGE,
));
println!("📻 Playlist créée: {}", playlist.title().await);
println!("🆔 ID: {}\n", playlist.id().await);
// Ajouter quelques tracks initiaux
println!("📝 Ajout de tracks initiaux...");
let initial_tracks = vec![
("The Beatles", "Come Together", "Abbey Road", 259),
("Nirvana", "Smells Like Teen Spirit", "Nevermind", 301),
("Queen", "Bohemian Rhapsody", "A Night at the Opera", 354),
];
for (idx, (artist, title, album, duration)) in initial_tracks.iter().enumerate() {
playlist.append_track(
Track::new(
format!("track-{}", idx),
*title,
format!("http://media.server/music/{}.flac", idx)
)
.with_artist(*artist)
.with_album(*album)
.with_duration(*duration)
.with_image(format!("http://media.server/covers/{}.jpg", idx))
).await;
println!("{} - {}", artist, title);
}
println!();
// Simuler différents endpoints HTTP
// 1. GET /playlist/container - Retourne le container DIDL-Lite
println!("🌐 Endpoint: GET /playlist/container");
simulate_get_container(playlist.clone()).await;
println!();
// 2. GET /playlist/items?offset=0&count=10 - Retourne les items
println!("🌐 Endpoint: GET /playlist/items?offset=0&count=10");
simulate_get_items(playlist.clone(), 0, 10).await;
println!();
// 3. GET /playlist/metadata - Retourne les métadonnées
println!("🌐 Endpoint: GET /playlist/metadata");
simulate_get_metadata(playlist.clone()).await;
println!();
// 4. POST /playlist/track - Ajoute un nouveau track
println!("🌐 Endpoint: POST /playlist/track");
let new_track = Track::new(
"track-new-1",
"Stairway to Heaven",
"http://media.server/music/stairway.flac"
)
.with_artist("Led Zeppelin")
.with_album("Led Zeppelin IV")
.with_duration(482);
simulate_add_track(playlist.clone(), new_track).await;
println!();
// 5. DELETE /playlist/oldest - Supprime le plus ancien
println!("🌐 Endpoint: DELETE /playlist/oldest");
simulate_delete_oldest(playlist.clone()).await;
println!();
// 6. GET /playlist/default-image - Retourne l'image par défaut
println!("🌐 Endpoint: GET /playlist/default-image");
simulate_get_default_image(playlist.clone()).await;
println!();
// 7. Vérifier l'état final
println!("📊 État final:");
let final_items = playlist.get_items(0, 10).await;
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}", playlist.update_id().await);
println!("\n Tracks actuels:");
for (idx, track) in final_items.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
println!(" {}. {} - {}", idx + 1, artist, track.title);
}
println!("\n=== Exemple terminé ===");
}
/// Simule GET /playlist/container
async fn simulate_get_container(playlist: Arc<FifoPlaylist>) {
let container = playlist.as_container().await;
println!(" Response (JSON representation):");
println!(" {{");
println!(" \"id\": \"{}\",", container.id);
println!(" \"parentId\": \"{}\",", container.parent_id);
println!(" \"title\": \"{}\",", container.title);
println!(" \"class\": \"{}\",", container.class);
println!(" \"childCount\": {}", container.child_count.unwrap_or_default());
println!(" }}");
}
/// Simule GET /playlist/items?offset=X&count=Y
async fn simulate_get_items(playlist: Arc<FifoPlaylist>, offset: usize, count: usize) {
let items = playlist.as_objects(
offset,
count,
Some("http://media.server/api/default-image")
).await;
println!(" Response: {} items", items.len());
println!(" [");
for (idx, item) in items.iter().enumerate() {
println!(" {{");
println!(" \"id\": \"{}\",", item.id);
println!(" \"title\": \"{}\",", item.title);
println!(" \"artist\": \"{}\",", item.artist.as_deref().unwrap_or(""));
println!(" \"album\": \"{}\",", item.album.as_deref().unwrap_or(""));
println!(" \"class\": \"{}\",", item.class);
if !item.resources.is_empty() {
println!(" \"uri\": \"{}\",", item.resources[0].url);
}
print!(" }}");
if idx < items.len() - 1 {
println!(",");
} else {
println!();
}
}
println!(" ]");
}
/// Simule GET /playlist/metadata
async fn simulate_get_metadata(playlist: Arc<FifoPlaylist>) {
let update_id = playlist.update_id().await;
let last_change = playlist.last_change().await;
let count = playlist.len().await;
let id = playlist.id().await;
let title = playlist.title().await;
println!(" Response:");
println!(" {{");
println!(" \"id\": \"{}\",", id);
println!(" \"title\": \"{}\",", title);
println!(" \"trackCount\": {},", count);
println!(" \"updateId\": {},", update_id);
println!(" \"lastChange\": \"{:?}\"", last_change);
println!(" }}");
}
/// Simule POST /playlist/track
async fn simulate_add_track(playlist: Arc<FifoPlaylist>, track: Track) {
let old_update_id = playlist.update_id().await;
playlist.append_track(track.clone()).await;
let new_update_id = playlist.update_id().await;
println!(" Track added: {} - {}",
track.artist.as_deref().unwrap_or("Unknown"),
track.title
);
println!(" Update ID: {}{}", old_update_id, new_update_id);
println!(" Response: 201 Created");
}
/// Simule DELETE /playlist/oldest
async fn simulate_delete_oldest(playlist: Arc<FifoPlaylist>) {
if let Some(removed) = playlist.remove_oldest().await {
println!(" Track removed: {} ({})", removed.title, removed.id);
println!(" New update ID: {}", playlist.update_id().await);
println!(" Response: 200 OK");
} else {
println!(" No tracks to remove");
println!(" Response: 404 Not Found");
}
}
/// Simule GET /playlist/default-image
async fn simulate_get_default_image(playlist: Arc<FifoPlaylist>) {
let image_bytes = playlist.default_image().await;
println!(" Response:");
println!(" Content-Type: image/webp");
println!(" Content-Length: {} bytes", image_bytes.len());
println!(" Status: 200 OK");
println!(" (Image WebP {} bytes ready to serve)", image_bytes.len());
}

View File

@@ -0,0 +1,173 @@
//! Exemple simulant une radio en streaming
//!
//! Cet exemple démontre :
//! - L'utilisation de FifoPlaylist dans un contexte multi-thread
//! - La simulation d'un flux radio continu
//! - La surveillance des changements via update_id
//!
//! Pour exécuter :
//! ```bash
//! cargo run -p pmoplaylist --example radio_streaming
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
println!("=== Simulation Radio en Streaming ===\n");
// Créer une radio avec historique limité à 10 tracks
let radio = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise - Main Mix".to_string(),
10,
DEFAULT_IMAGE,
);
println!("📻 Radio créée: {}", radio.title().await);
println!("📊 Capacité: 10 tracks (historique limité)");
println!("🆔 ID: {}\n", radio.id().await);
// Cloner pour les différentes tâches
let radio_streamer = radio.clone();
let radio_monitor = radio.clone();
let radio_client = radio.clone();
// Tâche 1: Simuler le streaming (ajoute des tracks régulièrement)
let streamer = tokio::spawn(async move {
println!("🎵 [STREAMER] Démarrage du flux radio...\n");
let tracks_data = vec![
("Radiohead", "Paranoid Android", "OK Computer", 383),
("Massive Attack", "Teardrop", "Mezzanine", 329),
("Pink Floyd", "Shine On You Crazy Diamond", "Wish You Were Here", 810),
("Portishead", "Glory Box", "Dummy", 305),
("Dire Straits", "Sultans of Swing", "Dire Straits", 349),
("The Cure", "Pictures of You", "Disintegration", 428),
("David Bowie", "Heroes", "Heroes", 371),
("Talking Heads", "Once in a Lifetime", "Remain in Light", 259),
("Fleetwood Mac", "Dreams", "Rumours", 257),
("The Smiths", "There Is a Light That Never Goes Out", "The Queen Is Dead", 244),
("Joy Division", "Love Will Tear Us Apart", "Closer", 206),
("New Order", "Blue Monday", "Power, Corruption & Lies", 448),
("Depeche Mode", "Enjoy the Silence", "Violator", 376),
("R.E.M.", "Losing My Religion", "Out of Time", 269),
("U2", "Where the Streets Have No Name", "The Joshua Tree", 337),
];
for (idx, (artist, title, album, duration)) in tracks_data.iter().enumerate() {
let track = Track::new(
format!("radio-track-{}", idx),
*title,
format!("http://stream.radioparadise.com/track/{}", idx)
)
.with_artist(*artist)
.with_album(*album)
.with_duration(*duration);
radio_streamer.append_track(track).await;
println!("🎵 [STREAMER] Now Playing: {} - {}", artist, title);
// Simuler l'attente entre les tracks
sleep(Duration::from_millis(500)).await;
}
println!("\n🎵 [STREAMER] Fin du streaming");
});
// Tâche 2: Monitorer les changements (update_id)
let monitor = tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
println!("👁️ [MONITOR] Surveillance des changements...\n");
let mut last_update_id = 0;
let mut iterations = 0;
loop {
let current_update_id = radio_monitor.update_id().await;
let count = radio_monitor.len().await;
if current_update_id != last_update_id {
println!(
"👁️ [MONITOR] Changement détecté! Update ID: {}{} | Tracks: {}",
last_update_id,
current_update_id,
count
);
last_update_id = current_update_id;
}
iterations += 1;
if iterations >= 50 {
break;
}
sleep(Duration::from_millis(200)).await;
}
println!("\n👁️ [MONITOR] Fin de la surveillance");
});
// Tâche 3: Client consultant l'historique
let client = tokio::spawn(async move {
sleep(Duration::from_millis(2000)).await;
println!("\n📱 [CLIENT] Consultation de l'historique de la radio...\n");
// Consulter plusieurs fois pendant le streaming
for i in 0..3 {
sleep(Duration::from_millis(2000)).await;
let history = radio_client.get_items(0, 10).await;
let update_id = radio_client.update_id().await;
println!("📱 [CLIENT] Consultation #{} (Update ID: {})", i + 1, update_id);
println!(" Historique actuel ({} tracks):", history.len());
for (idx, track) in history.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
println!(" {}. {} - {}", idx + 1, artist, track.title);
}
println!();
}
// Générer le container DIDL-Lite à la fin
println!("📱 [CLIENT] Génération du Container DIDL-Lite...");
let container = radio_client.as_container().await;
println!(" Container ID: {}", container.id);
println!(" Title: {}", container.title);
println!(" Child Count: {}", container.child_count.unwrap_or_default());
println!("\n📱 [CLIENT] Fin de la consultation");
});
// Attendre que toutes les tâches se terminent
let _ = tokio::join!(streamer, monitor, client);
// Afficher l'état final
println!("\n=== État Final ===");
println!("📊 Total tracks dans la radio: {}", radio.len().await);
println!("🆔 Update ID final: {}", radio.update_id().await);
let final_history = radio.get_items(0, 10).await;
println!("\n🎵 Historique final (10 derniers tracks):");
for (idx, track) in final_history.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
let duration_min = track.duration.map(|d| d / 60).unwrap_or(0);
let duration_sec = track.duration.map(|d| d % 60).unwrap_or(0);
println!(
" {}. {} - {} ({}:{:02})",
idx + 1,
artist,
track.title,
duration_min,
duration_sec
);
}
println!("\n=== Simulation terminée ===");
}

774
pmoplaylist/src/lib.rs Normal file
View File

@@ -0,0 +1,774 @@
//! # pmoplaylist - FIFO Audio Universelle pour MediaServer UPnP/OpenHome
//!
//! Cette crate fournit une abstraction de playlist/container audio avec :
//! - Gestion de FIFO audio avec capacité configurable
//! - Exposition d'objets DIDL-Lite via `pmodidl`
//! - Support update_id et last_change pour signaler les modifications
//! - Image par défaut pour le container racine
//!
//! # Exemples
//!
//! ```
//! use pmoplaylist::{FifoPlaylist, Track};
//!
//! # #[tokio::main]
//! # async fn main() {
//! // Créer une FIFO avec capacité de 10 tracks
//! let mut playlist = FifoPlaylist::new(
//! "radio-1".to_string(),
//! "Ma Radio Préférée".to_string(),
//! 10,
//! pmoplaylist::DEFAULT_IMAGE,
//! );
//!
//! // Ajouter un track
//! let track = Track {
//! id: "track-1".to_string(),
//! title: "Bohemian Rhapsody".to_string(),
//! artist: Some("Queen".to_string()),
//! album: Some("A Night at the Opera".to_string()),
//! duration: Some(354),
//! uri: "http://example.com/song.mp3".to_string(),
//! image: None,
//! };
//!
//! playlist.append_track(track).await;
//!
//! // Récupérer les items pour ContentDirectory
//! let items = playlist.get_items(0, 10).await;
//! println!("Nombre de tracks: {}", items.len());
//!
//! // Générer le container DIDL-Lite
//! let container = playlist.as_container().await;
//! println!("Container ID: {}", container.id);
//! # }
//! ```
use pmodidl::{Container, Item, Resource};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
/// Image WebP par défaut embarquée (1x1 pixel transparent)
/// Remplacez ceci par votre propre image WebP si nécessaire
pub const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
/// Représente un track audio dans la FIFO
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Track {
/// Identifiant unique du track
pub id: String,
/// Titre du track
pub title: String,
/// Artiste (optionnel)
pub artist: Option<String>,
/// Album (optionnel)
pub album: Option<String>,
/// Durée en secondes (optionnel)
pub duration: Option<u32>,
/// URI du flux ou fichier audio
pub uri: String,
/// URL de l'image/cover (optionnel, utilise l'image par défaut de la FIFO si absent)
pub image: Option<String>,
}
impl Track {
/// Crée un nouveau track avec les informations minimales
///
/// # Exemples
///
/// ```
/// use pmoplaylist::Track;
///
/// let track = Track::new(
/// "track-1",
/// "Bohemian Rhapsody",
/// "http://example.com/song.mp3"
/// );
/// ```
pub fn new(id: impl Into<String>, title: impl Into<String>, uri: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
artist: None,
album: None,
duration: None,
uri: uri.into(),
image: None,
}
}
/// Définit l'artiste du track
pub fn with_artist(mut self, artist: impl Into<String>) -> Self {
self.artist = Some(artist.into());
self
}
/// Définit l'album du track
pub fn with_album(mut self, album: impl Into<String>) -> Self {
self.album = Some(album.into());
self
}
/// Définit la durée du track en secondes
pub fn with_duration(mut self, duration: u32) -> Self {
self.duration = Some(duration);
self
}
/// Définit l'URL de l'image du track
pub fn with_image(mut self, image: impl Into<String>) -> Self {
self.image = Some(image.into());
self
}
/// Convertit le track en Item DIDL-Lite
///
/// # Arguments
///
/// * `parent_id` - ID du container parent
/// * `default_image` - Image par défaut si le track n'en a pas
fn to_didl_item(&self, parent_id: &str, default_image: Option<&str>) -> Item {
// Formater la durée au format H:MM:SS
let duration_str = self.duration.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{}:{:02}:{:02}", hours, minutes, seconds)
});
// Utiliser l'image du track ou l'image par défaut
let album_art = self.image.as_deref().or(default_image).map(String::from);
// Créer la ressource audio
let resource = Resource {
protocol_info: "http-get:*:audio/*:*".to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: duration_str,
url: self.uri.clone(),
};
Item {
id: self.id.clone(),
parent_id: parent_id.to_string(),
restricted: Some("1".to_string()),
title: self.title.clone(),
creator: self.artist.clone(),
class: "object.item.audioItem.musicTrack".to_string(),
artist: self.artist.clone(),
album: self.album.clone(),
genre: None,
album_art,
album_art_pk: None,
date: None,
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
}
}
}
/// FIFO playlist thread-safe avec capacité configurable
#[derive(Clone)]
pub struct FifoPlaylist {
inner: Arc<RwLock<FifoPlaylistInner>>,
}
struct FifoPlaylistInner {
/// Identifiant unique de la FIFO
id: String,
/// Titre de la FIFO
title: String,
/// Image par défaut (WebP embarquée)
default_image: &'static [u8],
/// Capacité maximale de la FIFO
capacity: usize,
/// Queue FIFO des tracks
queue: VecDeque<Track>,
/// Numéro de version pour signaler les modifications
update_id: u32,
/// Timestamp de la dernière modification
last_change: SystemTime,
}
impl FifoPlaylist {
/// Crée une nouvelle FIFO playlist
///
/// # Arguments
///
/// * `id` - Identifiant unique de la playlist
/// * `title` - Titre de la playlist
/// * `capacity` - Capacité maximale (nombre de tracks)
/// * `default_image` - Image par défaut en format WebP
///
/// # Exemples
///
/// ```
/// use pmoplaylist::FifoPlaylist;
///
/// let playlist = FifoPlaylist::new(
/// "radio-1".to_string(),
/// "Ma Radio".to_string(),
/// 10,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
/// ```
pub fn new(
id: String,
title: String,
capacity: usize,
default_image: &'static [u8],
) -> Self {
Self {
inner: Arc::new(RwLock::new(FifoPlaylistInner {
id,
title,
default_image,
capacity,
queue: VecDeque::new(),
update_id: 0,
last_change: SystemTime::now(),
})),
}
}
/// Ajoute un track à la fin de la FIFO
///
/// Si la capacité est atteinte, le track le plus ancien est supprimé automatiquement.
/// Met à jour `update_id` et `last_change`.
///
/// # Arguments
///
/// * `track` - Le track à ajouter
///
/// # Exemples
///
/// ```
/// use pmoplaylist::{FifoPlaylist, Track};
///
/// # #[tokio::main]
/// # async fn main() {
/// let mut playlist = FifoPlaylist::new(
/// "playlist-1".to_string(),
/// "My Playlist".to_string(),
/// 5,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
///
/// let track = Track::new("track-1", "Song Title", "http://example.com/song.mp3");
/// playlist.append_track(track).await;
/// # }
/// ```
pub async fn append_track(&self, track: Track) {
let mut inner = self.inner.write().await;
// Si la capacité est atteinte, supprimer le plus ancien
if inner.queue.len() >= inner.capacity {
inner.queue.pop_front();
}
inner.queue.push_back(track);
inner.update_id = inner.update_id.wrapping_add(1);
inner.last_change = SystemTime::now();
}
/// Supprime le track le plus ancien de la FIFO
///
/// Met à jour `update_id` et `last_change` si un track est supprimé.
/// Retourne le track supprimé, ou None si la FIFO est vide.
///
/// # Exemples
///
/// ```
/// use pmoplaylist::{FifoPlaylist, Track};
///
/// # #[tokio::main]
/// # async fn main() {
/// let mut playlist = FifoPlaylist::new(
/// "playlist-1".to_string(),
/// "My Playlist".to_string(),
/// 5,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
///
/// playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await;
///
/// let removed = playlist.remove_oldest().await;
/// assert!(removed.is_some());
/// # }
/// ```
pub async fn remove_oldest(&self) -> Option<Track> {
let mut inner = self.inner.write().await;
let track = inner.queue.pop_front();
if track.is_some() {
inner.update_id = inner.update_id.wrapping_add(1);
inner.last_change = SystemTime::now();
}
track
}
/// Supprime un track par son ID
///
/// Met à jour `update_id` et `last_change` si un track est supprimé.
/// Retourne true si un track a été supprimé, false sinon.
///
/// # Arguments
///
/// * `track_id` - L'ID du track à supprimer
pub async fn remove_by_id(&self, track_id: &str) -> bool {
let mut inner = self.inner.write().await;
if let Some(pos) = inner.queue.iter().position(|t| t.id == track_id) {
inner.queue.remove(pos);
inner.update_id = inner.update_id.wrapping_add(1);
inner.last_change = SystemTime::now();
true
} else {
false
}
}
/// Vide complètement la FIFO
///
/// Met à jour `update_id` et `last_change` si la FIFO n'était pas vide.
pub async fn clear(&self) {
let mut inner = self.inner.write().await;
if !inner.queue.is_empty() {
inner.queue.clear();
inner.update_id = inner.update_id.wrapping_add(1);
inner.last_change = SystemTime::now();
}
}
/// Retourne le nombre de tracks dans la FIFO
pub async fn len(&self) -> usize {
let inner = self.inner.read().await;
inner.queue.len()
}
/// Vérifie si la FIFO est vide
pub async fn is_empty(&self) -> bool {
let inner = self.inner.read().await;
inner.queue.is_empty()
}
/// Récupère une portion des tracks pour navigation partielle
///
/// # Arguments
///
/// * `offset` - Index de départ (0-based)
/// * `count` - Nombre maximum de tracks à retourner
///
/// # Retourne
///
/// Un vecteur de tracks, potentiellement vide si offset est hors limite
///
/// # Exemples
///
/// ```
/// use pmoplaylist::{FifoPlaylist, Track};
///
/// # #[tokio::main]
/// # async fn main() {
/// let mut playlist = FifoPlaylist::new(
/// "playlist-1".to_string(),
/// "My Playlist".to_string(),
/// 10,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
///
/// // Ajouter plusieurs tracks...
/// for i in 0..5 {
/// playlist.append_track(Track::new(
/// format!("track-{}", i),
/// format!("Song {}", i),
/// format!("http://example.com/{}.mp3", i)
/// )).await;
/// }
///
/// // Récupérer les tracks 2 à 4
/// let items = playlist.get_items(2, 2).await;
/// assert_eq!(items.len(), 2);
/// # }
/// ```
pub async fn get_items(&self, offset: usize, count: usize) -> Vec<Track> {
let inner = self.inner.read().await;
inner.queue
.iter()
.skip(offset)
.take(count)
.cloned()
.collect()
}
/// Retourne l'update_id actuel
///
/// L'update_id est incrémenté à chaque modification de la FIFO.
/// Utile pour détecter les changements côté client UPnP.
pub async fn update_id(&self) -> u32 {
let inner = self.inner.read().await;
inner.update_id
}
/// Retourne le timestamp de la dernière modification
pub async fn last_change(&self) -> SystemTime {
let inner = self.inner.read().await;
inner.last_change
}
/// Retourne l'ID de la playlist
pub async fn id(&self) -> String {
let inner = self.inner.read().await;
inner.id.clone()
}
/// Retourne le titre de la playlist
pub async fn title(&self) -> String {
let inner = self.inner.read().await;
inner.title.clone()
}
/// Génère un Container DIDL-Lite représentant cette FIFO
///
/// Le container peut être utilisé pour le ContentDirectory UPnP.
///
/// # Arguments
///
/// * `parent_id` - ID du container parent (par défaut "0" pour la racine)
///
/// # Exemples
///
/// ```
/// use pmoplaylist::FifoPlaylist;
///
/// # #[tokio::main]
/// # async fn main() {
/// let playlist = FifoPlaylist::new(
/// "radio-1".to_string(),
/// "Ma Radio".to_string(),
/// 10,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
///
/// let container = playlist.as_container_with_parent("0").await;
/// println!("Container: {:?}", container);
/// # }
/// ```
pub async fn as_container_with_parent(&self, parent_id: impl Into<String>) -> Container {
let inner = self.inner.read().await;
Container {
id: inner.id.clone(),
parent_id: parent_id.into(),
restricted: Some("1".to_string()),
child_count: Some(inner.queue.len().to_string()),
title: inner.title.clone(),
class: "object.container.playlistContainer".to_string(),
containers: vec![],
items: vec![],
}
}
/// Génère un Container DIDL-Lite avec parent_id = "0"
pub async fn as_container(&self) -> Container {
self.as_container_with_parent("0").await
}
/// Génère un vecteur d'objets DIDL-Lite Item correspondant aux tracks
///
/// # Arguments
///
/// * `offset` - Index de départ (0-based)
/// * `count` - Nombre maximum d'items à retourner
/// * `default_image_url` - URL optionnelle pour l'image par défaut (endpoint servant l'image)
///
/// # Exemples
///
/// ```
/// use pmoplaylist::{FifoPlaylist, Track};
///
/// # #[tokio::main]
/// # async fn main() {
/// let mut playlist = FifoPlaylist::new(
/// "radio-1".to_string(),
/// "Ma Radio".to_string(),
/// 10,
/// pmoplaylist::DEFAULT_IMAGE,
/// );
///
/// playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await;
///
/// let items = playlist.as_objects(0, 10, Some("http://server/default.webp")).await;
/// assert_eq!(items.len(), 1);
/// # }
/// ```
pub async fn as_objects(
&self,
offset: usize,
count: usize,
default_image_url: Option<&str>,
) -> Vec<Item> {
let inner = self.inner.read().await;
inner.queue
.iter()
.skip(offset)
.take(count)
.map(|track| track.to_didl_item(&inner.id, default_image_url))
.collect()
}
/// Retourne l'image par défaut en tant que slice de bytes
///
/// Peut être servi via un endpoint HTTP pour les clients UPnP
pub async fn default_image(&self) -> &'static [u8] {
let inner = self.inner.read().await;
inner.default_image
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_playlist() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
assert_eq!(playlist.len().await, 0);
assert!(playlist.is_empty().await);
assert_eq!(playlist.update_id().await, 0);
}
#[tokio::test]
async fn test_append_track() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
let track = Track::new("track-1", "Song 1", "http://example.com/1.mp3");
playlist.append_track(track).await;
assert_eq!(playlist.len().await, 1);
assert_eq!(playlist.update_id().await, 1);
}
#[tokio::test]
async fn test_fifo_capacity() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
3,
DEFAULT_IMAGE,
);
// Ajouter 5 tracks alors que la capacité est 3
for i in 0..5 {
let track = Track::new(
format!("track-{}", i),
format!("Song {}", i),
format!("http://example.com/{}.mp3", i),
);
playlist.append_track(track).await;
}
// Seuls les 3 derniers doivent rester
assert_eq!(playlist.len().await, 3);
let items = playlist.get_items(0, 10).await;
assert_eq!(items[0].id, "track-2");
assert_eq!(items[2].id, "track-4");
}
#[tokio::test]
async fn test_remove_oldest() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await;
let removed = playlist.remove_oldest().await;
assert!(removed.is_some());
assert_eq!(removed.unwrap().id, "track-1");
assert_eq!(playlist.len().await, 1);
}
#[tokio::test]
async fn test_remove_by_id() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await;
playlist.append_track(Track::new("track-3", "Song 3", "http://example.com/3.mp3")).await;
assert!(playlist.remove_by_id("track-2").await);
assert_eq!(playlist.len().await, 2);
let items = playlist.get_items(0, 10).await;
assert_eq!(items[0].id, "track-1");
assert_eq!(items[1].id, "track-3");
}
#[tokio::test]
async fn test_clear() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await;
playlist.clear().await;
assert_eq!(playlist.len().await, 0);
assert!(playlist.is_empty().await);
}
#[tokio::test]
async fn test_get_items_pagination() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
10,
DEFAULT_IMAGE,
);
for i in 0..5 {
playlist.append_track(Track::new(
format!("track-{}", i),
format!("Song {}", i),
format!("http://example.com/{}.mp3", i),
)).await;
}
let items = playlist.get_items(1, 2).await;
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "track-1");
assert_eq!(items[1].id, "track-2");
}
#[tokio::test]
async fn test_as_container() {
let playlist = FifoPlaylist::new(
"radio-1".to_string(),
"Test Radio".to_string(),
10,
DEFAULT_IMAGE,
);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
let container = playlist.as_container().await;
assert_eq!(container.id, "radio-1");
assert_eq!(container.title, "Test Radio");
assert_eq!(container.parent_id, "0");
assert_eq!(container.child_count, Some("1".to_string()));
}
#[tokio::test]
async fn test_as_objects() {
let playlist = FifoPlaylist::new(
"radio-1".to_string(),
"Test Radio".to_string(),
10,
DEFAULT_IMAGE,
);
let track = Track::new("track-1", "Bohemian Rhapsody", "http://example.com/song.mp3")
.with_artist("Queen")
.with_album("A Night at the Opera")
.with_duration(354);
playlist.append_track(track).await;
let items = playlist.as_objects(0, 10, Some("http://server/default.webp")).await;
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.id, "track-1");
assert_eq!(item.title, "Bohemian Rhapsody");
assert_eq!(item.artist, Some("Queen".to_string()));
assert_eq!(item.album, Some("A Night at the Opera".to_string()));
assert_eq!(item.parent_id, "radio-1");
assert!(item.resources.len() > 0);
}
#[tokio::test]
async fn test_track_builder() {
let track = Track::new("track-1", "Song", "http://example.com/song.mp3")
.with_artist("Artist")
.with_album("Album")
.with_duration(180)
.with_image("http://example.com/cover.jpg");
assert_eq!(track.artist, Some("Artist".to_string()));
assert_eq!(track.album, Some("Album".to_string()));
assert_eq!(track.duration, Some(180));
assert_eq!(track.image, Some("http://example.com/cover.jpg".to_string()));
}
#[tokio::test]
async fn test_update_id_increments() {
let playlist = FifoPlaylist::new(
"test-1".to_string(),
"Test Playlist".to_string(),
5,
DEFAULT_IMAGE,
);
assert_eq!(playlist.update_id().await, 0);
playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await;
assert_eq!(playlist.update_id().await, 1);
playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await;
assert_eq!(playlist.update_id().await, 2);
playlist.remove_oldest().await;
assert_eq!(playlist.update_id().await, 3);
playlist.clear().await;
assert_eq!(playlist.update_id().await, 4);
}
}