//! # 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, /// Album (optionnel) pub album: Option, /// Durée en secondes (optionnel) pub duration: Option, /// 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, } 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, title: impl Into, uri: impl Into) -> 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) -> Self { self.artist = Some(artist.into()); self } /// Définit l'album du track pub fn with_album(mut self, album: impl Into) -> 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) -> 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>, } 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, /// 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 { 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 un track existe déjà dans la playlist pub async fn has_track(&self, track_id: &str) -> bool { let inner = self.inner.read().await; inner.queue.iter().any(|t| t.id == track_id) } /// Met à jour un track existant en appliquant une fonction de mise à jour. /// /// Retourne `true` si le track a été trouvé et modifié. pub async fn update_track(&self, track_id: &str, updater: F) -> bool where F: FnOnce(&mut Track), { let mut inner = self.inner.write().await; if let Some(track) = inner.queue.iter_mut().find(|t| t.id == track_id) { updater(track); inner.update_id = inner.update_id.wrapping_add(1); inner.last_change = SystemTime::now(); true } else { false } } /// 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 { 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) -> 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()), searchable: Some("1".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 { 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); } }