diff --git a/.DS_Store b/.DS_Store index a6136655..d2a6989e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Cargo.lock b/Cargo.lock index a7025439..f68c118c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2615,6 +2615,7 @@ dependencies = [ "flacenc 0.4.0", "futures-util", "lofty", + "paste", "pmocache", "pmoconfig", "pmodidl", @@ -2641,6 +2642,7 @@ dependencies = [ "chrono", "futures-util", "hex", + "paste", "pmoconfig", "reqwest", "rusqlite", diff --git a/pmoaudiocache/Cargo.toml b/pmoaudiocache/Cargo.toml index 246e2ebf..098e8e47 100644 --- a/pmoaudiocache/Cargo.toml +++ b/pmoaudiocache/Cargo.toml @@ -31,6 +31,7 @@ anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" quick-xml = { version = "0.37", features = ["serialize"] } +paste = "1.0" # Async tokio = { version = "1.0", features = ["full"] } diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 95bb893c..d2173fc3 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -78,6 +78,7 @@ pub mod cache; pub mod metadata; +pub mod metadata_ext; #[cfg(feature = "pmoserver")] pub mod openapi; @@ -88,6 +89,7 @@ pub mod config_ext; // Re-exports principaux pub use cache::{add_with_metadata_extraction, get_metadata, new_cache, AudioConfig, Cache}; pub use metadata::AudioMetadata; +pub use metadata_ext::AudioMetadataExt; #[cfg(feature = "pmoconfig")] pub use config_ext::AudioCacheConfigExt; diff --git a/pmoaudiocache/src/metadata.rs b/pmoaudiocache/src/metadata.rs index 7d46a50c..abfcd454 100644 --- a/pmoaudiocache/src/metadata.rs +++ b/pmoaudiocache/src/metadata.rs @@ -159,6 +159,81 @@ impl AudioMetadata { _ => None, } } + + /// Retourne la durée formatée pour DIDL-Lite (H:MM:SS) + /// + /// # Exemple + /// + /// ``` + /// use pmoaudiocache::AudioMetadata; + /// + /// let metadata = AudioMetadata { + /// duration_secs: Some(3665), + /// ..Default::default() + /// }; + /// + /// assert_eq!(metadata.duration_formatted(), Some("1:01:05".to_string())); + /// ``` + pub fn duration_formatted(&self) -> Option { + self.duration_secs.map(|d| { + let hours = d / 3600; + let minutes = (d % 3600) / 60; + let seconds = d % 60; + format!("{}:{:02}:{:02}", hours, minutes, seconds) + }) + } + + /// Convertit les métadonnées en Resource DIDL-Lite + /// + /// # Arguments + /// + /// * `url` - URL de la ressource audio + /// + /// # Exemple + /// + /// ```no_run + /// use pmoaudiocache::AudioMetadata; + /// + /// let metadata = AudioMetadata { + /// duration_secs: Some(180), + /// sample_rate: Some(44100), + /// channels: Some(2), + /// ..Default::default() + /// }; + /// + /// let resource = metadata.to_didl_resource("http://localhost:8080/audio/tracks/abc123".into()); + /// assert_eq!(resource.url, "http://localhost:8080/audio/tracks/abc123"); + /// ``` + pub fn to_didl_resource(&self, url: String) -> pmodidl::Resource { + pmodidl::Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: self.sample_rate.map(|sr| sr.to_string()), + nr_audio_channels: self.channels.map(|ch| ch.to_string()), + duration: self.duration_formatted(), + url, + } + } +} + +impl Default for AudioMetadata { + fn default() -> Self { + Self { + title: None, + artist: None, + album: None, + year: None, + track_number: None, + track_total: None, + disc_number: None, + disc_total: None, + genre: None, + duration_secs: None, + sample_rate: None, + channels: None, + bitrate: None, + } + } } #[cfg(test)] diff --git a/pmoaudiocache/src/metadata_ext.rs b/pmoaudiocache/src/metadata_ext.rs new file mode 100644 index 00000000..49fc5724 --- /dev/null +++ b/pmoaudiocache/src/metadata_ext.rs @@ -0,0 +1,35 @@ +//! Extension trait pour accéder aux métadonnées audio de manière typée +//! +//! Ce module utilise la macro `define_metadata_properties!` de pmocache +//! pour générer automatiquement des méthodes d'accès typées aux métadonnées audio. + +use pmocache::define_metadata_properties; +use crate::AudioConfig; + +// Génération automatique du trait AudioMetadataExt avec toutes les propriétés audio +define_metadata_properties! { + AudioMetadataExt for pmocache::Cache { + // Métadonnées textuelles + title: String as string, + artist: String as string, + album: String as string, + album_artist: String as string, + genre: String as string, + composer: String as string, + comment: String as string, + + // Métadonnées numériques (année, numéros de piste) + year: i64 as i64, + track_number: i64 as i64, + disc_number: i64 as i64, + total_tracks: i64 as i64, + total_discs: i64 as i64, + + // Métadonnées techniques audio + duration_secs: i64 as i64, + sample_rate: i64 as i64, + bitrate: i64 as i64, + channels: i64 as i64, + bit_depth: i64 as i64, + } +} diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index f4dee612..409df8b1 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -22,6 +22,7 @@ chrono = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" bytes = "1.6" +paste = "1.0" # Async tokio = { version = "1.0", features = ["full"] } diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 74f31f69..48ec1977 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -130,6 +130,20 @@ pub trait FileCache: Send + Sync { /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants async fn consolidate(&self) -> Result<()>; + + /// Vérifie si une clé primaire est valide (existe en DB et fichier présent) + /// + /// # Arguments + /// + /// * `pk` - Clé primaire à vérifier + /// + /// # Returns + /// + /// `true` si l'entrée existe en base de données et que le fichier est présent + fn is_valid_pk(&self, pk: &str) -> bool { + self.get_database().get(pk, false).is_ok() + && self.file_path(pk).exists() + } } /// Génère une clé primaire à partir des premiers octets d'un document @@ -163,20 +177,3 @@ pub fn pk_from_content_header(header: &[u8]) -> String { hex::encode(&result[..16]) // 16 octets = 32 caractères hex } -/// Génère une clé primaire à partir d'une URL (legacy) -/// -/// **DEPRECATED**: Cette fonction est obsolète et ne devrait plus être utilisée. -/// Utilisez `pk_from_content_header()` à la place pour générer des identifiants -/// basés sur le contenu plutôt que sur l'URL. -/// -/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal. -#[deprecated( - since = "0.2.0", - note = "Utilisez pk_from_content_header() pour des identifiants basés sur le contenu" -)] -pub fn pk_from_url(url: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(url.as_bytes()); - let result = hasher.finalize(); - hex::encode(&result[..8]) -} diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index e03f535f..d6fac890 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -115,6 +115,7 @@ pub mod cache; pub mod cache_trait; pub mod db; pub mod download; +pub mod metadata_macros; #[cfg(feature = "pmoserver")] pub mod pmoserver_ext; @@ -129,7 +130,7 @@ pub mod openapi; pub mod config_ext; pub use cache::{Cache, CacheConfig}; -pub use cache_trait::{pk_from_content_header, pk_from_url, FileCache}; +pub use cache_trait::{pk_from_content_header, FileCache}; pub use db::{CacheEntry, DB}; pub use download::{ download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header, diff --git a/pmocache/src/metadata_macros.rs b/pmocache/src/metadata_macros.rs new file mode 100644 index 00000000..72ba8217 --- /dev/null +++ b/pmocache/src/metadata_macros.rs @@ -0,0 +1,234 @@ +//! Macros pour générer des extension traits typés sur les métadonnées +//! +//! La macro `define_metadata_properties!` génère automatiquement : +//! - Un trait avec des méthodes `get_XXX()` et `set_XXX()` pour chaque métadonnée +//! - L'implémentation complète pour `Cache` +//! - Les conversions JSON ↔ Rust selon le type +//! +//! # Types supportés +//! +//! - `String` : Métadonnée texte (JSON String) +//! - `i64` : Métadonnée numérique entière signée (JSON Number) +//! - `f64` : Métadonnée numérique décimale (JSON Number) +//! - `bool` : Métadonnée booléenne (JSON Boolean) +//! - `Value` : Métadonnée JSON brute (Array, Object, ou tout type JSON) +//! +//! # Mécanisme de stockage +//! +//! - Types simples (String, Number, Boolean) : stockés directement +//! - Types complexes (Array, Object) : sérialisés en string JSON, parsés automatiquement à la lecture +//! - La conversion est transparente grâce à `decode_metadata_value` +//! +//! # Exemple +//! +//! ```ignore +//! use pmocache::define_metadata_properties; +//! +//! struct AudioConfig; +//! impl CacheConfig for AudioConfig { ... } +//! +//! define_metadata_properties! { +//! AudioMetadataExt for pmocache::Cache { +//! title: String as string, +//! duration_secs: i64 as i64, +//! custom_tags: serde_json::Value as value, // Pour JSON complexe +//! } +//! } +//! +//! // Utilisation +//! use AudioMetadataExt; +//! let title = cache.get_title("pk123").await?; +//! cache.set_title("pk123", "New Title".into()).await?; +//! +//! // JSON complexe +//! let tags = json!({"mood": "happy", "bpm": 120}); +//! cache.set_custom_tags("pk123", tags).await?; +//! ``` + +/// Génère un extension trait pour accéder aux métadonnées de manière typée +/// +/// Cette macro génère un trait complet avec toutes les méthodes get/set +/// et son implémentation pour le type de cache spécifié. +/// +/// # Syntaxe +/// +/// ```ignore +/// define_metadata_properties! { +/// TraitName for CacheType { +/// field_name: RustType as type_kind, +/// field_name2: RustType as type_kind, +/// ... +/// } +/// } +/// ``` +/// +/// Type kinds available: `string`, `i64`, `f64`, `bool`, `value` +/// +/// Pour chaque champ, génère : +/// - `async fn get_FIELD(&self, pk: &str) -> Result>` +/// - `async fn set_FIELD(&self, pk: &str, value: TYPE) -> Result<()>` +/// +/// La clé JSON utilisée est le nom du champ (ex: `title` → clé `"title"`). +#[macro_export] +macro_rules! define_metadata_properties { + ( + $trait_name:ident for $cache_type:ty { + $( + $field:ident: $rust_type:ty as $type_kind:ident + ),* $(,)? + } + ) => { + // Définition du trait + pub trait $trait_name { + $( + // Génère get_FIELD + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result>; + } + + // Génère set_FIELD + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()>; + } + )* + } + + // Implémentation du trait + impl $trait_name for $cache_type { + $( + // Implémentation de get_FIELD selon le type + $crate::__impl_getter!($field, $rust_type, $type_kind); + + // Implémentation de set_FIELD selon le type + $crate::__impl_setter!($field, $rust_type, $type_kind); + )* + } + }; +} + +// ============================================================================ +// Macros internes pour générer les getters selon le type +// ============================================================================ + +#[doc(hidden)] +#[macro_export] +macro_rules! __impl_getter { + // String - utilise get_a_metadata_as_string + ($field:ident, $rust_type:ty, string) => { + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result> { + self.get_a_metadata_as_string(pk, stringify!($field)).await + } + } + }; + + // i64 - utilise get_a_metadata_as_number puis as_i64() + ($field:ident, $rust_type:ty, i64) => { + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result> { + match self.get_a_metadata_as_number(pk, stringify!($field)).await? { + Some(n) => Ok(n.as_i64()), + None => Ok(None), + } + } + } + }; + + // f64 - utilise get_a_metadata_as_number puis as_f64() + ($field:ident, $rust_type:ty, f64) => { + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result> { + match self.get_a_metadata_as_number(pk, stringify!($field)).await? { + Some(n) => Ok(n.as_f64()), + None => Ok(None), + } + } + } + }; + + // bool - utilise get_a_metadata_as_bool + ($field:ident, $rust_type:ty, bool) => { + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result> { + self.get_a_metadata_as_bool(pk, stringify!($field)).await + } + } + }; + + // Value - utilise get_a_metadata directement (retourne JSON brut) + ($field:ident, $rust_type:ty, value) => { + paste::paste! { + async fn [](&self, pk: &str) -> anyhow::Result> { + self.get_a_metadata(pk, stringify!($field)).await + } + } + }; +} + +// ============================================================================ +// Macros internes pour générer les setters selon le type +// ============================================================================ + +#[doc(hidden)] +#[macro_export] +macro_rules! __impl_setter { + // String - stocke comme Value::String + ($field:ident, $rust_type:ty, string) => { + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> { + use serde_json::Value; + self.db.set_a_metadata(pk, stringify!($field), Value::String(value)) + .map_err(|e| anyhow::anyhow!("DB error: {}", e)) + } + } + }; + + // i64 - stocke comme Value::Number + ($field:ident, $rust_type:ty, i64) => { + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> { + use serde_json::{Value, Number}; + self.db.set_a_metadata( + pk, + stringify!($field), + Value::Number(Number::from(value)) + ) + .map_err(|e| anyhow::anyhow!("DB error: {}", e)) + } + } + }; + + // f64 - stocke comme Value::Number (avec validation) + ($field:ident, $rust_type:ty, f64) => { + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> { + use serde_json::{Value, Number}; + let number = Number::from_f64(value) + .ok_or_else(|| anyhow::anyhow!("Invalid f64 value: {}", value))?; + self.db.set_a_metadata(pk, stringify!($field), Value::Number(number)) + .map_err(|e| anyhow::anyhow!("DB error: {}", e)) + } + } + }; + + // bool - stocke comme Value::Bool + ($field:ident, $rust_type:ty, bool) => { + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> { + use serde_json::Value; + self.db.set_a_metadata(pk, stringify!($field), Value::Bool(value)) + .map_err(|e| anyhow::anyhow!("DB error: {}", e)) + } + } + }; + + // Value - stocke directement (Array/Object sont sérialisés automatiquement) + ($field:ident, $rust_type:ty, value) => { + paste::paste! { + async fn [](&self, pk: &str, value: $rust_type) -> anyhow::Result<()> { + self.db.set_a_metadata(pk, stringify!($field), value) + .map_err(|e| anyhow::anyhow!("DB error: {}", e)) + } + } + }; +} diff --git a/pmoplaylist/Cargo.toml b/pmoplaylist/Cargo.toml index 952de651..eb5b37f3 100644 --- a/pmoplaylist/Cargo.toml +++ b/pmoplaylist/Cargo.toml @@ -1,9 +1,40 @@ [package] name = "pmoplaylist" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] +# Caches PMO +pmoaudiocache = { path = "../pmoaudiocache" } +pmocache = { path = "../pmocache" } + +# DIDL-Lite pour UPnP pmodidl = { path = "../pmodidl" } -tokio = { version = "1.42.0", features = ["sync", "time", "macros", "rt", "rt-multi-thread"] } -serde = { version = "1.0.228", features = ["derive"] } + +# UPnP (pour accéder au cache audio global) +pmoupnp = { path = "../pmoupnp" } + +# Configuration (optionnelle) +pmoconfig = { path = "../pmoconfig", optional = true } + +# Base de données +rusqlite = { version = "0.37", features = ["bundled"] } + +# Utilitaires +anyhow = "1.0" +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Singleton +once_cell = "1.20" + +# Logging +tracing = "0.1" + +[features] +default = ["pmoconfig"] +pmoconfig = ["dep:pmoconfig"] diff --git a/pmoplaylist/src/config_ext.rs b/pmoplaylist/src/config_ext.rs new file mode 100644 index 00000000..18ec7a9a --- /dev/null +++ b/pmoplaylist/src/config_ext.rs @@ -0,0 +1,20 @@ +//! Extension de pmoconfig pour les playlists + +use std::path::PathBuf; + +/// Trait d'extension pour pmoconfig::Config +pub trait PlaylistConfigExt { + /// Retourne le chemin de la base de données des playlists + fn playlist_db_path(&self) -> PathBuf; +} + +impl PlaylistConfigExt for pmoconfig::Config { + fn playlist_db_path(&self) -> PathBuf { + // Utilise get_managed_dir pour créer le répertoire playlists s'il n'existe pas + let playlists_dir = self + .get_managed_dir(&["playlists", "directory"], "playlists") + .expect("Failed to get or create playlists directory"); + + PathBuf::from(playlists_dir).join("playlists.db") + } +} diff --git a/pmoplaylist/src/error.rs b/pmoplaylist/src/error.rs new file mode 100644 index 00000000..4198d15e --- /dev/null +++ b/pmoplaylist/src/error.rs @@ -0,0 +1,38 @@ +//! Types d'erreurs pour pmoplaylist + +/// Erreurs de gestion de playlist +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Playlist not found: {0}")] + PlaylistNotFound(String), + + #[error("Playlist deleted: {0}")] + PlaylistDeleted(String), + + #[error("Playlist already exists: {0}")] + PlaylistAlreadyExists(String), + + #[error("Playlist is not persistent: {0}")] + PlaylistNotPersistent(String), + + #[error("Write lock already held for playlist: {0}")] + WriteLockHeld(String), + + #[error("Cache entry not found: {0}")] + CacheEntryNotFound(String), + + #[error("Cache error: {0}")] + CacheError(String), + + #[error("Persistence error: {0}")] + PersistenceError(String), + + #[error("PlaylistManager not initialized")] + ManagerNotInitialized, + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Type Result spécialisé pour pmoplaylist +pub type Result = std::result::Result; diff --git a/pmoplaylist/src/handle/mod.rs b/pmoplaylist/src/handle/mod.rs new file mode 100644 index 00000000..5810e972 --- /dev/null +++ b/pmoplaylist/src/handle/mod.rs @@ -0,0 +1,7 @@ +//! Handles pour interagir avec les playlists + +pub mod read; +pub mod write; + +pub use read::ReadHandle; +pub use write::WriteHandle; diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs new file mode 100644 index 00000000..8bcc6950 --- /dev/null +++ b/pmoplaylist/src/handle/read.rs @@ -0,0 +1,242 @@ +//! ReadHandle : consommation individuelle d'une playlist + +use crate::playlist::Playlist; +use crate::track::PlaylistTrack; +use crate::Result; +use pmocache::cache_trait::FileCache; +use pmodidl::{Container, Item, Resource}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +/// Handle de lecture sur une playlist (peut avoir plusieurs instances) +pub struct ReadHandle { + playlist: Arc, + cursor: AtomicUsize, +} + +impl ReadHandle { + /// Crée un nouveau handle de lecture + pub(crate) fn new(playlist: Arc) -> Self { + Self { + playlist, + cursor: AtomicUsize::new(0), + } + } + + /// Pop le prochain morceau (avance le curseur) + /// + /// Skip automatiquement les entrées invalides dans le cache. + pub async fn pop(&self) -> Result> { + loop { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let pos = self.cursor.load(Ordering::SeqCst); + + let core = self.playlist.core.read().await; + + // Fin de playlist ? + if pos >= core.len() { + return Ok(None); + } + + let record = match core.get(pos) { + Some(r) => r, + None => return Ok(None), + }; + + let cache_pk = record.cache_pk.clone(); + drop(core); + + // Vérifier validité dans le cache + let cache = crate::manager::audio_cache()?; + if cache.is_valid_pk(&cache_pk) { + // Valide, avancer le curseur et retourner + self.cursor.fetch_add(1, Ordering::SeqCst); + return Ok(Some(PlaylistTrack::new(cache_pk))); + } else { + // Invalide, supprimer de la playlist et continuer + tracing::warn!("Cache entry {} missing, removing from playlist", cache_pk); + let mut core = self.playlist.core.write().await; + core.remove_by_cache_pk(&cache_pk); + drop(core); + + // Sauvegarder si persistante + if self.playlist.persistent { + if let Some(persistence) = crate::manager::PlaylistManager().persistence() { + let title = self.playlist.title().await; + let core = self.playlist.core.read().await; + let _ = persistence.save_playlist(&self.playlist.id, &title, &core.config, &core.tracks).await; + } + } + + // Ne pas avancer le curseur, continuer avec la position actuelle + continue; + } + } + } + + /// Peek le prochain morceau sans avancer le curseur + pub async fn peek(&self) -> Result> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let pos = self.cursor.load(Ordering::SeqCst); + let core = self.playlist.core.read().await; + + match core.get(pos) { + Some(record) => { + // Vérifier validité + let cache = crate::manager::audio_cache()?; + if cache.is_valid_pk(&record.cache_pk) { + Ok(Some(PlaylistTrack::new(record.cache_pk.clone()))) + } else { + Ok(None) + } + } + None => Ok(None), + } + } + + /// Position actuelle du curseur + pub fn position(&self) -> usize { + self.cursor.load(Ordering::SeqCst) + } + + /// Nombre de morceaux restants (compte uniquement les valides) + pub async fn remaining(&self) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let pos = self.cursor.load(Ordering::SeqCst); + let core = self.playlist.core.read().await; + + if pos >= core.len() { + return Ok(0); + } + + let cache = crate::manager::audio_cache()?; + let mut count = 0; + + for i in pos..core.len() { + if let Some(record) = core.get(i) { + if cache.is_valid_pk(&record.cache_pk) { + count += 1; + } + } + } + + Ok(count) + } + + /// Crée un nouveau handle avec cursor à 0 + pub fn get_new_handle(&self) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + Ok(ReadHandle::new(self.playlist.clone())) + } + + /// Vérifie si la playlist est vivante + pub fn is_alive(&self) -> bool { + self.playlist.is_alive() + } + + /// ID de la playlist + pub fn id(&self) -> &str { + &self.playlist.id + } + + /// Génère un Container DIDL-Lite + pub async fn to_container(&self) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let title = self.playlist.title().await; + let remaining = self.remaining().await?; + + Ok(Container { + id: self.playlist.id.clone(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some(remaining.to_string()), + searchable: Some("0".to_string()), + title, + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + }) + } + + /// Génère des Items DIDL-Lite depuis la position actuelle + pub async fn to_items(&self, limit: usize) -> Result> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let pos = self.cursor.load(Ordering::SeqCst); + let core = self.playlist.core.read().await; + let cache = crate::manager::audio_cache()?; + + // Récupérer base_url depuis le cache (via route_for) + let mut items = Vec::new(); + let mut idx = 0; + + for i in pos..core.len() { + if items.len() >= limit { + break; + } + + let record = match core.get(i) { + Some(r) => r, + None => continue, + }; + + // Vérifier validité + if !cache.is_valid_pk(&record.cache_pk) { + continue; + } + + // Charger métadonnées + let metadata = match pmoaudiocache::get_metadata(&*cache, &record.cache_pk) { + Ok(m) => m, + Err(_) => continue, + }; + + // Construire l'URL via route_for + let url = cache.route_for(&record.cache_pk, None); + + // Créer le Resource DIDL + let resource = metadata.to_didl_resource(url); + + // Créer l'Item + let item = Item { + id: format!("{}:{}", self.playlist.id, pos + idx), + parent_id: self.playlist.id.clone(), + restricted: Some("1".to_string()), + title: metadata.title.unwrap_or_else(|| "Unknown".to_string()), + creator: metadata.artist.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: metadata.artist, + album: metadata.album, + genre: metadata.genre, + album_art: None, // TODO: intégrer pmocovers + album_art_pk: None, + date: metadata.year.map(|y| y.to_string()), + original_track_number: metadata.track_number.map(|n| n.to_string()), + resources: vec![resource], + descriptions: vec![], + }; + + items.push(item); + idx += 1; + } + + Ok(items) + } +} diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs new file mode 100644 index 00000000..9806338f --- /dev/null +++ b/pmoplaylist/src/handle/write.rs @@ -0,0 +1,258 @@ +//! WriteHandle : accès exclusif en écriture à une playlist + +use crate::playlist::core::PlaylistConfig; +use crate::playlist::record::Record; +use crate::playlist::Playlist; +use crate::Result; +use pmocache::cache_trait::FileCache; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +/// Handle d'écriture sur une playlist (exclusif) +pub struct WriteHandle { + playlist: Arc, + _write_token: Arc<()>, +} + +impl WriteHandle { + /// Crée un nouveau handle d'écriture + pub(crate) fn new(playlist: Arc, write_token: Arc<()>) -> Self { + Self { + playlist, + _write_token: write_token, + } + } + + /// Ajoute un morceau à la playlist + pub async fn push(&self, cache_pk: String) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + // Vérifier que le pk existe dans le cache + let cache = crate::manager::audio_cache()?; + if !cache.is_valid_pk(&cache_pk) { + return Err(crate::Error::CacheEntryNotFound(cache_pk)); + } + + // Ajouter à la playlist + let record = Record::new(cache_pk); + let mut core = self.playlist.core.write().await; + core.push(record); + drop(core); + + self.playlist.touch().await; + + // Sauvegarder si persistante + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Ajoute plusieurs morceaux de manière atomique + pub async fn push_set(&self, cache_pks: Vec) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + // Vérifier tous les pks d'abord + let cache = crate::manager::audio_cache()?; + for pk in &cache_pks { + if !cache.is_valid_pk(pk) { + return Err(crate::Error::CacheEntryNotFound(pk.clone())); + } + } + + // Créer tous les records + let records: Vec = cache_pks.into_iter() + .map(Record::new) + .collect(); + + // Ajouter atomiquement + let mut core = self.playlist.core.write().await; + core.push_all(records); + drop(core); + + self.playlist.touch().await; + + // Une seule sauvegarde pour tout le batch + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Vide la playlist + pub async fn flush(&self) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + core.clear(); + drop(core); + + self.playlist.touch().await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Supprime la playlist définitivement + pub async fn delete(self) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + // Marquer comme supprimée + self.playlist.mark_deleted(); + + // Supprimer du manager + crate::manager::delete_playlist_internal(&self.playlist.id).await?; + + Ok(()) + } + + /// Change le titre + pub async fn set_title(&self, title: String) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + self.playlist.set_title(title).await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Change la capacité maximale + pub async fn set_capacity(&self, max_size: Option) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + core.set_capacity(max_size); + drop(core); + + self.playlist.touch().await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Change le TTL par défaut + pub async fn set_default_ttl(&self, ttl: Option) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + let mut core = self.playlist.core.write().await; + core.set_default_ttl(ttl); + drop(core); + + self.playlist.touch().await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + Ok(()) + } + + /// Clone vers une nouvelle playlist persistante + pub async fn clone_as_persistent(&self, new_id: String) -> Result { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + // Récupérer les données actuelles + let title = self.playlist.title().await; + let core = self.playlist.core.read().await; + let config = core.config.clone(); + let tracks = core.snapshot(); + drop(core); + + // Créer la nouvelle playlist persistante + let manager = crate::manager::PlaylistManager(); + let mut new_handle = manager.create_persistent_playlist(new_id).await?; + + // Copier le titre et la config + new_handle.set_title(title).await?; + new_handle.set_capacity(config.max_size).await?; + new_handle.set_default_ttl(config.default_ttl).await?; + + // Copier tous les morceaux + let pks: Vec = tracks.iter() + .map(|r| r.cache_pk.clone()) + .collect(); + new_handle.push_set(pks).await?; + + Ok(new_handle) + } + + // Métadonnées + + pub fn id(&self) -> &str { + &self.playlist.id + } + + pub async fn title(&self) -> String { + self.playlist.title().await + } + + pub fn is_persistent(&self) -> bool { + self.playlist.persistent + } + + pub async fn capacity(&self) -> Option { + let core = self.playlist.core.read().await; + core.config.max_size + } + + pub async fn default_ttl(&self) -> Option { + let core = self.playlist.core.read().await; + core.config.default_ttl + } + + pub async fn len(&self) -> usize { + let core = self.playlist.core.read().await; + core.len() + } + + pub async fn is_empty(&self) -> bool { + let core = self.playlist.core.read().await; + core.is_empty() + } + + pub async fn last_change(&self) -> SystemTime { + self.playlist.last_change().await + } + + // Helpers internes + + async fn save_to_db(&self) -> Result<()> { + let manager = crate::manager::PlaylistManager(); + let persistence = manager.persistence() + .ok_or_else(|| crate::Error::PersistenceError("No persistence manager".into()))?; + + let title = self.playlist.title().await; + let core = self.playlist.core.read().await; + let config = &core.config; + let tracks = &core.tracks; + + persistence.save_playlist(&self.playlist.id, &title, config, tracks).await + } +} diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs index f7a924b0..a54349b9 100644 --- a/pmoplaylist/src/lib.rs +++ b/pmoplaylist/src/lib.rs @@ -1,828 +1,64 @@ -//! # pmoplaylist - FIFO Audio Universelle pour MediaServer UPnP/OpenHome +//! # pmoplaylist - Gestionnaire centralisé de playlists FIFO multi-consommateurs //! -//! 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 +//! Cette crate fournit un gestionnaire centralisé de playlists avec : +//! - Gestion FIFO avec capacité et TTL configurables +//! - Multi-consommateurs indépendants +//! - Persistance optionnelle (SQLite) +//! - Intégration avec pmoaudiocache +//! - Génération DIDL-Lite pour UPnP //! -//! # Exemples +//! # Architecture //! -//! ``` -//! use pmoplaylist::{FifoPlaylist, Track}; +//! - **PlaylistManager** : Singleton central gérant toutes les playlists +//! - **WriteHandle** : Accès exclusif en écriture (push, flush, delete) +//! - **ReadHandle** : Accès en lecture avec curseur individuel (pop, peek) +//! - **PlaylistTrack** : Référence minimale vers pmoaudiocache +//! +//! # Exemple d'utilisation +//! +//! ```no_run +//! use pmoplaylist::PlaylistManager; //! //! # #[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, -//! ); +//! # async fn main() -> pmoplaylist::Result<()> { +//! // Obtenir le gestionnaire (init automatique avec pmoconfig) +//! let manager = PlaylistManager(); //! -//! // 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, -//! }; +//! // Créer une playlist persistante +//! let mut writer = manager.create_persistent_playlist("radio-paradise".into())?; +//! writer.set_title("Radio Paradise - Main Mix".into()).await?; //! -//! playlist.append_track(track).await; +//! // Ajouter des morceaux (par cache_pk) +//! writer.push("abc123".into()).await?; +//! writer.push("def456".into()).await?; //! -//! // Récupérer les items pour ContentDirectory -//! let items = playlist.get_items(0, 10).await; -//! println!("Nombre de tracks: {}", items.len()); +//! // Créer un consommateur +//! let mut reader = manager.get_read_handle("radio-paradise")?; //! -//! // Générer le container DIDL-Lite -//! let container = playlist.as_container().await; -//! println!("Container ID: {}", container.id); +//! // Consommer +//! while let Some(track) = reader.pop().await? { +//! let path = track.file_path()?; +//! println!("Playing: {:?}", path); +//! } +//! # Ok(()) //! # } //! ``` -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); - } -} +mod error; +mod handle; +mod manager; +mod persistence; +mod playlist; +mod track; + +#[cfg(feature = "pmoconfig")] +mod config_ext; + +// Réexports publics +pub use error::{Error, Result}; +pub use handle::{ReadHandle, WriteHandle}; +pub use manager::{PlaylistManager, PlaylistManager as Manager}; +pub use track::PlaylistTrack; + +#[cfg(feature = "pmoconfig")] +pub use config_ext::PlaylistConfigExt; diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs new file mode 100644 index 00000000..333ee165 --- /dev/null +++ b/pmoplaylist/src/manager.rs @@ -0,0 +1,296 @@ +//! PlaylistManager : gestionnaire singleton central de toutes les playlists + +use crate::handle::{ReadHandle, WriteHandle}; +use crate::persistence::PersistenceManager; +use crate::playlist::core::PlaylistConfig; +use crate::playlist::Playlist; +use crate::Result; +use once_cell::sync::OnceCell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +/// Singleton PlaylistManager +static PLAYLIST_MANAGER: OnceCell = OnceCell::new(); + +/// Structure interne du manager +struct ManagerInner { + playlists: RwLock>>, + persistence: Option>, +} + +/// Gestionnaire central de playlists +pub struct PlaylistManager { + inner: Arc, +} + +impl PlaylistManager { + /// Initialise le gestionnaire (� appeler une seule fois au d�marrage) + fn init(db_path: PathBuf) -> Result { + // Initialiser la persistance + let persistence = Arc::new(PersistenceManager::new(&db_path)?); + + let manager = Self { + inner: Arc::new(ManagerInner { + playlists: RwLock::new(HashMap::new()), + persistence: Some(persistence.clone()), + }), + }; + + // Lancer la task d'�viction en background + let manager_clone = manager.clone(); + tokio::spawn(async move { + manager_clone.eviction_task().await; + }); + + Ok(manager) + } + + /// Initialise avec la configuration de pmoconfig + #[cfg(feature = "pmoconfig")] + fn init_with_config() -> Result { + use crate::config_ext::PlaylistConfigExt; + + let config = pmoconfig::get_config(); + let db_path = config.playlist_db_path(); + + Self::init(db_path) + } + + /// Retourne le singleton + pub fn get() -> &'static PlaylistManager { + #[cfg(feature = "pmoconfig")] + { + PLAYLIST_MANAGER.get_or_init(|| { + Self::init_with_config().expect("Failed to initialize PlaylistManager") + }) + } + + #[cfg(not(feature = "pmoconfig"))] + { + PLAYLIST_MANAGER.get().expect("PlaylistManager not initialized. Call init() first.") + } + } + + /// Cr�e une playlist persistante (erreur si existe d�j�) + pub async fn create_persistent_playlist(&self, id: String) -> Result { + let mut playlists = self.inner.playlists.write().await; + + if playlists.contains_key(&id) { + return Err(crate::Error::PlaylistAlreadyExists(id)); + } + + let playlist = Arc::new(Playlist::new( + id.clone(), + id.clone(), // Titre = id par d�faut + PlaylistConfig::default(), + true, // persistent + )); + + // Acqu�rir le write lock + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + playlists.insert(id.clone(), playlist.clone()); + drop(playlists); + + // Sauvegarder la structure vide + if let Some(persistence) = &self.inner.persistence { + let title = playlist.title().await; + let core = playlist.core.read().await; + persistence.save_playlist(&playlist.id, &title, &core.config, &core.tracks) + .await?; + } + + Ok(WriteHandle::new(playlist, write_token)) + } + + /// R�cup�re un write handle (cr�e �ph�m�re si n'existe pas) + pub async fn get_write_handle(&self, id: String) -> Result { + let mut playlists = self.inner.playlists.write().await; + + if let Some(playlist) = playlists.get(&id) { + // Playlist existe, tenter d'acqu�rir le lock + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + return Ok(WriteHandle::new(playlist.clone(), write_token)); + } + + // N'existe pas, cr�er �ph�m�re + let playlist = Arc::new(Playlist::new( + id.clone(), + id.clone(), + PlaylistConfig::default(), + false, // �ph�m�re + )); + + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + playlists.insert(id, playlist.clone()); + drop(playlists); + + Ok(WriteHandle::new(playlist, write_token)) + } + + /// R�cup�re un write handle persistant (cr�e si n'existe pas) + pub async fn get_persistent_write_handle(&self, id: String) -> Result { + let playlists = self.inner.playlists.read().await; + + if let Some(playlist) = playlists.get(&id) { + // Playlist existe + if !playlist.persistent { + return Err(crate::Error::PlaylistNotPersistent(id)); + } + + let write_token = playlist + .acquire_write_lock() + .await + .map_err(|_| crate::Error::WriteLockHeld(id.clone()))?; + + return Ok(WriteHandle::new(playlist.clone(), write_token)); + } + + drop(playlists); + + // N'existe pas, cr�er persistent + self.create_persistent_playlist(id).await + } + + /// R�cup�re un read handle (ressuscite depuis DB si besoin) + pub async fn get_read_handle(&self, id: &str) -> Result { + let playlists = self.inner.playlists.read().await; + + if let Some(playlist) = playlists.get(id) { + if !playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(id.to_string())); + } + return Ok(ReadHandle::new(playlist.clone())); + } + + drop(playlists); + + // Pas en m�moire, essayer de ressusciter depuis la DB + if let Some(persistence) = &self.inner.persistence { + if let Some((title, config, tracks)) = persistence.load_playlist(id).await? { + // Reconstruire la playlist + let mut playlists = self.inner.playlists.write().await; + + let playlist = Arc::new(Playlist::new( + id.to_string(), + title.clone(), + config, + true, + )); + + // Restaurer les tracks + { + let mut core = playlist.core.write().await; + core.tracks = tracks; + } + + playlists.insert(id.to_string(), playlist.clone()); + drop(playlists); + + return Ok(ReadHandle::new(playlist)); + } + } + + Err(crate::Error::PlaylistNotFound(id.to_string())) + } + + /// Supprime une playlist d�finitivement + pub async fn delete_playlist(&self, id: &str) -> Result<()> { + let mut playlists = self.inner.playlists.write().await; + + if let Some(playlist) = playlists.remove(id) { + playlist.mark_deleted(); + } + + drop(playlists); + + // Supprimer de la DB + if let Some(persistence) = &self.inner.persistence { + persistence.delete_playlist(id).await?; + } + + Ok(()) + } + + /// Liste toutes les playlists + pub async fn list_playlists(&self) -> Vec { + let playlists = self.inner.playlists.read().await; + playlists.keys().cloned().collect() + } + + /// V�rifie si une playlist existe + pub async fn exists(&self, id: &str) -> bool { + self.inner.playlists.read().await.contains_key(id) + } + + /// Retourne la r�f�rence au PersistenceManager + pub(crate) fn persistence(&self) -> Option<&Arc> { + self.inner.persistence.as_ref() + } + + /// Task d'�viction en background + async fn eviction_task(&self) { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + + let playlists = self.inner.playlists.read().await; + + for playlist in playlists.values() { + if !playlist.is_alive() { + continue; + } + + let mut core = playlist.core.write().await; + let initial_len = core.len(); + core.evict(); + let new_len = core.len(); + drop(core); + + // Si des morceaux ont �t� �vict�s et la playlist est persistante + if new_len < initial_len && playlist.persistent { + if let Some(persistence) = &self.inner.persistence { + let title = playlist.title().await; + let core = playlist.core.read().await; + let _ = persistence.save_playlist(&playlist.id, &title, &core.config, &core.tracks).await; + } + } + } + } + } + + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +/// Helper pour supprimer une playlist (appel� depuis WriteHandle) +pub(crate) async fn delete_playlist_internal(id: &str) -> Result<()> { + PlaylistManager::get().delete_playlist(id).await +} + +/// Helper pour acc�der au cache audio +pub(crate) fn audio_cache() -> Result> { + pmoupnp::get_audio_cache() + .ok_or_else(|| crate::Error::ManagerNotInitialized) +} + +/// Fonction raccourcie pour acc�der au singleton +pub fn PlaylistManager() -> &'static PlaylistManager { + PlaylistManager::get() +} diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs new file mode 100644 index 00000000..890a4616 --- /dev/null +++ b/pmoplaylist/src/persistence/mod.rs @@ -0,0 +1,215 @@ +//! Gestion de la persistance SQLite pour les playlists + +use crate::playlist::core::PlaylistConfig; +use crate::playlist::record::Record; +use crate::Result; +use rusqlite::{params, Connection}; +use std::collections::VecDeque; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Gestionnaire de persistance (une base pour toutes les playlists) +pub struct PersistenceManager { + conn: Arc>, +} + +impl PersistenceManager { + /// Initialise le gestionnaire de persistance + pub fn new(db_path: &Path) -> Result { + // Créer le répertoire parent si nécessaire + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| crate::Error::PersistenceError(format!("Failed to create directory: {}", e)))?; + } + + let conn = Connection::open(db_path) + .map_err(|e| crate::Error::PersistenceError(format!("Failed to open database: {}", e)))?; + + // Créer les tables + conn.execute( + "CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + max_size INTEGER, + default_ttl_secs INTEGER, + created_at INTEGER NOT NULL, + last_modified INTEGER NOT NULL + )", + [], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to create playlists table: {}", e)))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS tracks ( + playlist_id TEXT NOT NULL, + added_at INTEGER NOT NULL PRIMARY KEY, + cache_pk TEXT NOT NULL, + ttl_secs INTEGER, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + )", + [], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to create tracks table: {}", e)))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tracks_playlist ON tracks(playlist_id, added_at)", + [], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_tracks_cache_pk ON tracks(cache_pk)", + [], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to create index: {}", e)))?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Sauvegarde une playlist complète + pub async fn save_playlist( + &self, + id: &str, + title: &str, + config: &PlaylistConfig, + tracks: &VecDeque>, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as i64; + + // Upsert playlist metadata + conn.execute( + "INSERT OR REPLACE INTO playlists (id, title, max_size, default_ttl_secs, created_at, last_modified) + VALUES (?1, ?2, ?3, ?4, + COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?5), + ?5)", + params![ + id, + title, + config.max_size.map(|s| s as i64), + config.default_ttl.map(|d| d.as_secs() as i64), + now_nanos, + ], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to save playlist: {}", e)))?; + + // Supprimer les anciens tracks + conn.execute( + "DELETE FROM tracks WHERE playlist_id = ?1", + params![id], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to delete old tracks: {}", e)))?; + + // Insérer les nouveaux tracks + for record in tracks { + conn.execute( + "INSERT INTO tracks (playlist_id, added_at, cache_pk, ttl_secs) + VALUES (?1, ?2, ?3, ?4)", + params![ + id, + record.added_at_nanos(), + &record.cache_pk, + record.ttl.map(|d| d.as_secs() as i64), + ], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to insert track: {}", e)))?; + } + + Ok(()) + } + + /// Charge une playlist + pub async fn load_playlist(&self, id: &str) -> Result>)>> { + let conn = self.conn.lock().unwrap(); + + // Charger les métadonnées + let mut stmt = conn.prepare( + "SELECT title, max_size, default_ttl_secs FROM playlists WHERE id = ?1" + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?; + + let result = stmt.query_row(params![id], |row| { + let title: String = row.get(0)?; + let max_size: Option = row.get(1)?; + let default_ttl_secs: Option = row.get(2)?; + + Ok(( + title, + PlaylistConfig { + max_size: max_size.map(|s| s as usize), + default_ttl: default_ttl_secs.map(|s| Duration::from_secs(s as u64)), + }, + )) + }); + + let (title, config) = match result { + Ok(data) => data, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(crate::Error::PersistenceError(format!("Failed to load playlist: {}", e))), + }; + + // Charger les tracks + let mut stmt = conn.prepare( + "SELECT added_at, cache_pk, ttl_secs FROM tracks WHERE playlist_id = ?1 ORDER BY added_at ASC" + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?; + + let rows = stmt.query_map(params![id], |row| { + let added_at_nanos: i64 = row.get(0)?; + let cache_pk: String = row.get(1)?; + let ttl_secs: Option = row.get(2)?; + + let added_at = UNIX_EPOCH + Duration::from_nanos(added_at_nanos as u64); + let ttl = ttl_secs.map(|s| Duration::from_secs(s as u64)); + + Ok(Record { + cache_pk, + added_at, + ttl, + }) + }).map_err(|e| crate::Error::PersistenceError(format!("Failed to query tracks: {}", e)))?; + + let mut tracks = VecDeque::new(); + for row in rows { + let record = row.map_err(|e| crate::Error::PersistenceError(format!("Failed to read track: {}", e)))?; + tracks.push_back(Arc::new(record)); + } + + Ok(Some((title, config, tracks))) + } + + /// Supprime une playlist + pub async fn delete_playlist(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM playlists WHERE id = ?1", + params![id], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to delete playlist: {}", e)))?; + Ok(()) + } + + /// Liste toutes les playlists persistantes + pub async fn list_playlist_ids(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id FROM playlists") + .map_err(|e| crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)))?; + + let rows = stmt.query_map([], |row| row.get(0)) + .map_err(|e| crate::Error::PersistenceError(format!("Failed to query playlists: {}", e)))?; + + let mut ids = Vec::new(); + for row in rows { + ids.push(row.map_err(|e| crate::Error::PersistenceError(format!("Failed to read id: {}", e)))?); + } + + Ok(ids) + } + + /// Supprime tous les tracks contenant un cache_pk donné + pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM tracks WHERE cache_pk = ?1", + params![cache_pk], + ).map_err(|e| crate::Error::PersistenceError(format!("Failed to remove tracks: {}", e)))?; + Ok(()) + } +} diff --git a/pmoplaylist/src/playlist/core.rs b/pmoplaylist/src/playlist/core.rs new file mode 100644 index 00000000..bc75885d --- /dev/null +++ b/pmoplaylist/src/playlist/core.rs @@ -0,0 +1,111 @@ +//! PlaylistCore : structure FIFO avec éviction automatique + +use super::record::Record; +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Duration; + +/// Configuration d'une playlist +#[derive(Debug, Clone)] +pub struct PlaylistConfig { + pub max_size: Option, + pub default_ttl: Option, +} + +impl Default for PlaylistConfig { + fn default() -> Self { + Self { + max_size: None, + default_ttl: None, + } + } +} + +/// Noyau de la playlist (structure interne protégée par RwLock) +pub struct PlaylistCore { + pub tracks: VecDeque>, + pub config: PlaylistConfig, +} + +impl PlaylistCore { + /// Crée un nouveau core + pub fn new(config: PlaylistConfig) -> Self { + Self { + tracks: VecDeque::new(), + config, + } + } + + /// Ajoute un record et applique l'éviction + pub fn push(&mut self, record: Record) { + self.tracks.push_back(Arc::new(record)); + self.evict(); + } + + /// Ajoute plusieurs records de manière atomique + pub fn push_all(&mut self, records: Vec) { + for record in records { + self.tracks.push_back(Arc::new(record)); + } + self.evict(); + } + + /// Nettoie les morceaux expirés et applique la limite de taille + pub fn evict(&mut self) { + // 1. Supprimer les morceaux périmés par TTL + self.tracks.retain(|record| { + !record.is_expired(self.config.default_ttl) + }); + + // 2. Appliquer la limite de taille (FIFO) + if let Some(max) = self.config.max_size { + while self.tracks.len() > max { + self.tracks.pop_front(); + } + } + } + + /// Vide complètement la playlist + pub fn clear(&mut self) { + self.tracks.clear(); + } + + /// Nombre de morceaux + pub fn len(&self) -> usize { + self.tracks.len() + } + + /// Vérifie si la playlist est vide + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } + + /// Récupère un record par index + pub fn get(&self, index: usize) -> Option> { + self.tracks.get(index).cloned() + } + + /// Snapshot de tous les records + pub fn snapshot(&self) -> Vec> { + self.tracks.iter().cloned().collect() + } + + /// Supprime un record par cache_pk (retourne true si supprimé) + pub fn remove_by_cache_pk(&mut self, cache_pk: &str) -> bool { + let initial_len = self.tracks.len(); + self.tracks.retain(|r| r.cache_pk != cache_pk); + self.tracks.len() != initial_len + } + + /// Met à jour la capacité maximale + pub fn set_capacity(&mut self, max_size: Option) { + self.config.max_size = max_size; + self.evict(); + } + + /// Met à jour le TTL par défaut + pub fn set_default_ttl(&mut self, ttl: Option) { + self.config.default_ttl = ttl; + self.evict(); + } +} diff --git a/pmoplaylist/src/playlist/mod.rs b/pmoplaylist/src/playlist/mod.rs new file mode 100644 index 00000000..d6e11e5e --- /dev/null +++ b/pmoplaylist/src/playlist/mod.rs @@ -0,0 +1,102 @@ +//! Playlist interne (non exposée publiquement) + +pub mod core; +pub mod record; + +use self::core::{PlaylistConfig, PlaylistCore}; +use self::record::Record; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::SystemTime; +use tokio::sync::RwLock; + +/// État d'une playlist +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum PlaylistState { + Active = 0, + Deleted = 1, +} + +impl From for PlaylistState { + fn from(value: u8) -> Self { + match value { + 1 => PlaylistState::Deleted, + _ => PlaylistState::Active, + } + } +} + +/// Playlist interne (gérée par le PlaylistManager) +pub struct Playlist { + pub id: String, + title: RwLock, + state: Arc, + pub core: Arc>, + pub persistent: bool, + last_change: RwLock, + writer_lock: RwLock>>, +} + +impl Playlist { + /// Crée une nouvelle playlist + pub fn new(id: String, title: String, config: PlaylistConfig, persistent: bool) -> Self { + Self { + id, + title: RwLock::new(title), + state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)), + core: Arc::new(RwLock::new(PlaylistCore::new(config))), + persistent, + last_change: RwLock::new(SystemTime::now()), + writer_lock: RwLock::new(None), + } + } + + /// Vérifie si la playlist est active + pub fn is_alive(&self) -> bool { + PlaylistState::from(self.state.load(Ordering::SeqCst)) == PlaylistState::Active + } + + /// Marque la playlist comme supprimée + pub fn mark_deleted(&self) { + self.state.store(PlaylistState::Deleted as u8, Ordering::SeqCst); + } + + /// Met à jour le timestamp de dernière modification + pub async fn touch(&self) { + *self.last_change.write().await = SystemTime::now(); + } + + /// Récupère le titre + pub async fn title(&self) -> String { + self.title.read().await.clone() + } + + /// Change le titre + pub async fn set_title(&self, title: String) { + *self.title.write().await = title; + self.touch().await; + } + + /// Timestamp du dernier changement + pub async fn last_change(&self) -> SystemTime { + *self.last_change.read().await + } + + /// Tente d'acquérir le write lock + pub async fn acquire_write_lock(&self) -> Result, ()> { + let mut guard = self.writer_lock.write().await; + + // Vérifier si un writer existe déjà + if let Some(weak) = guard.as_ref() { + if weak.strong_count() > 0 { + return Err(()); // Lock déjà pris + } + } + + // Créer un nouveau token + let token = Arc::new(()); + *guard = Some(Arc::downgrade(&token)); + Ok(token) + } +} diff --git a/pmoplaylist/src/playlist/record.rs b/pmoplaylist/src/playlist/record.rs new file mode 100644 index 00000000..7dec95b5 --- /dev/null +++ b/pmoplaylist/src/playlist/record.rs @@ -0,0 +1,61 @@ +//! Record : entrée dans la playlist pointant vers le cache audio + +use std::time::{Duration, SystemTime}; + +/// Un enregistrement dans la playlist +/// +/// Contient uniquement une référence (pk) vers une entrée dans pmoaudiocache +/// et des informations de gestion (timestamp, TTL). +#[derive(Debug, Clone)] +pub struct Record { + /// Clé primaire dans pmoaudiocache + pub cache_pk: String, + + /// Timestamp d'ajout à la playlist (en nanosecondes depuis epoch) + pub added_at: SystemTime, + + /// Durée de vie optionnelle (surcharge le TTL par défaut) + pub ttl: Option, +} + +impl Record { + /// Crée un nouveau record + pub fn new(cache_pk: String) -> Self { + Self { + cache_pk, + added_at: SystemTime::now(), + ttl: None, + } + } + + /// Crée un record avec un TTL personnalisé + pub fn with_ttl(cache_pk: String, ttl: Duration) -> Self { + Self { + cache_pk, + added_at: SystemTime::now(), + ttl: Some(ttl), + } + } + + /// Vérifie si le record est expiré + pub fn is_expired(&self, default_ttl: Option) -> bool { + let now = SystemTime::now(); + let age = now.duration_since(self.added_at).unwrap_or_default(); + + if let Some(ttl) = self.ttl { + age >= ttl + } else if let Some(default_ttl) = default_ttl { + age >= default_ttl + } else { + false + } + } + + /// Retourne le timestamp en nanosecondes depuis epoch + pub fn added_at_nanos(&self) -> i64 { + self.added_at + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as i64 + } +} diff --git a/pmoplaylist/src/track.rs b/pmoplaylist/src/track.rs new file mode 100644 index 00000000..883791af --- /dev/null +++ b/pmoplaylist/src/track.rs @@ -0,0 +1,141 @@ +//! PlaylistTrack : résultat d'un pop() avec helpers pour accéder au cache + +use crate::Result; +use pmocache::cache_trait::FileCache; +use pmoaudiocache::AudioMetadataExt; +use std::path::PathBuf; + +/// Un morceau récupéré depuis une playlist +/// +/// Wrapper minimal autour d'un `cache_pk` qui délègue toutes les opérations +/// au système de cache (pmoaudiocache). Aucune métadonnée n'est stockée ici. +/// +/// # Exemples +/// +/// ```no_run +/// # use pmoplaylist::*; +/// # async fn example(track: PlaylistTrack) -> Result<()> { +/// // Accès à la clé cache +/// let pk = track.cache_pk(); +/// +/// // Récupérer les métadonnées (1 seul I/O) +/// let metadata = track.metadata().await?; +/// println!("Titre: {:?}", metadata.title); +/// println!("Artiste: {:?}", metadata.artist); +/// println!("Durée: {:?}s", metadata.duration_secs); +/// +/// // Récupérer le chemin du fichier pour streaming +/// let path = track.file_path()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct PlaylistTrack { + cache_pk: String, +} + +impl PlaylistTrack { + /// Crée un nouveau track + pub(crate) fn new(cache_pk: String) -> Self { + Self { cache_pk } + } + + /// Retourne la clé primaire dans le cache audio + pub fn cache_pk(&self) -> &str { + &self.cache_pk + } + + /// Récupère le chemin du fichier audio depuis le cache + /// + /// Délègue directement à `FileCache::file_path()`. La validation de + /// l'existence du fichier est déjà faite par `ReadHandle::pop()`. + /// + /// # Note + /// + /// Cette méthode ne vérifie PAS l'existence du fichier. Pour valider + /// avant de récupérer le chemin, utilisez `cache.is_valid_pk()`. + pub fn file_path(&self) -> Result { + let cache = crate::manager::audio_cache()?; + Ok(cache.file_path(&self.cache_pk)) + } + + /// Récupère les métadonnées audio complètes depuis le cache + /// + /// **Important** : Cette méthode récupère TOUTES les métadonnées de la base de données. + /// Si vous n'avez besoin que d'un seul champ (ex: titre), utilisez plutôt les méthodes + /// légères `title()`, `artist()`, etc. qui utilisent `get_a_metadata()`. + /// + /// # Exemples + /// + /// ```no_run + /// # use pmoplaylist::*; + /// # async fn example(track: PlaylistTrack) -> Result<()> { + /// // ✅ BON : Si vous avez besoin de plusieurs champs + /// let metadata = track.metadata().await?; + /// let title = metadata.title.as_deref().unwrap_or("Unknown"); + /// let artist = metadata.artist.as_deref().unwrap_or("Unknown"); + /// let album = metadata.album.as_deref().unwrap_or("Unknown"); + /// + /// // ✅ MIEUX : Si vous n'avez besoin que d'un seul champ (plus léger) + /// let title = track.title().await?.unwrap_or_else(|| "Unknown".to_string()); + /// # Ok(()) + /// # } + /// ``` + pub async fn metadata(&self) -> Result { + let cache = crate::manager::audio_cache()?; + pmoaudiocache::get_metadata(&*cache, &self.cache_pk) + .map_err(|e| crate::Error::CacheError(e.to_string())) + } + + /// Récupère uniquement le titre du morceau (méthode légère) + /// + /// Utilise l'extension trait `AudioMetadataExt` pour récupérer qu'une seule valeur + /// de la base de données au lieu de toutes les métadonnées. + /// + /// **Beaucoup plus rapide** que `metadata().await?.title` si vous n'avez + /// besoin que du titre. + pub async fn title(&self) -> Result> { + let cache = crate::manager::audio_cache()?; + cache + .get_title(&self.cache_pk) + .await + .map_err(|e| crate::Error::CacheError(e.to_string())) + } + + /// Récupère uniquement l'artiste du morceau (méthode légère) + /// + /// Utilise l'extension trait `AudioMetadataExt`. + pub async fn artist(&self) -> Result> { + let cache = crate::manager::audio_cache()?; + cache + .get_artist(&self.cache_pk) + .await + .map_err(|e| crate::Error::CacheError(e.to_string())) + } + + /// Récupère uniquement l'album du morceau (méthode légère) + /// + /// Utilise l'extension trait `AudioMetadataExt`. + pub async fn album(&self) -> Result> { + let cache = crate::manager::audio_cache()?; + cache + .get_album(&self.cache_pk) + .await + .map_err(|e| crate::Error::CacheError(e.to_string())) + } + + /// Récupère uniquement la durée en secondes (méthode légère) + /// + /// Utilise l'extension trait `AudioMetadataExt`. + pub async fn duration_secs(&self) -> Result> { + let cache = crate::manager::audio_cache()?; + match cache + .get_duration_secs(&self.cache_pk) + .await + .map_err(|e| crate::Error::CacheError(e.to_string()))? + { + Some(duration) => Ok(Some(duration as u64)), + None => Ok(None), + } + } +}