From 876b1e01479762f1dddb0c3f2cd175b5c9117e12 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 28 Dec 2025 09:56:05 +0100 Subject: [PATCH 1/3] update de qobuz pour utiliser les sources lazy --- .claude-env | 3 + pmoplaylist/src/manager.rs | 22 +++ pmoplaylist/src/persistence/mod.rs | 22 +++ pmoqobuz/src/cache.rs | 31 ++++ pmoqobuz/src/client.rs | 12 +- pmoqobuz/src/didl.rs | 14 +- pmoqobuz/src/source.rs | 227 +++++++++++++++++++++++++---- pmoupnp/src/config_ext.rs | 128 ++++++++++++++++ pmoupnp/src/lib.rs | 2 + rust-analyzer.toml | 46 ++++++ 10 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 .claude-env create mode 100644 pmoupnp/src/config_ext.rs create mode 100644 rust-analyzer.toml diff --git a/.claude-env b/.claude-env new file mode 100644 index 00000000..05f64415 --- /dev/null +++ b/.claude-env @@ -0,0 +1,3 @@ +# Configuration PATH pour Claude Code +# Ce fichier sera lu automatiquement pour configurer l'environnement +export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin" diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index 4fc643b0..87168119 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -224,6 +224,28 @@ impl PlaylistManager { .await } + /// Récupère l'âge d'une playlist depuis sa création + pub async fn get_playlist_age(&self, id: &str) -> Result> { + use std::time::{SystemTime, UNIX_EPOCH}; + + let persistence = match &self.inner.persistence { + Some(p) => p, + None => return Ok(None), + }; + + let created_at_nanos = persistence.get_playlist_created_at(id).await?; + + if let Some(nanos) = created_at_nanos { + let created_at = UNIX_EPOCH + Duration::from_nanos(nanos as u64); + let age = SystemTime::now() + .duration_since(created_at) + .unwrap_or(Duration::ZERO); + Ok(Some(age)) + } else { + Ok(None) + } + } + /// Enregistre un callback d'évènement playlist (update, track joué). /// /// Retourne un jeton (u64) pour désenregistrer plus tard. diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs index 881dcea3..3dfb2883 100644 --- a/pmoplaylist/src/persistence/mod.rs +++ b/pmoplaylist/src/persistence/mod.rs @@ -259,6 +259,28 @@ impl PersistenceManager { Ok(ids) } + /// Récupère le timestamp de création d'une playlist + pub async fn get_playlist_created_at(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn + .prepare("SELECT created_at 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| row.get(0)); + + match result { + Ok(created_at) => Ok(Some(created_at)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(crate::Error::PersistenceError(format!( + "Failed to get created_at: {}", + e + ))), + } + } + /// 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(); diff --git a/pmoqobuz/src/cache.rs b/pmoqobuz/src/cache.rs index 4bb0061f..e3543751 100644 --- a/pmoqobuz/src/cache.rs +++ b/pmoqobuz/src/cache.rs @@ -14,6 +14,8 @@ pub struct QobuzCache { albums: Arc>, /// Cache des tracks (TTL: 1 heure) tracks: Arc>, + /// Cache des tracks d'un album complet (TTL: 1 heure) + album_tracks: Arc>>, /// Cache des artistes (TTL: 1 heure) artists: Arc>, /// Cache des playlists (TTL: 30 minutes) @@ -45,6 +47,12 @@ impl QobuzCache { .time_to_live(Duration::from_secs(3600)) // 1 heure .build(), ), + album_tracks: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), artists: Arc::new( MokaCache::builder() .max_capacity(max_capacity / 2) @@ -106,6 +114,23 @@ impl QobuzCache { self.tracks.invalidate(id).await; } + // ============ Album Tracks (liste complète des tracks d'un album) ============ + + /// Récupère la liste complète des tracks d'un album depuis le cache + pub async fn get_album_tracks(&self, album_id: &str) -> Option> { + self.album_tracks.get(album_id).await + } + + /// Ajoute la liste complète des tracks d'un album au cache + pub async fn put_album_tracks(&self, album_id: String, tracks: Vec) { + self.album_tracks.insert(album_id, tracks).await; + } + + /// Invalide la liste des tracks d'un album du cache + pub async fn invalidate_album_tracks(&self, album_id: &str) { + self.album_tracks.invalidate(album_id).await; + } + // ============ Artists ============ /// Récupère un artiste depuis le cache @@ -180,6 +205,7 @@ impl QobuzCache { pub async fn clear_all(&self) { self.albums.invalidate_all(); self.tracks.invalidate_all(); + self.album_tracks.invalidate_all(); self.artists.invalidate_all(); self.playlists.invalidate_all(); self.searches.invalidate_all(); @@ -190,6 +216,7 @@ impl QobuzCache { pub async fn stats(&self) -> CacheStats { self.albums.run_pending_tasks().await; self.tracks.run_pending_tasks().await; + self.album_tracks.run_pending_tasks().await; self.artists.run_pending_tasks().await; self.playlists.run_pending_tasks().await; self.searches.run_pending_tasks().await; @@ -198,6 +225,7 @@ impl QobuzCache { CacheStats { albums_count: self.albums.entry_count(), tracks_count: self.tracks.entry_count(), + album_tracks_count: self.album_tracks.entry_count(), artists_count: self.artists.entry_count(), playlists_count: self.playlists.entry_count(), searches_count: self.searches.entry_count(), @@ -219,6 +247,8 @@ pub struct CacheStats { pub albums_count: u64, /// Nombre de tracks en cache pub tracks_count: u64, + /// Nombre de listes complètes de tracks d'albums en cache + pub album_tracks_count: u64, /// Nombre d'artistes en cache pub artists_count: u64, /// Nombre de playlists en cache @@ -234,6 +264,7 @@ impl CacheStats { pub fn total_count(&self) -> u64 { self.albums_count + self.tracks_count + + self.album_tracks_count + self.artists_count + self.playlists_count + self.searches_count diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index ebc4ff30..285628d9 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -584,14 +584,24 @@ impl QobuzClient { /// Récupère les tracks d'un album pub async fn get_album_tracks(&self, album_id: &str) -> Result> { + // Vérifier le cache d'abord + if let Some(tracks) = self.cache.get_album_tracks(album_id).await { + debug!("Album tracks for {} found in cache", album_id); + return Ok(tracks); + } + + // Sinon, récupérer depuis l'API let tracks = self .call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id)) .await?; - // Mettre les tracks en cache + // Mettre les tracks en cache (individuellement ET la liste complète) for track in &tracks { self.cache.put_track(track.id.clone(), track.clone()).await; } + self.cache + .put_album_tracks(album_id.to_string(), tracks.clone()) + .await; Ok(tracks) } diff --git a/pmoqobuz/src/didl.rs b/pmoqobuz/src/didl.rs index 2b8866c5..db077cc6 100644 --- a/pmoqobuz/src/didl.rs +++ b/pmoqobuz/src/didl.rs @@ -27,10 +27,10 @@ impl ToDIDL for Album { /// /// ```rust,ignore /// let album = client.get_album("12345").await?; - /// let container = album.to_didl_container("0$qobuz$albums")?; + /// let container = album.to_didl_container("qobuz:favorites")?; /// ``` fn to_didl_container(&self, parent_id: &str) -> Result { - let id = format!("0$qobuz$album${}", self.id); + let id = format!("qobuz:album:{}", self.id); Ok(Container { id, @@ -71,10 +71,10 @@ impl ToDIDL for Track { /// /// ```rust,ignore /// let track = client.get_track("98765").await?; - /// let item = track.to_didl_item("0$qobuz$album$12345")?; + /// let item = track.to_didl_item("qobuz:album:12345")?; /// ``` fn to_didl_item(&self, parent_id: &str) -> Result { - let id = format!("0$qobuz$track${}", self.id); + let id = format!("qobuz:track:{}", self.id); // Déterminer l'artiste à afficher let artist_name = self @@ -128,7 +128,7 @@ impl ToDIDL for Track { impl ToDIDL for Playlist { /// Convertit une playlist en Container DIDL fn to_didl_container(&self, parent_id: &str) -> Result { - let id = format!("0$qobuz$playlist${}", self.id); + let id = format!("qobuz:playlist:{}", self.id); Ok(Container { id, @@ -211,7 +211,7 @@ mod tests { }; let container = album.to_didl_container("parent").unwrap(); - assert_eq!(container.id, "0$qobuz$album$123"); + assert_eq!(container.id, "qobuz:album:123"); assert_eq!(container.parent_id, "parent"); assert!(container.title.contains("Test Album")); } @@ -234,7 +234,7 @@ mod tests { }; let item = track.to_didl_item("parent").unwrap(); - assert_eq!(item.id, "0$qobuz$track$789"); + assert_eq!(item.id, "qobuz:track:789"); assert_eq!(item.parent_id, "parent"); assert_eq!(item.title, "Test Track"); } diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 2738f0f0..b7f2d3a0 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -14,7 +14,10 @@ use pmosource::SourceCacheManager; use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; use serde_json::json; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; + +/// TTL pour les playlists d'albums (7 jours) +const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); /// Default image for Qobuz (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); @@ -322,6 +325,13 @@ impl QobuzSource { ); } + // Stocker le track_id Qobuz pour reconstruction DIDL ultérieure + let _ = self.inner.cache_manager.set_audio_metadata( + &cached_audio_pk, + "qobuz_track_id", + json!(track.id), + ); + // 4. Store metadata self.inner .cache_manager @@ -398,7 +408,7 @@ impl QobuzSource { // 3. Batch insert into playlist (single DB transaction) let playlist_manager = pmoplaylist::PlaylistManager(); let writer = playlist_manager - .get_write_handle(playlist_id.to_string()) + .get_persistent_write_handle(playlist_id.to_string()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; @@ -489,7 +499,7 @@ impl QobuzSource { // 3. Batch insert into playlist (single DB transaction) let playlist_manager = pmoplaylist::PlaylistManager(); let writer = playlist_manager - .get_write_handle(playlist_id.to_string()) + .get_persistent_write_handle(playlist_id.to_string()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; @@ -511,6 +521,181 @@ impl QobuzSource { Ok(lazy_pks.len()) } + /// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide) + async fn is_album_playlist_valid(&self, playlist_id: &str) -> Result { + let playlist_manager = pmoplaylist::PlaylistManager(); + + if !playlist_manager.exists(playlist_id).await { + return Ok(false); + } + + // Vérifier l'âge + match playlist_manager.get_playlist_age(playlist_id).await { + Ok(Some(age)) if age < ALBUM_PLAYLIST_TTL => { + // Playlist non expirée, vérifier qu'elle contient des tracks + match playlist_manager.get_read_handle(playlist_id).await { + Ok(reader) => { + let count = reader.remaining().await.unwrap_or(0); + Ok(count > 0) // Valide seulement si non vide + } + Err(_) => Ok(false), + } + } + _ => Ok(false), + } + } + + /// Adapte les Items d'une playlist pour correspondre au schéma UPnP Qobuz + async fn adapt_playlist_items_to_qobuz( + &self, + items: Vec, + album_id: &str, + ) -> Result> { + use tracing::warn; + + let parent_id = format!("qobuz:album:{}", album_id); + + let mut adapted = Vec::with_capacity(items.len()); + + for mut item in items { + // Extraire cache_pk depuis l'URL du resource + let cache_pk = if let Some(resource) = item.resources.first() { + resource.url + .strip_prefix("/audio/flac/") + .map(|s| s.to_string()) + } else { + None + }; + + if let Some(pk) = cache_pk { + // Récupérer track_id depuis metadata + if let Ok(Some(track_id_value)) = self.inner.cache_manager.get_audio_metadata(&pk, "qobuz_track_id") { + if let Some(track_id) = track_id_value.as_str() { + item.id = format!("qobuz:track:{}", track_id); + } else { + warn!("qobuz_track_id not a string for {}", pk); + } + } else { + warn!("No qobuz_track_id metadata for {}", pk); + } + } + + item.parent_id = parent_id.clone(); + adapted.push(item); + } + + Ok(adapted) + } + + /// Récupère ou crée une playlist lazy pour un album + async fn get_or_create_album_playlist_items( + &self, + album_id: &str, + limit: usize, + ) -> Result> { + use tracing::{debug, info}; + + let playlist_id = format!("qobuz-album-{}", album_id); + let playlist_manager = pmoplaylist::PlaylistManager(); + + // Vérifier validité (existe ET non expirée) + let is_valid = self.is_album_playlist_valid(&playlist_id).await?; + + if is_valid { + debug!("Album playlist {} found and valid", playlist_id); + + let reader = playlist_manager + .get_read_handle(&playlist_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + let items = reader + .to_items(limit) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + return self.adapt_playlist_items_to_qobuz(items, album_id).await; + } + + // Playlist invalide/inexistante : (re)créer + info!("Album playlist {} creating/refreshing", playlist_id); + + // 1. Métadonnées album + let album = self + .inner + .client + .get_album(album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // 2. Cache cover + let cover_pk = if let Some(ref image_url) = album.image { + self.inner + .cache_manager + .cache_cover(image_url) + .await + .ok() + } else { + None + }; + + // 3. Créer ou récupérer playlist + let writer = if playlist_manager.exists(&playlist_id).await { + let writer = playlist_manager + .get_persistent_write_handle(playlist_id.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .flush() + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + } else { + playlist_manager + .create_persistent_playlist_with_role( + playlist_id.clone(), + pmoplaylist::PlaylistRole::Album, + ) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))? + }; + + // 4. Métadonnées playlist + writer + .set_title(album.title.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + if let Some(pk) = cover_pk { + writer + .set_cover_pk(Some(pk)) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + } + + // IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist + drop(writer); + + // 5. Ajouter tracks (réutilise add_album_to_playlist existant) + self.add_album_to_playlist(&playlist_id, album_id).await?; + + // 6. Récupérer items + let reader = playlist_manager + .get_read_handle(&playlist_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + let items = reader + .to_items(limit) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 7. Adapter IDs + self.adapt_playlist_items_to_qobuz(items, album_id).await + } + /// Increment update counter (called on catalog changes) async fn increment_update_id(&self) { let mut counter = self.inner.update_counter.write().await; @@ -620,22 +805,9 @@ impl MusicSource for QobuzSource { } ObjectIdType::Album(album_id) => { - // Get tracks in album - let tracks = self - .inner - .client - .get_album_tracks(&album_id) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let items: Vec = tracks - .into_iter() - .filter_map(|track| { - track - .to_didl_item(&format!("qobuz:album:{}", album_id)) - .ok() - }) - .collect(); + let items = self + .get_or_create_album_playlist_items(&album_id, usize::MAX) + .await?; Ok(BrowseResult::Items(items)) } @@ -1041,23 +1213,14 @@ impl MusicSource for QobuzSource { ) -> Result { match self.parse_object_id(object_id) { ObjectIdType::Album(album_id) => { - // Qobuz returns all tracks, so we slice them - let tracks = self - .inner - .client - .get_album_tracks(&album_id) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let all_items = self + .get_or_create_album_playlist_items(&album_id, usize::MAX) + .await?; - let items: Vec = tracks + let items: Vec = all_items .into_iter() .skip(offset) .take(limit) - .filter_map(|track| { - track - .to_didl_item(&format!("qobuz:album:{}", album_id)) - .ok() - }) .collect(); Ok(BrowseResult::Items(items)) diff --git a/pmoupnp/src/config_ext.rs b/pmoupnp/src/config_ext.rs new file mode 100644 index 00000000..cd239673 --- /dev/null +++ b/pmoupnp/src/config_ext.rs @@ -0,0 +1,128 @@ +//! Extension pour intégrer la configuration UPnP dans pmoconfig +//! +//! Ce module fournit le trait `UpnpConfigExt` qui permet d'ajouter facilement +//! des méthodes de configuration UPnP à pmoconfig::Config. +//! +//! Il suit le même pattern que pmocache/src/config_ext.rs pour la cohérence. + +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::Value; + +// Constantes par défaut pour les noms UPnP +const DEFAULT_MANUFACTURER: &str = "PMOMusic"; +const DEFAULT_UDN_PREFIX: &str = "pmomusic"; +const DEFAULT_MODEL_NAME_PREFIX: &str = "PMOMusic"; +const DEFAULT_FRIENDLY_NAME_PREFIX: &str = "PMOMusic"; + +/// Trait d'extension pour ajouter la configuration UPnP à pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes pour configurer +/// les noms et identifiants des devices UPnP. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoupnp::UpnpConfigExt; +/// +/// let config = get_config(); +/// let manufacturer = config.get_upnp_manufacturer()?; +/// let udn_prefix = config.get_upnp_udn_prefix()?; +/// ``` +pub trait UpnpConfigExt { + /// Récupère le fabricant pour les devices UPnP + /// + /// # Returns + /// + /// Le nom du fabricant à afficher dans les descripteurs UPnP (défaut: "PMOMusic") + fn get_upnp_manufacturer(&self) -> Result; + + /// Définit le fabricant pour les devices UPnP + fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()>; + + /// Récupère le préfixe UDN pour les devices UPnP + /// + /// # Returns + /// + /// Le préfixe utilisé pour générer les UDN (défaut: "pmomusic") + fn get_upnp_udn_prefix(&self) -> Result; + + /// Définit le préfixe UDN pour les devices UPnP + fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()>; + + /// Récupère le préfixe pour les noms de modèle des devices UPnP + /// + /// # Returns + /// + /// Le préfixe utilisé pour construire les model names (défaut: "PMOMusic") + fn get_upnp_model_name_prefix(&self) -> Result; + + /// Définit le préfixe pour les noms de modèle des devices UPnP + fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()>; + + /// Récupère le préfixe pour les noms conviviaux des devices UPnP + /// + /// # Returns + /// + /// Le préfixe utilisé pour construire les friendly names (défaut: "PMOMusic") + fn get_upnp_friendly_name_prefix(&self) -> Result; + + /// Définit le préfixe pour les noms conviviaux des devices UPnP + fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()>; +} + +impl UpnpConfigExt for Config { + fn get_upnp_manufacturer(&self) -> Result { + match self.get_value(&["host", "upnp", "manufacturer"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(s), + _ => Ok(DEFAULT_MANUFACTURER.to_string()), + } + } + + fn set_upnp_manufacturer(&self, manufacturer: String) -> Result<()> { + self.set_value( + &["host", "upnp", "manufacturer"], + Value::String(manufacturer), + ) + } + + fn get_upnp_udn_prefix(&self) -> Result { + match self.get_value(&["host", "upnp", "udn_prefix"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(s), + _ => Ok(DEFAULT_UDN_PREFIX.to_string()), + } + } + + fn set_upnp_udn_prefix(&self, prefix: String) -> Result<()> { + self.set_value(&["host", "upnp", "udn_prefix"], Value::String(prefix)) + } + + fn get_upnp_model_name_prefix(&self) -> Result { + match self.get_value(&["host", "upnp", "model_name_prefix"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(s), + _ => Ok(DEFAULT_MODEL_NAME_PREFIX.to_string()), + } + } + + fn set_upnp_model_name_prefix(&self, prefix: String) -> Result<()> { + self.set_value( + &["host", "upnp", "model_name_prefix"], + Value::String(prefix), + ) + } + + fn get_upnp_friendly_name_prefix(&self) -> Result { + match self.get_value(&["host", "upnp", "friendly_name_prefix"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(s), + _ => Ok(DEFAULT_FRIENDLY_NAME_PREFIX.to_string()), + } + } + + fn set_upnp_friendly_name_prefix(&self, prefix: String) -> Result<()> { + self.set_value( + &["host", "upnp", "friendly_name_prefix"], + Value::String(prefix), + ) + } +} diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 8340a145..c9349e82 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -3,6 +3,7 @@ mod object_trait; pub mod actions; pub mod cache_registry; +pub mod config_ext; pub mod devices; pub mod services; pub mod soap; @@ -20,6 +21,7 @@ use std::{collections::HashMap, sync::Arc}; pub use pmoaudiocache::get_audio_cache; pub use pmocovers::get_cover_cache; +pub use crate::config_ext::UpnpConfigExt; pub use crate::object_trait::*; pub use crate::upnp_server::UpnpServerExt; diff --git a/rust-analyzer.toml b/rust-analyzer.toml new file mode 100644 index 00000000..3fa2e20a --- /dev/null +++ b/rust-analyzer.toml @@ -0,0 +1,46 @@ +# Configuration rust-analyzer pour gros workspace (23 crates) +# Optimisé pour éviter les crashs et limiter l'utilisation mémoire + +# Limiter l'analyse en arrière-plan +[checkOnSave] +enable = true +# N'analyser que la cible par défaut (pas tous les targets) +allTargets = false +# Utiliser clippy au lieu de cargo check (optionnel, commentez si trop lent) +# command = "clippy" + +# Désactiver les build scripts pour réduire la charge +[cargo] +buildScripts.enable = false +# Ne charger que les crates nécessaires +loadOutDirsFromCheck = false + +# Désactiver les proc-macros si elles causent des problèmes +[procMacro] +enable = true +# Si les crashs persistent, passez à false ci-dessus + +# Limiter la complétion +[completion] +limit = 50 + +# Désactiver certains diagnostics coûteux +[diagnostics] +disabled = [ + "unresolved-proc-macro", + "macro-error", +] + +# Optimisations de performance +[inlayHints] +# Réduire les hints pour améliorer les perfs +maxLength = 25 + +# Limiter la profondeur d'analyse des types +[typing] +autoClosingAngleBrackets.enable = false + +# Pour les très gros workspaces, décommenter pour analyser moins de crates +# [linkedProjects] +# Spécifier uniquement les crates que vous éditez activement +# Par exemple : ["PMOMusic/Cargo.toml", "pmocontrol/Cargo.toml"] From 0c64ed1a9aa56d3d367048e7e3c36906e824a26d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 28 Dec 2025 11:36:20 +0100 Subject: [PATCH 2/3] Autorise la configuration des noms UPNP. --- .claude/CLAUDE.md | 31 ++++++++++++++ Cargo.lock | 1 + pmoconfig/src/pmomusic.yaml | 5 +++ pmomediarenderer/src/device.rs | 7 +-- pmomediaserver/src/device.rs | 7 +-- pmoupnp/Cargo.toml | 1 + pmoupnp/src/devices/device.rs | 78 ++++++++++++++++++++++++++++++++++ pmoupnp/src/upnp_server.rs | 7 ++- 8 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..e3df146e --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,31 @@ +# PMOMusic Project Configuration + +## Version Control +Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git. +- Utiliser les commandes `jj` au lieu des commandes `git` +- Bookmark principal : `main` +- Ne jamais suggérer de commandes git + +## Environnement +Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet. + +## Configuration de l'application +- Fichier de configuration principal : `.pmomusic/config.yaml` +- Configuration UPNP personnalisable pour différencier les instances en développement + +## Développement +Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances : + +```yaml +host: + upnp: + manufacturer: "PMOMusic-Dev1" + udn_prefix: "pmomusic-dev1" + model_name_prefix: "PMOMusic-Dev1" + friendly_name_prefix: "PMOMusic-Dev1" +``` + +## Architecture +- Projet Rust multi-crates avec workspaces +- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig +- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs) diff --git a/Cargo.lock b/Cargo.lock index 3736cee7..16b7238d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4021,6 +4021,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml", "socket2 0.5.10", "thiserror 2.0.17", "tokio", diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index 6423ad4e..0272b677 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -1,5 +1,10 @@ host: http_port: "8080" + upnp: + manufacturer: "PMOMusic" + udn_prefix: "pmomusic" + model_name_prefix: "PMOMusic" + friendly_name_prefix: "PMOMusic" cover_cache: directory: "cache_covers" size: 2000 diff --git a/pmomediarenderer/src/device.rs b/pmomediarenderer/src/device.rs index 2d30d4c2..43a94241 100644 --- a/pmomediarenderer/src/device.rs +++ b/pmomediarenderer/src/device.rs @@ -41,16 +41,13 @@ use pmoupnp::devices::Device; /// } /// ``` pub static MEDIA_RENDERER: Lazy> = Lazy::new(|| { - let mut device = Device::new( + let mut device = Device::new_from_config( "PMO_MediaRenderer".to_string(), "MediaRenderer".to_string(), - "PMOMusic Audio Renderer".to_string(), + "Audio Renderer".to_string(), ); - device.set_manufacturer("PMOMusic".to_string()); - device.set_model_name("PMOMusic Audio Renderer".to_string()); device.set_model_description("UPnP AV MediaRenderer for audio streaming".to_string()); - device.set_udn_prefix("pmomusic".to_string()); // Ajouter les trois services obligatoires device diff --git a/pmomediaserver/src/device.rs b/pmomediaserver/src/device.rs index 9ea04a24..aba871c4 100644 --- a/pmomediaserver/src/device.rs +++ b/pmomediaserver/src/device.rs @@ -37,16 +37,13 @@ use pmoupnp::devices::Device; /// } /// ``` pub static MEDIA_SERVER: Lazy> = Lazy::new(|| { - let mut device = Device::new( + let mut device = Device::new_from_config( "PMO_MediaServer".to_string(), "MediaServer".to_string(), - "PMOMusic Media Server".to_string(), + "Media Server".to_string(), ); - device.set_manufacturer("PMOMusic".to_string()); - device.set_model_name("PMOMusic Media Server".to_string()); device.set_model_description("UPnP AV MediaServer for audio streaming".to_string()); - device.set_udn_prefix("pmomusic".to_string()); // Ajouter les deux services obligatoires device diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 9f41ffd1..9b6f3be3 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -36,6 +36,7 @@ reqwest = "0.12.23" utoipa = { version = "5.3", features = ["axum_extras"] } socket2 = "0.5" get_if_addrs = "0.5" +serde_yaml = "0.9" [features] default = ["server"] diff --git a/pmoupnp/src/devices/device.rs b/pmoupnp/src/devices/device.rs index d947c9ea..1a2190e6 100644 --- a/pmoupnp/src/devices/device.rs +++ b/pmoupnp/src/devices/device.rs @@ -122,6 +122,84 @@ impl Device { } } + /// Crée un nouveau modèle de device en utilisant la configuration. + /// + /// Cette factory method charge les préfixes depuis pmoconfig et construit + /// automatiquement les noms finaux en combinant les préfixes avec les suffixes. + /// + /// # Arguments + /// + /// * `name` - Nom unique du device + /// * `device_type` - Type UPnP du device (ex: "MediaServer", "MediaRenderer") + /// * `friendly_name_suffix` - Suffixe pour le nom convivial (sera combiné avec le préfixe) + /// + /// # Examples + /// + /// ```ignore + /// use pmoupnp::devices::Device; + /// + /// let device = Device::new_from_config( + /// "PMO_MediaServer".to_string(), + /// "MediaServer".to_string(), + /// "Media Server".to_string(), + /// ); + /// // Avec config par défaut : + /// // - manufacturer = "PMOMusic" + /// // - udn_prefix = "pmomusic" + /// // - model_name = "PMOMusic Media Server" + /// // - friendly_name = "PMOMusic Media Server" + /// ``` + pub fn new_from_config( + name: String, + device_type: String, + friendly_name_suffix: String, + ) -> Self { + use crate::config_ext::UpnpConfigExt; + + let config = pmoconfig::get_config(); + + // Charger les valeurs depuis la config (avec fallback aux defaults) + let manufacturer = config + .get_upnp_manufacturer() + .unwrap_or_else(|_| "PMOMusic".to_string()); + let udn_prefix = config + .get_upnp_udn_prefix() + .unwrap_or_else(|_| "pmomusic".to_string()); + let model_name_prefix = config + .get_upnp_model_name_prefix() + .unwrap_or_else(|_| "PMOMusic".to_string()); + let friendly_name_prefix = config + .get_upnp_friendly_name_prefix() + .unwrap_or_else(|_| "PMOMusic".to_string()); + + // Construire les noms finaux + let model_name = format!("{} {}", model_name_prefix, device_type); + let friendly_name = format!("{} {}", friendly_name_prefix, friendly_name_suffix); + + Self { + object: UpnpObjectType { + name: name.clone(), + object_type: "Device".to_string(), + }, + device_type, + version: 1, + friendly_name, + manufacturer, + manufacturer_url: None, + model_description: None, + model_name, + model_number: None, + model_url: None, + serial_number: None, + udn_prefix, + upc: None, + icon_url: None, + presentation_url: None, + services: RwLock::new(HashMap::new()), + devices: RwLock::new(HashMap::new()), + } + } + /// Retourne le type de device UPnP. /// /// Format: `urn:schemas-upnp-org:device:{type}:{version}` diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index e76ccf40..64876e9a 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -261,7 +261,12 @@ impl UpnpServerExt for Server { if self.ssdp_enabled() { let ssdp_opt = SSDP_SERVER.read().unwrap(); if let Some(ref ssdp) = *ssdp_opt { - let ssdp_device = di.to_ssdp_device("PMOMusic", "1.0"); + use crate::config_ext::UpnpConfigExt; + let config = pmoconfig::get_config(); + let manufacturer = config + .get_upnp_manufacturer() + .unwrap_or_else(|_| "PMOMusic".to_string()); + let ssdp_device = di.to_ssdp_device(&manufacturer, "1.0"); ssdp.add_device(ssdp_device); info!("✅ SSDP announcement for {}", di.udn()); } From 2b693501627211e54c91dd60417123e78fffc899 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 28 Dec 2025 12:53:40 +0100 Subject: [PATCH 3/3] Debug qobuz pas encore parfait mais mieux --- pmomediaserver/src/sources.rs | 10 ++++++++-- pmomediaserver/src/sources_api.rs | 10 +++++++++- pmoqobuz/src/lib.rs | 4 ++-- pmoqobuz/src/source.rs | 21 +++++++++++++++++++-- pmosource/src/cache.rs | 23 +++++++++++++++++++++++ 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 6c6b2ff0..bd7714fe 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -127,13 +127,16 @@ impl SourcesExt for Server { tracing::info!("Initializing Qobuz source..."); + // Obtenir l'URL de base du serveur + let base_url = self.base_url(); + // Créer le client depuis la config let client = QobuzClient::from_config() .await .map_err(|e| SourceInitError::QobuzError(format!("Failed to create client: {}", e)))?; // Créer la source depuis le registry - let source = QobuzSource::from_registry(client) + let source = QobuzSource::from_registry(client, base_url) .map_err(|e| SourceInitError::QobuzError(format!("Failed to create source: {}", e)))?; // Enregistrer la source @@ -154,13 +157,16 @@ impl SourcesExt for Server { tracing::info!("Initializing Qobuz source with explicit credentials..."); + // Obtenir l'URL de base du serveur + let base_url = self.base_url(); + // Créer le client avec credentials let client = QobuzClient::new(username, password) .await .map_err(|e| SourceInitError::QobuzError(format!("Failed to authenticate: {}", e)))?; // Créer la source depuis le registry - let source = QobuzSource::from_registry(client) + let source = QobuzSource::from_registry(client, base_url) .map_err(|e| SourceInitError::QobuzError(format!("Failed to create source: {}", e)))?; // Enregistrer la source diff --git a/pmomediaserver/src/sources_api.rs b/pmomediaserver/src/sources_api.rs index 7034056c..675d392f 100644 --- a/pmomediaserver/src/sources_api.rs +++ b/pmomediaserver/src/sources_api.rs @@ -26,6 +26,9 @@ pub struct QobuzCredentials { pub username: Option, /// Mot de passe Qobuz (optionnel, lu depuis la config si absent) pub password: Option, + /// URL de base du serveur (optionnelle, "http://localhost:8080" par défaut) + #[serde(default)] + pub base_url: Option, } /// Paramètres pour Radio Paradise @@ -75,6 +78,11 @@ async fn register_qobuz(Json(creds): Json) -> impl IntoRespons use pmoqobuz::{QobuzClient, QobuzSource}; use pmosource::api::register_source; + // Utiliser l'URL de base depuis les params ou une valeur par défaut + let base_url = creds + .base_url + .unwrap_or_else(|| "http://localhost:8080".to_string()); + // Créer le client selon les credentials fournis let client_result = if let (Some(username), Some(password)) = (creds.username, creds.password) { QobuzClient::new(&username, &password).await @@ -96,7 +104,7 @@ async fn register_qobuz(Json(creds): Json) -> impl IntoRespons }; // Créer et enregistrer la source depuis le registry - let source = match QobuzSource::from_registry(client) { + let source = match QobuzSource::from_registry(client, base_url) { Ok(s) => Arc::new(s), Err(e) => { return ( diff --git a/pmoqobuz/src/lib.rs b/pmoqobuz/src/lib.rs index 7856d9a2..ce3b7f5f 100644 --- a/pmoqobuz/src/lib.rs +++ b/pmoqobuz/src/lib.rs @@ -124,7 +124,7 @@ //! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); //! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); //! -//! let source = QobuzSource::new(client, cover_cache, audio_cache); +//! let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080"); //! # Ok(()) //! # } //! ``` @@ -145,7 +145,7 @@ //! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); //! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); //! -//! let source = QobuzSource::new(client, cover_cache, audio_cache); +//! let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080"); //! //! // Add a track with caching //! let tracks = source.client().get_favorite_tracks().await?; diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index b7f2d3a0..84b5bbc3 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -56,7 +56,7 @@ const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); /// let client = QobuzClient::from_config().await?; /// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?); /// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?); -/// let source = QobuzSource::new(client, cover_cache, audio_cache); +/// let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080"); /// /// println!("Source: {}", source.name()); /// println!("Supports FIFO: {}", source.supports_fifo()); @@ -80,6 +80,9 @@ struct QobuzSourceInner { /// Cache manager (centralisé) cache_manager: SourceCacheManager, + /// Base URL for streaming server (e.g., "http://192.168.0.138:8080") + base_url: String, + /// Update tracking update_counter: tokio::sync::RwLock, last_change: tokio::sync::RwLock, @@ -100,12 +103,13 @@ impl QobuzSource { /// # Arguments /// /// * `client` - Authenticated Qobuz API client + /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080") /// /// # Errors /// /// Returns an error if the caches are not initialized in the registry #[cfg(feature = "server")] - pub fn from_registry(client: QobuzClient) -> Result { + pub fn from_registry(client: QobuzClient, base_url: impl Into) -> Result { let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?; let client = Arc::new(client); cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone()))); @@ -114,6 +118,7 @@ impl QobuzSource { inner: Arc::new(QobuzSourceInner { client, cache_manager, + base_url: base_url.into(), update_counter: tokio::sync::RwLock::new(0), last_change: tokio::sync::RwLock::new(SystemTime::now()), }), @@ -127,10 +132,12 @@ impl QobuzSource { /// * `client` - Authenticated Qobuz API client /// * `cover_cache` - Cover image cache (required) /// * `audio_cache` - Audio cache (required) + /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080") pub fn new( client: QobuzClient, cover_cache: Arc, audio_cache: Arc, + base_url: impl Into, ) -> Self { let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache); let client = Arc::new(client); @@ -140,6 +147,7 @@ impl QobuzSource { inner: Arc::new(QobuzSourceInner { client, cache_manager, + base_url: base_url.into(), update_counter: tokio::sync::RwLock::new(0), last_change: tokio::sync::RwLock::new(SystemTime::now()), }), @@ -578,6 +586,15 @@ impl QobuzSource { } else { warn!("No qobuz_track_id metadata for {}", pk); } + + // Convertir URL relative en URL absolue + // From: /audio/flac/QOBUZ:123 + // To: http://192.168.0.138:8080/audio/flac/QOBUZ:123 + if let Some(resource) = item.resources.first_mut() { + if resource.url.starts_with('/') { + resource.url = format!("{}{}", self.inner.base_url, resource.url); + } + } } item.parent_id = parent_id.clone(); diff --git a/pmosource/src/cache.rs b/pmosource/src/cache.rs index 426fff56..57c50742 100755 --- a/pmosource/src/cache.rs +++ b/pmosource/src/cache.rs @@ -217,6 +217,29 @@ impl SourceCacheManager { } } + /// Obtenir l'URL absolue d'un fichier audio en cache à partir de son pk + /// + /// # Arguments + /// + /// * `pk` - Clé primaire du fichier audio dans le cache + /// + /// # Returns + /// + /// L'URL absolue complète du fichier audio (ex: http://localhost:8080/audio/flac/QOBUZ:123) + pub fn audio_url(&self, _pk: &str) -> Result { + #[cfg(feature = "server")] + { + pmoupnp::cache_registry::build_audio_url(_pk, None) + .map_err(|e| MusicSourceError::CacheError(e.to_string())) + } + #[cfg(not(feature = "server"))] + { + Err(MusicSourceError::CacheError( + "Server feature not enabled - cannot build audio URL".to_string(), + )) + } + } + /// Cacher une piste audio depuis une URL /// /// Utilise la collection de cette source pour organiser les pistes.