Crée la crate pmoplaylist
This commit is contained in:
519
pmoplaylist/ARCHITECTURE.md
Normal file
519
pmoplaylist/ARCHITECTURE.md
Normal 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
100
pmoplaylist/CHANGELOG.md
Normal 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
9
pmoplaylist/Cargo.toml
Normal 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
507
pmoplaylist/README.md
Normal 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.
|
||||
BIN
pmoplaylist/assets/default.webp
Normal file
BIN
pmoplaylist/assets/default.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
149
pmoplaylist/examples/basic_usage.rs
Normal file
149
pmoplaylist/examples/basic_usage.rs
Normal 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é ===");
|
||||
}
|
||||
207
pmoplaylist/examples/http_server_integration.rs
Normal file
207
pmoplaylist/examples/http_server_integration.rs
Normal 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());
|
||||
}
|
||||
173
pmoplaylist/examples/radio_streaming.rs
Normal file
173
pmoplaylist/examples/radio_streaming.rs
Normal 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
774
pmoplaylist/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user