la crate des playlists

This commit is contained in:
2025-10-27 12:32:31 +01:00
parent a0e5d92bf9
commit 92c35116f8
23 changed files with 1943 additions and 837 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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;

View File

@@ -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)]

View 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,
}
}

View File

@@ -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"] }

View File

@@ -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])
}

View File

@@ -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,

View 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))
}
}
};
}

View File

@@ -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"]

View 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
View 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>;

View 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;

View 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)
}
}

View 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
}
}

View File

@@ -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
View 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()
}

View 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(())
}
}

View 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();
}
}

View 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)
}
}

View 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
View 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),
}
}
}