diff --git a/Cargo.lock b/Cargo.lock index 639ca9c1..66277c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3996,6 +3996,7 @@ dependencies = [ "futures-util", "mdns", "percent-encoding", + "pmocovers", "pmodidl", "pmoserver", "pmoupnp", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 86a176c2..fd7ec5c2 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -30,6 +30,7 @@ rand = { workspace = true } # pmoserver extension support (optional) pmoserver = { path = "../pmoserver", optional = true } +pmocovers = { path = "../pmocovers", optional = true } utoipa = { version = "5.4.0", optional = true } axum = { version = "0.8.4", optional = true } tokio = { workspace = true, features = ["sync", "rt"], optional = true } @@ -47,4 +48,4 @@ percent-encoding = "2.3" [features] default = [] # Active l'API REST pmoserver -pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:url", "dep:urlencoding"] +pmoserver = ["dep:pmoserver", "dep:pmocovers", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:url", "dep:urlencoding"] diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index e8007e59..f0d68e0e 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -21,6 +21,8 @@ use crate::openapi::{ use crate::queue::PlaybackItem; #[cfg(feature = "pmoserver")] use crate::{DeviceId, DeviceIdentity, DeviceOnline}; +#[cfg(feature = "pmoserver")] +use pmocovers; #[cfg(feature = "pmoserver")] use async_trait::async_trait; @@ -170,13 +172,14 @@ async fn get_renderer_state( async fn get_renderer_full_snapshot( State(state): State, Path(renderer_id): Path, + headers: HeaderMap, ) -> Result, (StatusCode, Json)> { let rid = DeviceId(renderer_id.clone()); // Use spawn_blocking because renderer_full_snapshot does sync UPnP calls let control_point = state.control_point.clone(); let rid_clone = rid.clone(); - let snapshot = + let mut snapshot = tokio::task::spawn_blocking(move || control_point.renderer_full_snapshot(&rid_clone)) .await .map_err(|e| { @@ -189,6 +192,19 @@ async fn get_renderer_full_snapshot( })? .map_err(|err| map_snapshot_error(renderer_id, err))?; + // Get base_url from request headers for transforming cover URLs + let base_url_str = pmoserver::get_base_url_from_request(&headers); + let base_url = pmoserver::BaseUrl(base_url_str); + + // Transform cover URLs in current_track + if let Some(ref mut current_track) = snapshot.state.current_track { + if let Some(ref album_art) = current_track.album_art_uri { + if let Some(transformed) = transform_cover_url(Some(album_art), &base_url).await { + current_track.album_art_uri = Some(transformed); + } + } + } + Ok(Json(snapshot)) } @@ -213,13 +229,29 @@ async fn get_renderer_full_snapshot( async fn get_renderer_queue( State(state): State, Path(renderer_id): Path, + headers: HeaderMap, ) -> Result, (StatusCode, Json)> { let rid = DeviceId(renderer_id.clone()); - let snapshot = state + let mut snapshot = state .control_point .renderer_full_snapshot(&rid) .map_err(|err| map_snapshot_error(renderer_id, err))?; + // Get base_url from request headers + let base_url_str = pmoserver::get_base_url_from_request(&headers); + let base_url = pmoserver::BaseUrl(base_url_str); + + // Transform cover URLs in all queue items + for item in &mut snapshot.queue.items { + if let Some(ref mut metadata) = item.metadata { + if let Some(ref album_art) = metadata.album_art_uri { + if let Some(transformed) = transform_cover_url(Some(album_art), &base_url).await { + metadata.album_art_uri = Some(transformed); + } + } + } + } + Ok(Json(snapshot.queue)) } @@ -2083,7 +2115,8 @@ async fn browse_container( Query(params): Query, headers: HeaderMap, ) -> Result, (StatusCode, Json)> { - let base_url = pmoserver::get_base_url_from_request(&headers); + let base_url_str = pmoserver::get_base_url_from_request(&headers); + let base_url = pmoserver::BaseUrl(base_url_str); let sid = DeviceId(server_id.clone()); let server = state.control_point.media_server(&sid).ok_or_else(|| { @@ -2162,9 +2195,11 @@ async fn browse_container( ) })?; - let container_entries: Vec = page.entries - .into_iter() - .map(|e| ContainerEntry { + // Transform cover URLs + let mut container_entries = Vec::with_capacity(page.entries.len()); + for e in page.entries { + let album_art_uri = transform_cover_url(e.album_art_uri.as_deref(), &base_url).await; + container_entries.push(ContainerEntry { id: e.id, title: e.title, class: e.class, @@ -2172,9 +2207,9 @@ async fn browse_container( child_count: None, artist: e.artist, album: e.album, - album_art_uri: transform_cover_url(e.album_art_uri.as_deref(), &base_url), - }) - .collect(); + album_art_uri, + }); + } Ok(Json(BrowseResponse { container_id, @@ -2206,39 +2241,32 @@ fn map_snapshot_error( ) } -/// Transforme une URL de cover externe LAN en URL de proxy local -fn transform_cover_url(url: Option<&str>, base_url: &str) -> Option { +/// Transforme une URL de cover pour qu'elle soit accessible depuis le client +/// +/// Si l'URL est une route locale de notre cache (/covers/...), on la transforme en URL absolue. +/// Sinon, on utilise pmocovers::proxy_cover_url() pour mettre en cache et retourner notre URL. +/// C'est le même mécanisme que PMO Cache utilise déjà pour Qobuz. +async fn transform_cover_url(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option { let url = url?; - // Si c'est déjà une URL locale de notre instance, ne pas transformer - if url.starts_with(base_url) { + // Si c'est déjà une route locale de notre cache, la transformer en URL absolue + if url.starts_with("/covers/") { + return Some(base_url.url_for(url)); + } + + // Si c'est une URL de notre instance, la retourner directement + if url.starts_with(&base_url.0) { return Some(url.to_string()); } - // Vérifier si c'est une URL LAN externe à proxyfier - if should_proxy_cover_url(url) { - let encoded = urlencoding::encode(url); - return Some(format!("/covers/proxy?url={}", encoded)); - } - - //URL publique ou autre - laisser telle quelle - Some(url.to_string()) -} - -/// Vérifie si l'URL doit être proxyfiée (URL LAN externe) -fn should_proxy_cover_url(url: &str) -> bool { - if let Ok(parsed) = url::Url::parse(url) { - if let Some(host) = parsed.host_str() { - if let Ok(ip) = host.parse::() { - return match ip { - std::net::IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_loopback(), - std::net::IpAddr::V6(ipv6) => ipv6.is_loopback(), - }; - } - return host.ends_with(".local") || host == "localhost"; + // Pour les autres URLs, utiliser le mechanisme de proxy standard (comme Qobuz) + match pmocovers::proxy_cover_url(url, base_url).await { + Ok(local_url) => Some(local_url), + Err(e) => { + tracing::warn!("Failed to proxy cover URL {}: {}", url, e); + Some(url.to_string()) } } - false } /// Helper to fetch playback items from a media server object (container or item). diff --git a/pmocontrol/src/sse.rs b/pmocontrol/src/sse.rs index d5448592..546f70af 100644 --- a/pmocontrol/src/sse.rs +++ b/pmocontrol/src/sse.rs @@ -24,6 +24,7 @@ use async_stream::stream; use axum::{ Router, extract::State, + http::header::HeaderMap, response::IntoResponse, response::sse::{Event, KeepAlive, Sse}, }; @@ -32,9 +33,71 @@ use serde::Serialize; #[cfg(feature = "pmoserver")] use std::sync::Arc; +#[cfg(feature = "pmoserver")] +use pmocovers; + use crate::{DeviceIdentity, DeviceOnline}; use tracing::error; +// ============================================================================ +// HELPERS - Transformation des URLs de covers LAN externes +// ============================================================================ + +/// Transforme une URL de cover pour qu'elle soit accessible depuis le client +/// +/// Si l'URL est une route locale de notre cache (/covers/...), on la transforme en URL absolue. +/// Sinon, on utilise pmocovers::proxy_cover_url() pour mettre en cache et retourner notre URL. +#[cfg(feature = "pmoserver")] +async fn transform_cover_url(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option { + let url = url?; + + // Si c'est déjà une route locale de notre cache, la transformer en URL absolue + if url.starts_with("/covers/") { + return Some(base_url.url_for(url)); + } + + // Si c'est une URL de notre instance, la retourner directement + if url.starts_with(&base_url.0) { + return Some(url.to_string()); + } + + // Pour les autres URLs, utiliser le mechanisme de proxy standard + match pmocovers::proxy_cover_url(url, base_url).await { + Ok(local_url) => Some(local_url), + Err(e) => { + tracing::warn!("Failed to proxy cover URL {}: {}", url, e); + Some(url.to_string()) + } + } +} + +/// Transforme une URL de cover pour qu'elle soit accessible depuis le client (version synchrone) +/// +/// Utilise la version sync de proxy_cover_url directement. +#[cfg(feature = "pmoserver")] +fn transform_cover_url_sync(url: Option<&str>, base_url: &pmoserver::BaseUrl) -> Option { + let url = url?; + + // Si c'est déjà une route locale de notre cache, la transformer en URL absolue + if url.starts_with("/covers/") { + return Some(base_url.url_for(url)); + } + + // Si c'est une URL de notre instance, la retourner directement + if url.starts_with(&base_url.0) { + return Some(url.to_string()); + } + + // Pour les autres URLs, utiliser proxy_cover_url_sync + match pmocovers::proxy_cover_url_sync(url, base_url) { + Ok(local_url) => Some(local_url), + Err(e) => { + tracing::warn!("Failed to proxy cover URL {}: {}", url, e); + Some(url.to_string()) + } + } +} + // ============================================================================ // PAYLOADS SSE // ============================================================================ @@ -181,6 +244,7 @@ pub enum UnifiedEventPayload { fn renderer_event_to_payload( event: RendererEvent, timestamp: chrono::DateTime, + base_url: &pmoserver::BaseUrl, ) -> RendererEventPayload { match event { RendererEvent::StateChanged { id, state } => RendererEventPayload::StateChanged { @@ -210,7 +274,7 @@ fn renderer_event_to_payload( title: metadata.title, artist: metadata.artist, album: metadata.album, - album_art_uri: metadata.album_art_uri, + album_art_uri: transform_cover_url_sync(metadata.album_art_uri.as_deref(), base_url), timestamp, }, RendererEvent::QueueUpdated { id, queue_length } => RendererEventPayload::QueueUpdated { @@ -346,7 +410,10 @@ fn media_server_event_to_payload( )] pub async fn renderer_events_sse( State(control_point): State>, + headers: HeaderMap, ) -> impl IntoResponse { + let base_url_str = pmoserver::get_base_url_from_request(&headers); + let base_url = pmoserver::BaseUrl(base_url_str); // Convert crossbeam channel to tokio channel for async compatibility let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel(); let rx = control_point.subscribe_events(); @@ -406,7 +473,7 @@ pub async fn renderer_events_sse( // Regular events from the control point Some(event) = rx_tokio.recv() => { let timestamp = chrono::Utc::now(); - let payload = renderer_event_to_payload(event, timestamp); + let payload = renderer_event_to_payload(event, timestamp, &base_url); if let Ok(json) = serde_json::to_string(&payload) { yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json)); @@ -468,7 +535,9 @@ pub async fn renderer_events_sse( )] pub async fn media_server_events_sse( State(control_point): State>, + _headers: HeaderMap, ) -> impl IntoResponse { + // Note: les événements media server n'ont pas de album_art_uri à transformer // Convert crossbeam channel to tokio channel for async compatibility let (tx, mut rx_tokio) = tokio::sync::mpsc::unbounded_channel(); let rx = control_point.subscribe_media_server_events(); @@ -586,7 +655,12 @@ pub async fn media_server_events_sse( ), tag = "control" )] -pub async fn all_events_sse(State(control_point): State>) -> impl IntoResponse { +pub async fn all_events_sse( + State(control_point): State>, + headers: HeaderMap, +) -> impl IntoResponse { + let base_url_str = pmoserver::get_base_url_from_request(&headers); + let base_url = pmoserver::BaseUrl(base_url_str); // Convert crossbeam channels to tokio channels for async compatibility let (renderer_tx, mut renderer_rx_tokio) = tokio::sync::mpsc::unbounded_channel(); let (server_tx, mut server_rx_tokio) = tokio::sync::mpsc::unbounded_channel(); @@ -677,7 +751,7 @@ pub async fn all_events_sse(State(control_point): State>) -> i tokio::select! { Some(event) = renderer_rx_tokio.recv() => { let timestamp = chrono::Utc::now(); - let renderer_payload = renderer_event_to_payload(event, timestamp); + let renderer_payload = renderer_event_to_payload(event, timestamp, &base_url); let payload = UnifiedEventPayload::Renderer(renderer_payload); diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs index 4c376fb8..76f955d1 100644 --- a/pmocovers/src/api.rs +++ b/pmocovers/src/api.rs @@ -109,9 +109,9 @@ pub struct CoverProxyResponse { /// GET /covers/proxy?url= /// Proxy transparent qui : -/// 1. Détecte si l'URL est une URL LAN externe (pas déjà locale) -/// 2. Ajoute à cache via add_from_url (déduplication automatique) -/// 3. Retourne l'URL locale du cache +/// 1. Ajoute l'URL au cache (add_from_url gère déduplication) +/// 2. Retourne l'URL locale du cache +/// Note: Si l'URL est déjà une cover locale de notre instance, on retourne directement l'URL #[cfg(feature = "pmoserver")] pub async fn cover_proxy_handler( Query(params): Query, @@ -120,31 +120,19 @@ pub async fn cover_proxy_handler( ) -> impl IntoResponse { let external_url = ¶ms.url; - // Ignorer si déjà une URL de NOTRE instance pmomusic (ne pas se cacher soi-même) + // Si c'est déjà une URL locale de NOTRE instance pmomusic, la retourner directement if is_local_cover_url(external_url, &base_url) { return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "INVALID_REQUEST".to_string(), - message: "URL is already a local cover from this instance".to_string(), + StatusCode::OK, + Json(CoverProxyResponse { + cached_url: external_url.clone(), + pk: String::new(), }), ) .into_response(); } - // Vérifier si c'est une URL LAN à proxyfier - if !should_proxy_url(external_url) { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "INVALID_REQUEST".to_string(), - message: "URL is not a LAN URL requiring proxy".to_string(), - }), - ) - .into_response(); - } - - // Ajouter au cache (add_from_url gère la déduplication) + // Ajouter au cache (add_from_url gère la déduplication et le download) match cache.add_from_url(external_url, Some("external-covers")).await { Ok(pk) => { // Retourner l'URL locale diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index 054762a0..fe5143c8 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -135,6 +135,77 @@ pub fn get_cover_cache() -> Option> { COVER_CACHE.get().cloned() } +// ============================================================================ +// Helper pour proxyfier les URLs de covers externes +// ============================================================================ + +/// Transforme une URL de cover externe en URL locale du cache. +/// +/// Si l'URL est déjà une route locale de notre cache, la retourne directement. +/// Sinon, ajoute l'URL au cache (download si nécessaire) et retourne l'URL locale. +/// +/// Usage : +/// ```rust +/// let local_url = pmocovers::proxy_cover_url("https://example.com/cover.jpg").await?; +/// ``` +#[cfg(feature = "pmoserver")] +pub async fn proxy_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result { + proxy_cover_url_sync_impl(url, base_url).await +} + +/// Version synchrone de proxy_cover_url. +/// Utilise un runtime tokio temporaire pour exécuter add_from_url. +/// +/// Usage : +/// ```rust +/// let local_url = pmocovers::proxy_cover_url_sync("https://example.com/cover.jpg", base_url); +/// ``` +#[cfg(feature = "pmoserver")] +pub fn proxy_cover_url_sync(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result { + // Si c'est déjà une route locale de notre cache, retourner directement + if url.starts_with("/covers/") { + return Ok(url.to_string()); + } + + // Si c'est déjà une URL de notre instance, la retourner directement + if url.starts_with(&base_url.0) { + return Ok(url.to_string()); + } + + // Ajouter au cache en utilisant un runtime temporaire + let cache = get_cover_cache() + .ok_or_else(|| anyhow::anyhow!("Cover cache not initialized"))?; + + let runtime = tokio::runtime::Runtime::new()?; + let pk = runtime.block_on(async move { + cache.add_from_url(url, Some("external-covers")).await + })?; + + let route = pmocache::covers_route_for(&pk, None); + Ok(base_url.url_for(&route)) +} + +#[cfg(feature = "pmoserver")] +async fn proxy_cover_url_sync_impl(url: &str, base_url: &pmoserver::BaseUrl) -> anyhow::Result { + // Si c'est déjà une route locale de notre cache, retourner directement + if url.starts_with("/covers/") { + return Ok(url.to_string()); + } + + // Si c'est déjà une URL de notre instance, la retourner directement + if url.starts_with(&base_url.0) { + return Ok(url.to_string()); + } + + // Ajouter au cache (add_from_url gère déduplication et download) + let cache = get_cover_cache() + .ok_or_else(|| anyhow::anyhow!("Cover cache not initialized"))?; + + let pk = cache.add_from_url(url, Some("external-covers")).await?; + let route = pmocache::covers_route_for(&pk, None); + Ok(base_url.url_for(&route)) +} + // ============================================================================ // Extension pmoserver // ============================================================================