diff --git a/Cargo.lock b/Cargo.lock index 4a5052d5..d8b919d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,15 +2350,22 @@ name = "pmomediaserver" version = "0.1.0" dependencies = [ "async-trait", + "axum", "bevy_reflect", "once_cell", + "pmoconfig", "pmodidl", + "pmoqobuz", "pmoserver", "pmosource", "pmoupnp", "quick-xml 0.38.3", + "serde", + "serde_json", + "thiserror 1.0.69", "tokio", "tracing", + "utoipa", ] [[package]] diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index b3326db2..c3be8e51 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmoupnp = { path = "../pmoupnp"} pmomediarenderer = { path = "../pmomediarenderer" } -pmomediaserver = { path = "../pmomediaserver"} +pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "api"] } pmoserver = { path = "../pmoserver" } pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoapp = { path = "../pmoapp", features = ["pmoserver"] } diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 86515898..28affaab 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -1,7 +1,7 @@ use pmoapp::{WebAppExt, Webapp}; use pmocovers::CoverCacheExt; use pmomediarenderer::MEDIA_RENDERER; -use pmomediaserver::{MEDIA_SERVER, MediaServerExt}; +use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt, MediaServerExt, sources_api_router}; use pmoserver::{ServerBuilder, logs::LoggingOptions}; use pmoupnp::{UpnpServer, ssdp::SsdpServer, upnp_api::UpnpApiExt}; use tracing::info; @@ -36,6 +36,10 @@ async fn main() { // Enregistrer l'API d'introspection UPnP server.register_upnp_api().await; + // Enregistrer l'API de gestion des sources musicales + info!("📡 Registering Sources API..."); + server.add_router("/api", sources_api_router()).await; + info!("📡 Registering MediaRenderer..."); let renderer_instance = server .register_device(MEDIA_RENDERER.clone()) @@ -48,27 +52,20 @@ async fn main() { renderer_instance.description_route() ); - // TODO: Enregistrer les sources musicales - // Exemple d'utilisation du MediaServerExt: - // - // use std::sync::Arc; - // - // info!("📡 Registering music sources..."); - // - // // Exemple: Enregistrer une source Qobuz - // // let qobuz = Arc::new(QobuzSource::new(credentials)); - // // server.register_music_source(qobuz).await; - // - // // Exemple: Enregistrer une source Radio Paradise - // // let radio = Arc::new(RadioParadiseSource::new()); - // // server.register_music_source(radio).await; - // - // // Lister toutes les sources enregistrées - // let sources = server.list_music_sources().await; - // info!("✅ {} music source(s) registered", sources.len()); - // for source in sources { - // info!(" - {} ({})", source.name(), source.id()); - // } + // Enregistrer les sources musicales + info!("📡 Registering music sources..."); + + // Enregistrer Qobuz depuis la configuration + if let Err(e) = server.register_qobuz_from_config().await { + tracing::warn!("Failed to register Qobuz: {}", e); + } + + // Lister toutes les sources enregistrées + let sources = server.list_music_sources().await; + info!("✅ {} music source(s) registered", sources.len()); + for source in sources { + info!(" - {} ({})", source.name(), source.id()); + } info!("📡 Registering MediaServer..."); let server_instance = server diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index ffcc50ef..a001d651 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -15,3 +15,19 @@ tokio = { version = "1", features = ["sync"] } async-trait = "0.1" tracing = "0.1" quick-xml = { version = "0.38.3", features = ["serialize"] } +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Optional dependencies +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", optional = true } +pmoqobuz = { path = "../pmoqobuz", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } + +[features] +default = [] +# Feature pour activer l'API REST de gestion des sources +api = ["dep:axum", "dep:utoipa"] +# Feature pour activer le support Qobuz configuré +qobuz = ["dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/cache"] diff --git a/pmomediaserver/src/lib.rs b/pmomediaserver/src/lib.rs index 33858d61..8bd2cfe5 100644 --- a/pmomediaserver/src/lib.rs +++ b/pmomediaserver/src/lib.rs @@ -48,6 +48,20 @@ //! println!("Source: {} ({})", source.name(), source.id()); //! } //! ``` +//! +//! # Enregistrement simplifié avec features +//! +//! Avec les features activées, vous pouvez enregistrer des sources préconfigurées : +//! +//! ```ignore +//! use pmomediaserver::sources::SourcesExt; +//! use pmoserver::ServerBuilder; +//! +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Enregistrer Qobuz depuis la config (feature "qobuz" requise) +//! server.register_qobuz_from_config().await?; +//! ``` pub mod contentdirectory; pub mod connectionmanager; @@ -55,8 +69,20 @@ pub mod device; pub mod source_registry; pub mod server_ext; pub mod content_handler; +pub mod sources; + +#[cfg(feature = "api")] +pub mod sources_api; pub use device::MEDIA_SERVER; pub use source_registry::SourceRegistry; pub use server_ext::{MediaServerExt, get_source_registry}; pub use content_handler::ContentHandler; +pub use sources::{SourcesExt, SourceInitError}; + +#[cfg(feature = "api")] +pub use sources_api::{sources_api_router, SourcesApiDoc}; + +// Re-export sources when features are enabled +#[cfg(feature = "qobuz")] +pub use pmoqobuz; diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs new file mode 100644 index 00000000..652f84ce --- /dev/null +++ b/pmomediaserver/src/sources.rs @@ -0,0 +1,172 @@ +//! # Source Helpers - Helpers pour l'initialisation simplifiée de sources +//! +//! Ce module fournit des helpers pour créer et enregistrer facilement des sources +//! musicales préconfigurées à partir de la configuration système. + +use crate::server_ext::MediaServerExt; +use pmoserver::Server; +use std::sync::Arc; + +/// Erreur lors de l'initialisation d'une source +#[derive(Debug, thiserror::Error)] +pub enum SourceInitError { + #[cfg(feature = "qobuz")] + #[error("Failed to initialize Qobuz: {0}")] + QobuzError(String), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error("Source not available: {0}")] + NotAvailable(String), +} + +/// Result type pour les opérations d'initialisation de sources +pub type Result = std::result::Result; + +/// Extension trait pour faciliter l'enregistrement de sources préconfigurées +/// +/// Ce trait ajoute des méthodes pratiques à `Server` pour enregistrer des sources +/// musicales préconfigurées à partir de la configuration système. +/// +/// # Examples +/// +/// ```ignore +/// use pmomediaserver::sources::SourcesExt; +/// use pmoserver::ServerBuilder; +/// +/// let mut server = ServerBuilder::new_configured().build(); +/// +/// // Enregistrer Qobuz depuis la config +/// server.register_qobuz_from_config().await?; +/// +/// // Lister toutes les sources +/// let sources = server.list_music_sources().await; +/// println!("{} sources registered", sources.len()); +/// ``` +#[async_trait::async_trait] +pub trait SourcesExt { + /// Enregistre la source Qobuz depuis la configuration + /// + /// Cette méthode lit les credentials Qobuz depuis `pmoconfig` et crée + /// automatiquement un `QobuzSource` avec cache activé. + /// + /// # Configuration requise + /// + /// Le fichier de configuration doit contenir : + /// ```yaml + /// accounts: + /// qobuz: + /// username: "votre@email.com" + /// password: "votrepassword" + /// ``` + /// + /// # Erreurs + /// + /// Retourne une erreur si : + /// - La configuration Qobuz n'est pas trouvée + /// - L'authentification échoue + /// - La feature "qobuz" n'est pas activée + /// + /// # Examples + /// + /// ```ignore + /// server.register_qobuz_from_config().await?; + /// ``` + #[cfg(feature = "qobuz")] + async fn register_qobuz_from_config(&mut self) -> Result<()>; + + /// Enregistre la source Qobuz avec des credentials explicites + /// + /// # Arguments + /// + /// * `username` - Nom d'utilisateur Qobuz + /// * `password` - Mot de passe Qobuz + /// + /// # Examples + /// + /// ```ignore + /// server.register_qobuz("user@example.com", "password").await?; + /// ``` + #[cfg(feature = "qobuz")] + async fn register_qobuz(&mut self, username: &str, password: &str) -> Result<()>; +} + +#[async_trait::async_trait] +impl SourcesExt for Server { + #[cfg(feature = "qobuz")] + async fn register_qobuz_from_config(&mut self) -> Result<()> { + use pmoqobuz::{QobuzClient, QobuzSource}; + + tracing::info!("Initializing Qobuz source from configuration..."); + + // Créer le client depuis la config + let client = QobuzClient::from_config() + .await + .map_err(|e| SourceInitError::QobuzError(format!("Failed to create client: {}", e)))?; + + // Récupérer l'URL de base du serveur depuis la config + let config = pmoconfig::get_config(); + let port = config.get_http_port(); + let base_url = format!("http://localhost:{}", port); + + // Créer la source + let source = QobuzSource::new(client, &base_url); + + // Enregistrer la source + self.register_music_source(Arc::new(source)).await; + + tracing::info!("✅ Qobuz source registered successfully"); + + Ok(()) + } + + #[cfg(feature = "qobuz")] + async fn register_qobuz(&mut self, username: &str, password: &str) -> Result<()> { + use pmoqobuz::{QobuzClient, QobuzSource}; + + tracing::info!("Initializing Qobuz source with explicit credentials..."); + + // Créer le client avec credentials + let client = QobuzClient::new(username, password) + .await + .map_err(|e| SourceInitError::QobuzError(format!("Failed to authenticate: {}", e)))?; + + // Récupérer l'URL de base du serveur depuis la config + let config = pmoconfig::get_config(); + let port = config.get_http_port(); + let base_url = format!("http://localhost:{}", port); + + // Créer la source + let source = QobuzSource::new(client, &base_url); + + // Enregistrer la source + self.register_music_source(Arc::new(source)).await; + + tracing::info!("✅ Qobuz source registered successfully"); + + Ok(()) + } +} + +// Placeholder pour d'autres sources +// TODO: Ajouter Radio Paradise lorsque la crate sera disponible +// #[cfg(feature = "radioparadise")] +// async fn register_radioparadise_from_config(&mut self) -> Result<()> { ... } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_init_error() { + #[cfg(feature = "qobuz")] + { + let err = SourceInitError::QobuzError("test error".to_string()); + assert!(err.to_string().contains("Qobuz")); + } + + let err = SourceInitError::ConfigError("test".to_string()); + assert!(err.to_string().contains("Configuration")); + } +} diff --git a/pmomediaserver/src/sources_api.rs b/pmomediaserver/src/sources_api.rs new file mode 100644 index 00000000..80c552a2 --- /dev/null +++ b/pmomediaserver/src/sources_api.rs @@ -0,0 +1,343 @@ +//! # Sources API - API REST pour la gestion des sources musicales +//! +//! Ce module fournit une API REST pour : +//! - Lister les sources enregistrées +//! - Obtenir des informations sur une source spécifique +//! - Enregistrer/désenregistrer des sources +//! +//! ## Routes +//! +//! - `GET /sources` - Liste toutes les sources +//! - `GET /sources/:id` - Informations sur une source +//! - `POST /sources/qobuz` - Enregistrer Qobuz (feature "qobuz") +//! - `DELETE /sources/:id` - Désenregistrer une source + +use crate::server_ext::get_source_registry; +use axum::{ + extract::Path, + http::StatusCode, + response::IntoResponse, + Json, Router, + routing::{delete, get, post}, +}; +use pmosource::MusicSource; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Information sur une source musicale +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SourceInfo { + /// ID unique de la source + pub id: String, + /// Nom de la source + pub name: String, + /// La source supporte-t-elle les opérations FIFO + pub supports_fifo: bool, + /// Capacités de la source + pub capabilities: SourceCapabilitiesInfo, +} + +/// Capacités d'une source +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SourceCapabilitiesInfo { + pub supports_search: bool, + pub supports_favorites: bool, + pub supports_playlists: bool, + pub supports_high_res_audio: bool, +} + +/// Liste des sources enregistrées +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SourcesList { + /// Nombre total de sources + pub count: usize, + /// Liste des sources + pub sources: Vec, +} + +/// Credentials pour Qobuz +#[cfg(feature = "qobuz")] +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct QobuzCredentials { + /// Nom d'utilisateur Qobuz (optionnel, lu depuis la config si absent) + pub username: Option, + /// Mot de passe Qobuz (optionnel, lu depuis la config si absent) + pub password: Option, +} + +/// Réponse d'enregistrement de source +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SourceRegisteredResponse { + /// Message de succès + pub message: String, + /// ID de la source enregistrée + pub source_id: String, +} + +/// Message d'erreur +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ErrorResponse { + /// Message d'erreur + pub error: String, +} + +/// Liste toutes les sources musicales enregistrées +/// +/// Retourne la liste complète des sources avec leurs informations. +#[utoipa::path( + get, + path = "/sources", + responses( + (status = 200, description = "Liste des sources", body = SourcesList), + ), + tag = "sources" +)] +async fn list_sources() -> impl IntoResponse { + let registry = get_source_registry().await; + let sources = registry.list_all().await; + + let source_infos: Vec = sources + .iter() + .map(|s| { + let caps = s.capabilities(); + SourceInfo { + id: s.id().to_string(), + name: s.name().to_string(), + supports_fifo: s.supports_fifo(), + capabilities: SourceCapabilitiesInfo { + supports_search: caps.supports_search, + supports_favorites: caps.supports_favorites, + supports_playlists: caps.supports_playlists, + supports_high_res_audio: caps.supports_high_res_audio, + }, + } + }) + .collect(); + + let list = SourcesList { + count: source_infos.len(), + sources: source_infos, + }; + + Json(list) +} + +/// Obtient les informations d'une source spécifique +/// +/// Retourne les détails d'une source musicale par son ID. +#[utoipa::path( + get, + path = "/sources/{id}", + params( + ("id" = String, Path, description = "ID de la source") + ), + responses( + (status = 200, description = "Informations de la source", body = SourceInfo), + (status = 404, description = "Source non trouvée", body = ErrorResponse), + ), + tag = "sources" +)] +async fn get_source(Path(id): Path) -> impl IntoResponse { + let registry = get_source_registry().await; + + match registry.get(&id).await { + Some(source) => { + let caps = source.capabilities(); + let info = SourceInfo { + id: source.id().to_string(), + name: source.name().to_string(), + supports_fifo: source.supports_fifo(), + capabilities: SourceCapabilitiesInfo { + supports_search: caps.supports_search, + supports_favorites: caps.supports_favorites, + supports_playlists: caps.supports_playlists, + supports_high_res_audio: caps.supports_high_res_audio, + }, + }; + (StatusCode::OK, Json(info)).into_response() + } + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + +/// Enregistre une source Qobuz +/// +/// Enregistre une nouvelle source Qobuz avec les credentials fournis ou depuis la config. +#[cfg(feature = "qobuz")] +#[utoipa::path( + post, + path = "/sources/qobuz", + request_body = QobuzCredentials, + responses( + (status = 201, description = "Source enregistrée", body = SourceRegisteredResponse), + (status = 400, description = "Erreur d'enregistrement", body = ErrorResponse), + ), + tag = "sources" +)] +async fn register_qobuz(Json(creds): Json) -> impl IntoResponse { + use pmoqobuz::{QobuzClient, QobuzSource}; + + let registry = get_source_registry().await; + + // 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 + } else { + QobuzClient::from_config().await + }; + + let client = match client_result { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Failed to create Qobuz client: {}", e), + }), + ) + .into_response(); + } + }; + + // Récupérer l'URL de base du serveur depuis la config + let config = pmoconfig::get_config(); + let port = config.get_http_port(); + let base_url = format!("http://localhost:{}", port); + + // Créer et enregistrer la source + let source = Arc::new(QobuzSource::new(client, &base_url)); + let source_id = source.as_ref().id().to_string(); + + registry.register(source).await; + + ( + StatusCode::CREATED, + Json(SourceRegisteredResponse { + message: "Qobuz source registered successfully".to_string(), + source_id, + }), + ) + .into_response() +} + +/// Désenregistre une source musicale +/// +/// Supprime une source du registre par son ID. +#[utoipa::path( + delete, + path = "/sources/{id}", + params( + ("id" = String, Path, description = "ID de la source à supprimer") + ), + responses( + (status = 200, description = "Source supprimée"), + (status = 404, description = "Source non trouvée", body = ErrorResponse), + ), + tag = "sources" +)] +async fn unregister_source(Path(id): Path) -> impl IntoResponse { + let registry = get_source_registry().await; + + if registry.remove(&id).await { + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("Source '{}' unregistered successfully", id) + })), + ) + .into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response() + } +} + +/// Crée le router pour l'API des sources +/// +/// # Returns +/// +/// Un `Router` Axum avec toutes les routes de l'API configurées. +/// +/// # Examples +/// +/// ```ignore +/// use pmomediaserver::sources_api::sources_api_router; +/// use axum::Router; +/// +/// let app = Router::new() +/// .nest("/api", sources_api_router()); +/// ``` +pub fn sources_api_router() -> Router { + let mut router = Router::new() + .route("/sources", get(list_sources)) + .route("/sources/:id", get(get_source)) + .route("/sources/:id", delete(unregister_source)); + + #[cfg(feature = "qobuz")] + { + router = router.route("/sources/qobuz", post(register_qobuz)); + } + + router +} + +/// Structure pour la documentation OpenAPI +#[cfg(feature = "qobuz")] +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + list_sources, + get_source, + unregister_source, + register_qobuz, + ), + components( + schemas( + SourceInfo, + SourceCapabilitiesInfo, + SourcesList, + SourceRegisteredResponse, + ErrorResponse, + QobuzCredentials, + ) + ), + tags( + (name = "sources", description = "Gestion des sources musicales") + ) +)] +pub struct SourcesApiDoc; + +/// Structure pour la documentation OpenAPI (sans Qobuz) +#[cfg(not(feature = "qobuz"))] +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + list_sources, + get_source, + unregister_source, + ), + components( + schemas( + SourceInfo, + SourceCapabilitiesInfo, + SourcesList, + SourceRegisteredResponse, + ErrorResponse, + ) + ), + tags( + (name = "sources", description = "Gestion des sources musicales") + ) +)] +pub struct SourcesApiDoc;