Add pmocovers integration for external cover URL proxying

- Introduce optional `pmocover` dependency in pmocontrol
- Add cover URL transformation logic for both REST and SSE endpoints using `pmocovers::proxy_cover_url`/sync
- Refactor `/covers/proxy?...=` handler to accept any external URL and return local cached route
- Implement helper functions `transform_cover_url` (async) & sync variant for consistent cover URL normalization
- Update Cargo.lock to include `pmocovers`
This commit is contained in:
2026-04-04 12:04:40 +02:00
parent bdaee820ad
commit ea5936717a
6 changed files with 224 additions and 61 deletions

1
Cargo.lock generated
View File

@@ -3996,6 +3996,7 @@ dependencies = [
"futures-util",
"mdns",
"percent-encoding",
"pmocovers",
"pmodidl",
"pmoserver",
"pmoupnp",

View File

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

View File

@@ -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<ControlPointState>,
Path(renderer_id): Path<String>,
headers: HeaderMap,
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
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<ControlPointState>,
Path(renderer_id): Path<String>,
headers: HeaderMap,
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
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<BrowseParams>,
headers: HeaderMap,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
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<ContainerEntry> = 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<String> {
/// 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<String> {
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::<std::net::IpAddr>() {
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).

View File

@@ -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<String> {
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<String> {
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<chrono::Utc>,
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<Arc<ControlPoint>>,
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<Arc<ControlPoint>>,
_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<Arc<ControlPoint>>) -> impl IntoResponse {
pub async fn all_events_sse(
State(control_point): State<Arc<ControlPoint>>,
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<Arc<ControlPoint>>) -> 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);

View File

@@ -109,9 +109,9 @@ pub struct CoverProxyResponse {
/// GET /covers/proxy?url=<encoded_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<CoverProxyParams>,
@@ -120,31 +120,19 @@ pub async fn cover_proxy_handler(
) -> impl IntoResponse {
let external_url = &params.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

View File

@@ -135,6 +135,77 @@ pub fn get_cover_cache() -> Option<Arc<Cache>> {
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<String> {
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<String> {
// 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<String> {
// 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
// ============================================================================