✨ 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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3996,6 +3996,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"mdns",
|
||||
"percent-encoding",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
// 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())
|
||||
}
|
||||
|
||||
/// 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";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper to fetch playback items from a media server object (container or item).
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = ¶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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user