diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index d120fcd7..34ca7322 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -117,7 +117,7 @@ use pmoaudio::{ }; use pmoaudiocache::Cache as AudioCache; use pmoflac::{decode_audio_stream, StreamInfo}; -use pmoplaylist::ReadHandle; +use pmoplaylist::{PlaylistRole, ReadHandle}; use std::{path::PathBuf, sync::Arc, time::Duration}; use tokio::{fs::File, io::AsyncReadExt, sync::mpsc}; use tokio_util::sync::CancellationToken; diff --git a/pmoparadise/examples/play_and_cache.rs b/pmoparadise/examples/play_and_cache.rs index 2a1ca627..b0ce7f77 100644 --- a/pmoparadise/examples/play_and_cache.rs +++ b/pmoparadise/examples/play_and_cache.rs @@ -34,7 +34,7 @@ use pmoaudiocache::{ }; use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; -use pmoplaylist::register_audio_cache as register_playlist_audio_cache; +use pmoplaylist::{register_audio_cache as register_playlist_audio_cache, PlaylistRole}; use std::env; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -137,6 +137,7 @@ async fn main() -> Result<(), Box> { writer .set_title(format!("Radio Paradise - Channel {}", channel_id)) .await?; + writer.set_role(PlaylistRole::Radio).await?; writer.flush().await?; // Vider la playlist si elle existait tracing::debug!("Playlist created and flushed"); diff --git a/pmoparadise/src/playlist_feeder.rs b/pmoparadise/src/playlist_feeder.rs index 4594743c..2d9abd15 100644 --- a/pmoparadise/src/playlist_feeder.rs +++ b/pmoparadise/src/playlist_feeder.rs @@ -6,7 +6,7 @@ use crate::{client::RadioParadiseClient, models::EventId}; use anyhow::Result; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoversCache; -use pmoplaylist::{PlaylistManager, ReadHandle, WriteHandle}; +use pmoplaylist::{PlaylistManager, PlaylistRole, ReadHandle, WriteHandle}; use std::{ collections::{HashMap, VecDeque}, sync::Arc, @@ -115,7 +115,7 @@ impl RadioParadisePlaylistFeeder { ) -> Result<(Self, ReadHandle)> { let manager = PlaylistManager::get(); let write_handle = manager - .create_persistent_playlist(playlist_id.clone()) + .create_persistent_playlist_with_role(playlist_id.clone(), PlaylistRole::Radio) .await?; let read_handle = manager.get_read_handle(&playlist_id).await?; diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index b1909f92..92d3909e 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -67,9 +67,10 @@ impl ReadHandle { if self.playlist.persistent { if let Some(persistence) = crate::manager::PlaylistManager().persistence() { let title = self.playlist.title().await; + let role = self.playlist.role().await; let core = self.playlist.core.read().await; let _ = persistence - .save_playlist(&self.playlist.id, &title, &core.config, &core.tracks) + .save_playlist(&self.playlist.id, &title, &role, &core.config, &core.tracks) .await; } } diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs index 5c35feb4..fb751af0 100644 --- a/pmoplaylist/src/handle/write.rs +++ b/pmoplaylist/src/handle/write.rs @@ -1,7 +1,7 @@ //! WriteHandle : accès exclusif en écriture à une playlist use crate::playlist::record::Record; -use crate::playlist::Playlist; +use crate::playlist::{Playlist, PlaylistRole}; use crate::Result; use pmocache::cache_trait::FileCache; use std::sync::Arc; @@ -196,6 +196,23 @@ impl WriteHandle { Ok(()) } + /// Modifie le rôle logique de la playlist + pub async fn set_role(&self, role: PlaylistRole) -> Result<()> { + if !self.playlist.is_alive() { + return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone())); + } + + self.playlist.set_role(role).await; + + if self.playlist.persistent { + self.save_to_db().await?; + } + + crate::manager::PlaylistManager().notify_playlist_changed(&self.playlist.id); + + Ok(()) + } + /// Change la capacité maximale pub async fn set_capacity(&self, max_size: Option) -> Result<()> { if !self.playlist.is_alive() { @@ -266,6 +283,7 @@ impl WriteHandle { // Récupérer les données actuelles let title = self.playlist.title().await; + let role = self.playlist.role().await; let core = self.playlist.core.read().await; let config = core.config.clone(); let tracks = core.snapshot(); @@ -273,7 +291,9 @@ impl WriteHandle { // Créer la nouvelle playlist persistante let manager = crate::manager::PlaylistManager(); - let new_handle = manager.create_persistent_playlist(new_id).await?; + let new_handle = manager + .create_persistent_playlist_with_role(new_id, role) + .await?; // Copier le titre et la config new_handle.set_title(title).await?; @@ -297,6 +317,10 @@ impl WriteHandle { self.playlist.title().await } + pub async fn role(&self) -> PlaylistRole { + self.playlist.role().await + } + pub fn is_persistent(&self) -> bool { self.playlist.persistent } @@ -474,12 +498,13 @@ impl WriteHandle { .ok_or_else(|| crate::Error::PersistenceError("No persistence manager".into()))?; let title = self.playlist.title().await; + let role = self.playlist.role().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) + .save_playlist(&self.playlist.id, &title, &role, config, tracks) .await } } diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs index 090038d6..e18285cc 100644 --- a/pmoplaylist/src/lib.rs +++ b/pmoplaylist/src/lib.rs @@ -63,6 +63,7 @@ pub use error::{Error, Result}; pub use handle::{ReadHandle, WriteHandle}; pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager}; pub use manager::{subscribe_events, PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind}; +pub use playlist::PlaylistRole; #[cfg(feature = "pmoserver")] pub use sse::playlist_events_router; pub use track::PlaylistTrack; diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index cffe0ff8..35ebc06f 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -3,7 +3,7 @@ use crate::handle::{ReadHandle, WriteHandle}; use crate::persistence::PersistenceManager; use crate::playlist::core::PlaylistConfig; -use crate::playlist::Playlist; +use crate::playlist::{Playlist, PlaylistRole}; use crate::Result; use once_cell::sync::OnceCell; use pmocache::{CacheBroadcastEvent, CacheEvent, CacheSubscription}; @@ -138,8 +138,12 @@ impl PlaylistManager { } } - /// Cr�e une playlist persistante (erreur si existe d�j�) - pub async fn create_persistent_playlist(&self, id: String) -> Result { + /// Cr�e une playlist persistante (erreur si existe d�j�) avec rôle personnalisé + pub async fn create_persistent_playlist_with_role( + &self, + id: String, + role: PlaylistRole, + ) -> Result { let mut playlists = self.inner.playlists.write().await; if playlists.contains_key(&id) { @@ -148,9 +152,10 @@ impl PlaylistManager { let playlist = Arc::new(Playlist::new( id.clone(), - id.clone(), // Titre = id par d�faut + id.clone(), // Titre = id par défaut PlaylistConfig::default(), true, // persistent + role, )); // Acqu�rir le write lock @@ -165,15 +170,22 @@ impl PlaylistManager { // Sauvegarder la structure vide if let Some(persistence) = &self.inner.persistence { let title = playlist.title().await; + let role = playlist.role().await; let core = playlist.core.read().await; persistence - .save_playlist(&playlist.id, &title, &core.config, &core.tracks) + .save_playlist(&playlist.id, &title, &role, &core.config, &core.tracks) .await?; } Ok(WriteHandle::new(playlist, write_token)) } + /// Cr�e une playlist persistante avec rôle par défaut (user) + pub async fn create_persistent_playlist(&self, id: String) -> Result { + self.create_persistent_playlist_with_role(id, PlaylistRole::User) + .await + } + /// Enregistre un callback d'évènement playlist (update, track joué). /// /// Retourne un jeton (u64) pour désenregistrer plus tard. @@ -394,7 +406,8 @@ impl PlaylistManager { id.clone(), id.clone(), PlaylistConfig::default(), - false, // �ph�m�re + false, // éphémère + PlaylistRole::User, )); let write_token = playlist @@ -430,11 +443,12 @@ impl PlaylistManager { // Pas en mémoire, essayer de charger depuis la DB if let Some(persistence) = &self.inner.persistence { - if let Some((title, config, tracks)) = persistence.load_playlist(&id).await? { + if let Some((title, role, 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.clone(), title.clone(), config, true)); + let playlist = + Arc::new(Playlist::new(id.clone(), title.clone(), config, true, role)); // Restaurer les tracks { @@ -477,11 +491,12 @@ impl PlaylistManager { // 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? { + if let Some((title, role, 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)); + let playlist = + Arc::new(Playlist::new(id.to_string(), title.clone(), config, true, role)); // Restaurer les tracks { @@ -767,13 +782,14 @@ impl PlaylistManager { let new_len = core.len(); drop(core); - // Si des morceaux ont �t� �vict�s et la playlist est persistante + // 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 role = playlist.role().await; let core = playlist.core.read().await; let _ = persistence - .save_playlist(&playlist.id, &title, &core.config, &core.tracks) + .save_playlist(&playlist.id, &title, &role, &core.config, &core.tracks) .await; } } diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs index 3d9c0891..be272734 100644 --- a/pmoplaylist/src/persistence/mod.rs +++ b/pmoplaylist/src/persistence/mod.rs @@ -2,12 +2,14 @@ use crate::playlist::core::PlaylistConfig; use crate::playlist::record::Record; +use crate::playlist::PlaylistRole; 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}; +use std::str::FromStr; /// Gestionnaire de persistance (une base pour toutes les playlists) pub struct PersistenceManager { @@ -33,6 +35,7 @@ impl PersistenceManager { "CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, title TEXT NOT NULL, + role TEXT NOT NULL, max_size INTEGER, default_ttl_secs INTEGER, created_at INTEGER NOT NULL, @@ -80,6 +83,7 @@ impl PersistenceManager { &self, id: &str, title: &str, + role: &PlaylistRole, config: &PlaylistConfig, tracks: &VecDeque>, ) -> Result<()> { @@ -92,13 +96,14 @@ impl PersistenceManager { // 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)", + "INSERT OR REPLACE INTO playlists (id, title, role, max_size, default_ttl_secs, created_at, last_modified) + VALUES (?1, ?2, ?3, ?4, ?5, + COALESCE((SELECT created_at FROM playlists WHERE id = ?1), ?6), + ?6)", params![ id, title, + role.as_str(), config.max_size.map(|s| s as i64), config.default_ttl.map(|d| d.as_secs() as i64), now_nanos, @@ -135,23 +140,28 @@ impl PersistenceManager { pub async fn load_playlist( &self, id: &str, - ) -> Result>)>> { + ) -> 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") + .prepare( + "SELECT title, role, 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)?; + let role_raw: String = row.get(1)?; + let max_size: Option = row.get(2)?; + let default_ttl_secs: Option = row.get(3)?; Ok(( title, + PlaylistRole::from_str(&role_raw) + .unwrap_or_else(|_| PlaylistRole::custom(role_raw)), PlaylistConfig { max_size: max_size.map(|s| s as usize), default_ttl: default_ttl_secs.map(|s| Duration::from_secs(s as u64)), @@ -159,7 +169,7 @@ impl PersistenceManager { )) }); - let (title, config) = match result { + let (title, role, config) = match result { Ok(data) => data, Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), Err(e) => { @@ -202,7 +212,7 @@ impl PersistenceManager { tracks.push_back(Arc::new(record)); } - Ok(Some((title, config, tracks))) + Ok(Some((title, role, config, tracks))) } /// Supprime une playlist diff --git a/pmoplaylist/src/playlist/mod.rs b/pmoplaylist/src/playlist/mod.rs index a1e21025..d2cc4c60 100644 --- a/pmoplaylist/src/playlist/mod.rs +++ b/pmoplaylist/src/playlist/mod.rs @@ -4,6 +4,9 @@ pub mod core; pub mod record; use self::core::{PlaylistConfig, PlaylistCore}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Weak}; use std::time::SystemTime; @@ -30,6 +33,7 @@ impl From for PlaylistState { pub struct Playlist { pub id: String, title: RwLock, + role: RwLock, state: Arc, pub core: Arc>, pub persistent: bool, @@ -39,10 +43,17 @@ pub struct Playlist { impl Playlist { /// Crée une nouvelle playlist - pub fn new(id: String, title: String, config: PlaylistConfig, persistent: bool) -> Self { + pub fn new( + id: String, + title: String, + config: PlaylistConfig, + persistent: bool, + role: PlaylistRole, + ) -> Self { Self { id, title: RwLock::new(title), + role: RwLock::new(role), state: Arc::new(AtomicU8::new(PlaylistState::Active as u8)), core: Arc::new(RwLock::new(PlaylistCore::new(config))), persistent, @@ -78,6 +89,17 @@ impl Playlist { self.touch().await; } + /// Retourne le rôle + pub async fn role(&self) -> PlaylistRole { + self.role.read().await.clone() + } + + /// Change le rôle + pub async fn set_role(&self, role: PlaylistRole) { + *self.role.write().await = role; + self.touch().await; + } + /// Timestamp du dernier changement pub async fn last_change(&self) -> SystemTime { *self.last_change.read().await @@ -100,3 +122,90 @@ impl Playlist { Ok(token) } } + +/// Rôle logique d'une playlist +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlaylistRole { + User, + Album, + Radio, + Source, + Custom(String), +} + +impl PlaylistRole { + pub const fn user() -> Self { + PlaylistRole::User + } + + pub const fn album() -> Self { + PlaylistRole::Album + } + + pub const fn radio() -> Self { + PlaylistRole::Radio + } + + pub const fn source() -> Self { + PlaylistRole::Source + } + + pub fn custom>(value: S) -> Self { + PlaylistRole::Custom(value.into()) + } + + pub fn as_str(&self) -> &str { + match self { + PlaylistRole::User => "user", + PlaylistRole::Album => "album", + PlaylistRole::Radio => "radio", + PlaylistRole::Source => "source", + PlaylistRole::Custom(value) => value.as_str(), + } + } +} + +impl Default for PlaylistRole { + fn default() -> Self { + PlaylistRole::User + } +} + +impl fmt::Display for PlaylistRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for PlaylistRole { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for PlaylistRole { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(PlaylistRole::from_str(&value).unwrap_or_else(|_| PlaylistRole::Custom(value))) + } +} + +impl FromStr for PlaylistRole { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "user" => Ok(PlaylistRole::User), + "album" => Ok(PlaylistRole::Album), + "radio" => Ok(PlaylistRole::Radio), + "source" => Ok(PlaylistRole::Source), + other => Ok(PlaylistRole::Custom(other.to_string())), + } + } +} diff --git a/pmoqobuz/examples/lazy_loading.rs b/pmoqobuz/examples/lazy_loading.rs index 5f8b610d..38f71092 100644 --- a/pmoqobuz/examples/lazy_loading.rs +++ b/pmoqobuz/examples/lazy_loading.rs @@ -20,7 +20,7 @@ use pmoaudiocache::{register_audio_cache, AudioCacheConfigExt, Cache as AudioCac use pmoconfig::get_config; use pmocovers::Cache as CoverCache; use pmocovers::CoverCacheConfigExt; -use pmoplaylist::PlaylistManager; +use pmoplaylist::{PlaylistManager, PlaylistRole}; use pmoqobuz::{QobuzClient, QobuzSource}; use std::sync::Arc; use std::time::Instant; @@ -107,7 +107,7 @@ async fn main() -> Result<(), Box> { let playlist_manager = PlaylistManager(); let playlist_id = { let writer = playlist_manager - .create_persistent_playlist("lazy-test".to_string()) + .create_persistent_playlist_with_role("lazy-test".to_string(), PlaylistRole::Album) .await?; writer.id().to_string() }; // Drop writer here to release the lock diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index c93ee71b..2738f0f0 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -402,6 +402,11 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + writer + .set_role(pmoplaylist::PlaylistRole::Album) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + writer .push_lazy_batch(lazy_pks.clone()) .await