la crate des playlists
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
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)]
|
||||
|
||||
35
pmoaudiocache/src/metadata_ext.rs
Normal file
35
pmoaudiocache/src/metadata_ext.rs
Normal file
@@ -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<AudioConfig> {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -130,6 +130,20 @@ pub trait FileCache<C: CacheConfig>: 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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
234
pmocache/src/metadata_macros.rs
Normal file
234
pmocache/src/metadata_macros.rs
Normal file
@@ -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<Config>`
|
||||
//! - 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<AudioConfig> {
|
||||
//! 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<Option<TYPE>>`
|
||||
/// - `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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>>;
|
||||
}
|
||||
|
||||
// Génère set_FIELD
|
||||
paste::paste! {
|
||||
async fn [<set_ $field>](&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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
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 [<get_ $field>](&self, pk: &str) -> anyhow::Result<Option<$rust_type>> {
|
||||
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 [<set_ $field>](&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 [<set_ $field>](&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 [<set_ $field>](&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 [<set_ $field>](&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 [<set_ $field>](&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))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
20
pmoplaylist/src/config_ext.rs
Normal file
20
pmoplaylist/src/config_ext.rs
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
38
pmoplaylist/src/error.rs
Normal file
38
pmoplaylist/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, Error>;
|
||||
7
pmoplaylist/src/handle/mod.rs
Normal file
7
pmoplaylist/src/handle/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Handles pour interagir avec les playlists
|
||||
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
|
||||
pub use read::ReadHandle;
|
||||
pub use write::WriteHandle;
|
||||
242
pmoplaylist/src/handle/read.rs
Normal file
242
pmoplaylist/src/handle/read.rs
Normal file
@@ -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<Playlist>,
|
||||
cursor: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ReadHandle {
|
||||
/// Crée un nouveau handle de lecture
|
||||
pub(crate) fn new(playlist: Arc<Playlist>) -> 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<Option<PlaylistTrack>> {
|
||||
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<Option<PlaylistTrack>> {
|
||||
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<usize> {
|
||||
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<ReadHandle> {
|
||||
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<Container> {
|
||||
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<Vec<Item>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
258
pmoplaylist/src/handle/write.rs
Normal file
258
pmoplaylist/src/handle/write.rs
Normal file
@@ -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<Playlist>,
|
||||
_write_token: Arc<()>,
|
||||
}
|
||||
|
||||
impl WriteHandle {
|
||||
/// Crée un nouveau handle d'écriture
|
||||
pub(crate) fn new(playlist: Arc<Playlist>, 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<String>) -> 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<Record> = 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<usize>) -> 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<Duration>) -> 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<WriteHandle> {
|
||||
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<String> = 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<usize> {
|
||||
let core = self.playlist.core.read().await;
|
||||
core.config.max_size
|
||||
}
|
||||
|
||||
pub async fn default_ttl(&self) -> Option<Duration> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
/// Album (optionnel)
|
||||
pub album: Option<String>,
|
||||
|
||||
/// Durée en secondes (optionnel)
|
||||
pub duration: Option<u32>,
|
||||
|
||||
/// URI du flux ou fichier audio
|
||||
pub uri: String,
|
||||
|
||||
/// URL de l'image/cover (optionnel, utilise l'image par défaut de la FIFO si absent)
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// Crée un nouveau track avec les informations minimales
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoplaylist::Track;
|
||||
///
|
||||
/// let track = Track::new(
|
||||
/// "track-1",
|
||||
/// "Bohemian Rhapsody",
|
||||
/// "http://example.com/song.mp3"
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(id: impl Into<String>, title: impl Into<String>, uri: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
uri: uri.into(),
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit l'artiste du track
|
||||
pub fn with_artist(mut self, artist: impl Into<String>) -> Self {
|
||||
self.artist = Some(artist.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Définit l'album du track
|
||||
pub fn with_album(mut self, album: impl Into<String>) -> Self {
|
||||
self.album = Some(album.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Définit la durée du track en secondes
|
||||
pub fn with_duration(mut self, duration: u32) -> Self {
|
||||
self.duration = Some(duration);
|
||||
self
|
||||
}
|
||||
|
||||
/// Définit l'URL de l'image du track
|
||||
pub fn with_image(mut self, image: impl Into<String>) -> Self {
|
||||
self.image = Some(image.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Convertit le track en Item DIDL-Lite
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `parent_id` - ID du container parent
|
||||
/// * `default_image` - Image par défaut si le track n'en a pas
|
||||
fn to_didl_item(&self, parent_id: &str, default_image: Option<&str>) -> Item {
|
||||
// Formater la durée au format H:MM:SS
|
||||
let duration_str = self.duration.map(|d| {
|
||||
let hours = d / 3600;
|
||||
let minutes = (d % 3600) / 60;
|
||||
let seconds = d % 60;
|
||||
format!("{}:{:02}:{:02}", hours, minutes, seconds)
|
||||
});
|
||||
|
||||
// Utiliser l'image du track ou l'image par défaut
|
||||
let album_art = self.image.as_deref().or(default_image).map(String::from);
|
||||
|
||||
// Créer la ressource audio
|
||||
let resource = Resource {
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: duration_str,
|
||||
url: self.uri.clone(),
|
||||
};
|
||||
|
||||
Item {
|
||||
id: self.id.clone(),
|
||||
parent_id: parent_id.to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: self.title.clone(),
|
||||
creator: self.artist.clone(),
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: self.artist.clone(),
|
||||
album: self.album.clone(),
|
||||
genre: None,
|
||||
album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![resource],
|
||||
descriptions: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FIFO playlist thread-safe avec capacité configurable
|
||||
#[derive(Clone)]
|
||||
pub struct FifoPlaylist {
|
||||
inner: Arc<RwLock<FifoPlaylistInner>>,
|
||||
}
|
||||
|
||||
struct FifoPlaylistInner {
|
||||
/// Identifiant unique de la FIFO
|
||||
id: String,
|
||||
|
||||
/// Titre de la FIFO
|
||||
title: String,
|
||||
|
||||
/// Image par défaut (WebP embarquée)
|
||||
default_image: &'static [u8],
|
||||
|
||||
/// Capacité maximale de la FIFO
|
||||
capacity: usize,
|
||||
|
||||
/// Queue FIFO des tracks
|
||||
queue: VecDeque<Track>,
|
||||
|
||||
/// Numéro de version pour signaler les modifications
|
||||
update_id: u32,
|
||||
|
||||
/// Timestamp de la dernière modification
|
||||
last_change: SystemTime,
|
||||
}
|
||||
|
||||
impl FifoPlaylist {
|
||||
/// Crée une nouvelle FIFO playlist
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - Identifiant unique de la playlist
|
||||
/// * `title` - Titre de la playlist
|
||||
/// * `capacity` - Capacité maximale (nombre de tracks)
|
||||
/// * `default_image` - Image par défaut en format WebP
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoplaylist::FifoPlaylist;
|
||||
///
|
||||
/// let playlist = FifoPlaylist::new(
|
||||
/// "radio-1".to_string(),
|
||||
/// "Ma Radio".to_string(),
|
||||
/// 10,
|
||||
/// pmoplaylist::DEFAULT_IMAGE,
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(id: String, title: String, capacity: usize, default_image: &'static [u8]) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(FifoPlaylistInner {
|
||||
id,
|
||||
title,
|
||||
default_image,
|
||||
capacity,
|
||||
queue: VecDeque::new(),
|
||||
update_id: 0,
|
||||
last_change: SystemTime::now(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un track à la fin de la FIFO
|
||||
///
|
||||
/// Si la capacité est atteinte, le track le plus ancien est supprimé automatiquement.
|
||||
/// Met à jour `update_id` et `last_change`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track` - Le track à ajouter
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoplaylist::{FifoPlaylist, Track};
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// let mut playlist = FifoPlaylist::new(
|
||||
/// "playlist-1".to_string(),
|
||||
/// "My Playlist".to_string(),
|
||||
/// 5,
|
||||
/// pmoplaylist::DEFAULT_IMAGE,
|
||||
/// );
|
||||
///
|
||||
/// let track = Track::new("track-1", "Song Title", "http://example.com/song.mp3");
|
||||
/// playlist.append_track(track).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn append_track(&self, track: Track) {
|
||||
let mut inner = self.inner.write().await;
|
||||
|
||||
// Si la capacité est atteinte, supprimer le plus ancien
|
||||
if inner.queue.len() >= inner.capacity {
|
||||
inner.queue.pop_front();
|
||||
}
|
||||
|
||||
inner.queue.push_back(track);
|
||||
inner.update_id = inner.update_id.wrapping_add(1);
|
||||
inner.last_change = SystemTime::now();
|
||||
}
|
||||
|
||||
/// Supprime le track le plus ancien de la FIFO
|
||||
///
|
||||
/// Met à jour `update_id` et `last_change` si un track est supprimé.
|
||||
/// Retourne le track supprimé, ou None si la FIFO est vide.
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoplaylist::{FifoPlaylist, Track};
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// let mut playlist = FifoPlaylist::new(
|
||||
/// "playlist-1".to_string(),
|
||||
/// "My Playlist".to_string(),
|
||||
/// 5,
|
||||
/// pmoplaylist::DEFAULT_IMAGE,
|
||||
/// );
|
||||
///
|
||||
/// playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await;
|
||||
///
|
||||
/// let removed = playlist.remove_oldest().await;
|
||||
/// assert!(removed.is_some());
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn remove_oldest(&self) -> Option<Track> {
|
||||
let mut inner = self.inner.write().await;
|
||||
|
||||
let track = inner.queue.pop_front();
|
||||
|
||||
if track.is_some() {
|
||||
inner.update_id = inner.update_id.wrapping_add(1);
|
||||
inner.last_change = SystemTime::now();
|
||||
}
|
||||
|
||||
track
|
||||
}
|
||||
|
||||
/// Supprime un track par son ID
|
||||
///
|
||||
/// Met à jour `update_id` et `last_change` si un track est supprimé.
|
||||
/// Retourne true si un track a été supprimé, false sinon.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track_id` - L'ID du track à supprimer
|
||||
pub async fn remove_by_id(&self, track_id: &str) -> bool {
|
||||
let mut inner = self.inner.write().await;
|
||||
|
||||
if let Some(pos) = inner.queue.iter().position(|t| t.id == track_id) {
|
||||
inner.queue.remove(pos);
|
||||
inner.update_id = inner.update_id.wrapping_add(1);
|
||||
inner.last_change = SystemTime::now();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Vide complètement la FIFO
|
||||
///
|
||||
/// Met à jour `update_id` et `last_change` si la FIFO n'était pas vide.
|
||||
pub async fn clear(&self) {
|
||||
let mut inner = self.inner.write().await;
|
||||
|
||||
if !inner.queue.is_empty() {
|
||||
inner.queue.clear();
|
||||
inner.update_id = inner.update_id.wrapping_add(1);
|
||||
inner.last_change = SystemTime::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nombre de tracks dans la FIFO
|
||||
pub async fn len(&self) -> usize {
|
||||
let inner = self.inner.read().await;
|
||||
inner.queue.len()
|
||||
}
|
||||
|
||||
/// Vérifie si 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<F>(&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<Track> {
|
||||
let inner = self.inner.read().await;
|
||||
|
||||
inner
|
||||
.queue
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.take(count)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Retourne l'update_id actuel
|
||||
///
|
||||
/// L'update_id est incrémenté à chaque modification de la FIFO.
|
||||
/// Utile pour détecter les changements côté client UPnP.
|
||||
pub async fn update_id(&self) -> u32 {
|
||||
let inner = self.inner.read().await;
|
||||
inner.update_id
|
||||
}
|
||||
|
||||
/// Retourne le timestamp de la dernière modification
|
||||
pub async fn last_change(&self) -> SystemTime {
|
||||
let inner = self.inner.read().await;
|
||||
inner.last_change
|
||||
}
|
||||
|
||||
/// Retourne l'ID de la playlist
|
||||
pub async fn id(&self) -> String {
|
||||
let inner = self.inner.read().await;
|
||||
inner.id.clone()
|
||||
}
|
||||
|
||||
/// Retourne le titre de la playlist
|
||||
pub async fn title(&self) -> String {
|
||||
let inner = self.inner.read().await;
|
||||
inner.title.clone()
|
||||
}
|
||||
|
||||
/// Génère un Container DIDL-Lite représentant cette FIFO
|
||||
///
|
||||
/// Le container peut être utilisé pour le ContentDirectory UPnP.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `parent_id` - ID du container parent (par défaut "0" pour la racine)
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoplaylist::FifoPlaylist;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// let playlist = FifoPlaylist::new(
|
||||
/// "radio-1".to_string(),
|
||||
/// "Ma Radio".to_string(),
|
||||
/// 10,
|
||||
/// pmoplaylist::DEFAULT_IMAGE,
|
||||
/// );
|
||||
///
|
||||
/// let container = playlist.as_container_with_parent("0").await;
|
||||
/// println!("Container: {:?}", container);
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn as_container_with_parent(&self, parent_id: impl Into<String>) -> Container {
|
||||
let inner = self.inner.read().await;
|
||||
|
||||
Container {
|
||||
id: inner.id.clone(),
|
||||
parent_id: parent_id.into(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(inner.queue.len().to_string()),
|
||||
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<Item> {
|
||||
let inner = self.inner.read().await;
|
||||
|
||||
inner
|
||||
.queue
|
||||
.iter()
|
||||
.skip(offset)
|
||||
.take(count)
|
||||
.map(|track| track.to_didl_item(&inner.id, default_image_url))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Retourne l'image par défaut en tant que slice de bytes
|
||||
///
|
||||
/// Peut être servi via un endpoint HTTP pour les clients UPnP
|
||||
pub async fn default_image(&self) -> &'static [u8] {
|
||||
let inner = self.inner.read().await;
|
||||
inner.default_image
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_playlist() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
assert_eq!(playlist.len().await, 0);
|
||||
assert!(playlist.is_empty().await);
|
||||
assert_eq!(playlist.update_id().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_append_track() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
let track = Track::new("track-1", "Song 1", "http://example.com/1.mp3");
|
||||
playlist.append_track(track).await;
|
||||
|
||||
assert_eq!(playlist.len().await, 1);
|
||||
assert_eq!(playlist.update_id().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fifo_capacity() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
3,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
// Ajouter 5 tracks alors que la capacité est 3
|
||||
for i in 0..5 {
|
||||
let track = Track::new(
|
||||
format!("track-{}", i),
|
||||
format!("Song {}", i),
|
||||
format!("http://example.com/{}.mp3", i),
|
||||
);
|
||||
playlist.append_track(track).await;
|
||||
}
|
||||
|
||||
// Seuls les 3 derniers doivent rester
|
||||
assert_eq!(playlist.len().await, 3);
|
||||
|
||||
let items = playlist.get_items(0, 10).await;
|
||||
assert_eq!(items[0].id, "track-2");
|
||||
assert_eq!(items[2].id, "track-4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_oldest() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3"))
|
||||
.await;
|
||||
playlist
|
||||
.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3"))
|
||||
.await;
|
||||
|
||||
let removed = playlist.remove_oldest().await;
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(removed.unwrap().id, "track-1");
|
||||
assert_eq!(playlist.len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_by_id() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3"))
|
||||
.await;
|
||||
playlist
|
||||
.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3"))
|
||||
.await;
|
||||
playlist
|
||||
.append_track(Track::new("track-3", "Song 3", "http://example.com/3.mp3"))
|
||||
.await;
|
||||
|
||||
assert!(playlist.remove_by_id("track-2").await);
|
||||
assert_eq!(playlist.len().await, 2);
|
||||
|
||||
let items = playlist.get_items(0, 10).await;
|
||||
assert_eq!(items[0].id, "track-1");
|
||||
assert_eq!(items[1].id, "track-3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3"))
|
||||
.await;
|
||||
playlist
|
||||
.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3"))
|
||||
.await;
|
||||
|
||||
playlist.clear().await;
|
||||
assert_eq!(playlist.len().await, 0);
|
||||
assert!(playlist.is_empty().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_items_pagination() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
10,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
for i in 0..5 {
|
||||
playlist
|
||||
.append_track(Track::new(
|
||||
format!("track-{}", i),
|
||||
format!("Song {}", i),
|
||||
format!("http://example.com/{}.mp3", i),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let items = playlist.get_items(1, 2).await;
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "track-1");
|
||||
assert_eq!(items[1].id, "track-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_as_container() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"radio-1".to_string(),
|
||||
"Test Radio".to_string(),
|
||||
10,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3"))
|
||||
.await;
|
||||
|
||||
let container = playlist.as_container().await;
|
||||
assert_eq!(container.id, "radio-1");
|
||||
assert_eq!(container.title, "Test Radio");
|
||||
assert_eq!(container.parent_id, "0");
|
||||
assert_eq!(container.child_count, Some("1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_as_objects() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"radio-1".to_string(),
|
||||
"Test Radio".to_string(),
|
||||
10,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
let track = Track::new(
|
||||
"track-1",
|
||||
"Bohemian Rhapsody",
|
||||
"http://example.com/song.mp3",
|
||||
)
|
||||
.with_artist("Queen")
|
||||
.with_album("A Night at the Opera")
|
||||
.with_duration(354);
|
||||
|
||||
playlist.append_track(track).await;
|
||||
|
||||
let items = playlist
|
||||
.as_objects(0, 10, Some("http://server/default.webp"))
|
||||
.await;
|
||||
assert_eq!(items.len(), 1);
|
||||
|
||||
let item = &items[0];
|
||||
assert_eq!(item.id, "track-1");
|
||||
assert_eq!(item.title, "Bohemian Rhapsody");
|
||||
assert_eq!(item.artist, Some("Queen".to_string()));
|
||||
assert_eq!(item.album, Some("A Night at the Opera".to_string()));
|
||||
assert_eq!(item.parent_id, "radio-1");
|
||||
assert!(item.resources.len() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_track_builder() {
|
||||
let track = Track::new("track-1", "Song", "http://example.com/song.mp3")
|
||||
.with_artist("Artist")
|
||||
.with_album("Album")
|
||||
.with_duration(180)
|
||||
.with_image("http://example.com/cover.jpg");
|
||||
|
||||
assert_eq!(track.artist, Some("Artist".to_string()));
|
||||
assert_eq!(track.album, Some("Album".to_string()));
|
||||
assert_eq!(track.duration, Some(180));
|
||||
assert_eq!(
|
||||
track.image,
|
||||
Some("http://example.com/cover.jpg".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_id_increments() {
|
||||
let playlist = FifoPlaylist::new(
|
||||
"test-1".to_string(),
|
||||
"Test Playlist".to_string(),
|
||||
5,
|
||||
DEFAULT_IMAGE,
|
||||
);
|
||||
|
||||
assert_eq!(playlist.update_id().await, 0);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3"))
|
||||
.await;
|
||||
assert_eq!(playlist.update_id().await, 1);
|
||||
|
||||
playlist
|
||||
.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3"))
|
||||
.await;
|
||||
assert_eq!(playlist.update_id().await, 2);
|
||||
|
||||
playlist.remove_oldest().await;
|
||||
assert_eq!(playlist.update_id().await, 3);
|
||||
|
||||
playlist.clear().await;
|
||||
assert_eq!(playlist.update_id().await, 4);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
296
pmoplaylist/src/manager.rs
Normal file
296
pmoplaylist/src/manager.rs
Normal file
@@ -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<PlaylistManager> = OnceCell::new();
|
||||
|
||||
/// Structure interne du manager
|
||||
struct ManagerInner {
|
||||
playlists: RwLock<HashMap<String, Arc<Playlist>>>,
|
||||
persistence: Option<Arc<PersistenceManager>>,
|
||||
}
|
||||
|
||||
/// Gestionnaire central de playlists
|
||||
pub struct PlaylistManager {
|
||||
inner: Arc<ManagerInner>,
|
||||
}
|
||||
|
||||
impl PlaylistManager {
|
||||
/// Initialise le gestionnaire (<28> appeler une seule fois au d<>marrage)
|
||||
fn init(db_path: PathBuf) -> Result<Self> {
|
||||
// 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'<27>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<Self> {
|
||||
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<43>e une playlist persistante (erreur si existe d<>j<EFBFBD>)
|
||||
pub async fn create_persistent_playlist(&self, id: String) -> Result<WriteHandle> {
|
||||
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<71>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<75>re un write handle (cr<63>e <20>ph<70>m<EFBFBD>re si n'existe pas)
|
||||
pub async fn get_write_handle(&self, id: String) -> Result<WriteHandle> {
|
||||
let mut playlists = self.inner.playlists.write().await;
|
||||
|
||||
if let Some(playlist) = playlists.get(&id) {
|
||||
// Playlist existe, tenter d'acqu<71>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<63>er <20>ph<70>m<EFBFBD>re
|
||||
let playlist = Arc::new(Playlist::new(
|
||||
id.clone(),
|
||||
id.clone(),
|
||||
PlaylistConfig::default(),
|
||||
false, // <20>ph<70>m<EFBFBD>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<75>re un write handle persistant (cr<63>e si n'existe pas)
|
||||
pub async fn get_persistent_write_handle(&self, id: String) -> Result<WriteHandle> {
|
||||
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<63>er persistent
|
||||
self.create_persistent_playlist(id).await
|
||||
}
|
||||
|
||||
/// R<>cup<75>re un read handle (ressuscite depuis DB si besoin)
|
||||
pub async fn get_read_handle(&self, id: &str) -> Result<ReadHandle> {
|
||||
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<String> {
|
||||
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<EFBFBD>rence au PersistenceManager
|
||||
pub(crate) fn persistence(&self) -> Option<&Arc<PersistenceManager>> {
|
||||
self.inner.persistence.as_ref()
|
||||
}
|
||||
|
||||
/// Task d'<27>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 <20>t<EFBFBD> <20>vict<63>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<65> depuis WriteHandle)
|
||||
pub(crate) async fn delete_playlist_internal(id: &str) -> Result<()> {
|
||||
PlaylistManager::get().delete_playlist(id).await
|
||||
}
|
||||
|
||||
/// Helper pour acc<63>der au cache audio
|
||||
pub(crate) fn audio_cache() -> Result<Arc<pmoaudiocache::Cache>> {
|
||||
pmoupnp::get_audio_cache()
|
||||
.ok_or_else(|| crate::Error::ManagerNotInitialized)
|
||||
}
|
||||
|
||||
/// Fonction raccourcie pour acc<63>der au singleton
|
||||
pub fn PlaylistManager() -> &'static PlaylistManager {
|
||||
PlaylistManager::get()
|
||||
}
|
||||
215
pmoplaylist/src/persistence/mod.rs
Normal file
215
pmoplaylist/src/persistence/mod.rs
Normal file
@@ -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<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl PersistenceManager {
|
||||
/// Initialise le gestionnaire de persistance
|
||||
pub fn new(db_path: &Path) -> Result<Self> {
|
||||
// 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<Arc<Record>>,
|
||||
) -> 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<Option<(String, PlaylistConfig, VecDeque<Arc<Record>>)>> {
|
||||
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<i64> = row.get(1)?;
|
||||
let default_ttl_secs: Option<i64> = 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<i64> = 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<Vec<String>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
111
pmoplaylist/src/playlist/core.rs
Normal file
111
pmoplaylist/src/playlist/core.rs
Normal file
@@ -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<usize>,
|
||||
pub default_ttl: Option<Duration>,
|
||||
}
|
||||
|
||||
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<Arc<Record>>,
|
||||
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<Record>) {
|
||||
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<Arc<Record>> {
|
||||
self.tracks.get(index).cloned()
|
||||
}
|
||||
|
||||
/// Snapshot de tous les records
|
||||
pub fn snapshot(&self) -> Vec<Arc<Record>> {
|
||||
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<usize>) {
|
||||
self.config.max_size = max_size;
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Met à jour le TTL par défaut
|
||||
pub fn set_default_ttl(&mut self, ttl: Option<Duration>) {
|
||||
self.config.default_ttl = ttl;
|
||||
self.evict();
|
||||
}
|
||||
}
|
||||
102
pmoplaylist/src/playlist/mod.rs
Normal file
102
pmoplaylist/src/playlist/mod.rs
Normal file
@@ -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<u8> 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<String>,
|
||||
state: Arc<AtomicU8>,
|
||||
pub core: Arc<RwLock<PlaylistCore>>,
|
||||
pub persistent: bool,
|
||||
last_change: RwLock<SystemTime>,
|
||||
writer_lock: RwLock<Option<Weak<()>>>,
|
||||
}
|
||||
|
||||
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<Arc<()>, ()> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
61
pmoplaylist/src/playlist/record.rs
Normal file
61
pmoplaylist/src/playlist/record.rs
Normal file
@@ -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<Duration>,
|
||||
}
|
||||
|
||||
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<Duration>) -> 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
|
||||
}
|
||||
}
|
||||
141
pmoplaylist/src/track.rs
Normal file
141
pmoplaylist/src/track.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<pmoaudiocache::AudioMetadata> {
|
||||
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<Option<String>> {
|
||||
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<Option<String>> {
|
||||
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<Option<String>> {
|
||||
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<Option<u64>> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user