Debug qobuz, pas encore parfait mais presque. #33

Merged
eric merged 3 commits from push-ryzlqtmkoyxw into main 2025-12-28 13:53:54 +01:00
22 changed files with 654 additions and 58 deletions

3
.claude-env Normal file
View File

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

31
.claude/CLAUDE.md Normal file
View File

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

1
Cargo.lock generated
View File

@@ -4021,6 +4021,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"serde_yaml",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",

View File

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

View File

@@ -41,16 +41,13 @@ use pmoupnp::devices::Device;
/// }
/// ```
pub static MEDIA_RENDERER: Lazy<Arc<Device>> = 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

View File

@@ -37,16 +37,13 @@ use pmoupnp::devices::Device;
/// }
/// ```
pub static MEDIA_SERVER: Lazy<Arc<Device>> = 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

View File

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

View File

@@ -26,6 +26,9 @@ pub struct QobuzCredentials {
pub username: Option<String>,
/// Mot de passe Qobuz (optionnel, lu depuis la config si absent)
pub password: Option<String>,
/// URL de base du serveur (optionnelle, "http://localhost:8080" par défaut)
#[serde(default)]
pub base_url: Option<String>,
}
/// Paramètres pour Radio Paradise
@@ -75,6 +78,11 @@ async fn register_qobuz(Json(creds): Json<QobuzCredentials>) -> 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<QobuzCredentials>) -> 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 (

View File

@@ -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<Option<Duration>> {
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.

View File

@@ -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<Option<i64>> {
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();

View File

@@ -14,6 +14,8 @@ pub struct QobuzCache {
albums: Arc<MokaCache<String, Album>>,
/// Cache des tracks (TTL: 1 heure)
tracks: Arc<MokaCache<String, Track>>,
/// Cache des tracks d'un album complet (TTL: 1 heure)
album_tracks: Arc<MokaCache<String, Vec<Track>>>,
/// Cache des artistes (TTL: 1 heure)
artists: Arc<MokaCache<String, Artist>>,
/// 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<Vec<Track>> {
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<Track>) {
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

View File

@@ -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<Vec<Track>> {
// 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)
}

View File

@@ -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<Container> {
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<Item> {
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<Container> {
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");
}

View File

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

View File

@@ -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");
@@ -53,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());
@@ -77,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<u32>,
last_change: tokio::sync::RwLock<SystemTime>,
@@ -97,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<Self> {
pub fn from_registry(client: QobuzClient, base_url: impl Into<String>) -> Result<Self> {
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())));
@@ -111,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()),
}),
@@ -124,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<CoverCache>,
audio_cache: Arc<AudioCache>,
base_url: impl Into<String>,
) -> Self {
let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache);
let client = Arc::new(client);
@@ -137,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()),
}),
@@ -322,6 +333,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 +416,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 +507,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 +529,190 @@ 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<bool> {
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<Item>,
album_id: &str,
) -> Result<Vec<Item>> {
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);
}
// 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();
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<Vec<Item>> {
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 +822,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<Item> = 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 +1230,14 @@ impl MusicSource for QobuzSource {
) -> Result<BrowseResult> {
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<Item> = tracks
let items: Vec<Item> = 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))

View File

@@ -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<String> {
#[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.

View File

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

128
pmoupnp/src/config_ext.rs Normal file
View File

@@ -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<String>;
/// 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<String>;
/// 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<String>;
/// 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<String>;
/// 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<String> {
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<String> {
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<String> {
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<String> {
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),
)
}
}

View File

@@ -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}`

View File

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

View File

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

46
rust-analyzer.toml Normal file
View File

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