- Convert `renderer_event_to_payload` and `media_server_sse::event_handler`s to async - Replace sync cover URL transformation with `.await` on `transform_cover_url` - Remove blocking thread-based workarounds for async execution - Improve SSE scalability and responsiveness by eliminating nested runtimes
16 KiB
Évaluation du plan : centraliser_base_url_axum_middleware
Résumé de l'audit
Le plan est bien pensé et cohérent. Il identifie correctement le problème et la solution. Cependant, j'ai identifié plusieurs points nécessitant des amendements.
Points validés (conformes au code actuel)
-
Problème bien identifié : URLs hardcodées avec IP locale (
PMO_SERVER_URL) retournées au frontend via reverse proxy. -
get_request_base_urlexiste déjà àpmoserver/src/lib.rs:199— pas besoin de la recréer. -
covers_route_forexiste déjà danspmocache/src/lib.rs:149. -
covers_absolute_url_forutilisée dans les contextes UPnP :pmoupnp/src/cache_registry.rs:57pmoradiofrance/src/metadata_cache.rs:263pmoparadise/src/source.rs:216pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs:91
-
Route audio correcte :
/audio/tracks/{pk}(pas/audio/flac/{pk}). -
Architecture du Server : Les routes sont construites dynamiquement via
Arc<RwLock<Router>>. Le layer devra être ajouté dans la construction du router, pas après.
Points à amender
1. Ajout du layer dans le Server
Le plan suggère d'ajouter le layer "dans server.rs" mais la structure du router est complexe :
- Les routes sont dynamiques (
RwLock<Router>) - Le router final est un fallback qui délègue
Correction : Ajouter le layer directement lors de la création du registry_route initial (ligne 120-122) :
let registry_route = Router::new()
.route("/api/registry", get(get_api_registry))
.with_state(api_registry.clone())
.layer(base_url_layer()); // ← ici
2. Comportement requis pour LAN vs WAN
Le middleware doit supporter les deux cas d'usage :
- LAN (sans reverse proxy) : Pas de headers
X-Forwarded-*→ utiliser l'adresse IP locale du serveur (PMO_SERVER_URL) - WAN (via reverse proxy) : Headers
X-Forwarded-*présents → utiliser l'URL publique du reverse proxy
Important : get_request_base_url dans pmoserver/src/lib.rs:199 lit déjà ces headers. Le fallback doit être PMO_SERVER_URL qui est configuré au démarrage avec l'IP locale.
3. Chemin du middleware dans la pile
Le plan dit d'appliquer le layer "avant" les autres. En réalité, Tower/Acorn applique les couches dans l'ordre où elles sont ajoutées — le premier layer ajouté est le plus extérieur (exécuté en premier). Le base_url_layer doit donc être ajouté en premier (le plus intérieur) pour voir les headers nettoyés.
3. Les handlers n'ont PAS besoin de BaseUrl
Après analyse, aucun handler dans le codebase actuel n'appelle covers_absolute_url_for() directement pour le frontend. Les album_art_uri sont :
- Soit propagés depuis les réponses UPnP des media servers (pas des URLs pmomusic)
- Soit construits en tâche de fond dans les caches (RadioFrance, RadioParadise)
Correction : Le plan surestime le nombre de handlers à modifier. La vraie question est : d'où viennent les URLs incorrectes ?
4. Source du problème à clarifier
Les URLs incorrectes ne viennent pas des handlers REST classiques. Elles viennent probablement de :
a) Tâches de fond (background tasks) qui stockent des URLs complètes :
pmoradiofrance/src/metadata_cache.rs:263— construitcovers_absolute_url_for()dans le cachepmoparadise/src/source.rs:216— même problème
b) API Qobuz (pmoqobuz/src/api_rest.rs:302) — utilise covers_route_for (route relative, OK)
c) Playlist (pmoplaylist/src/handle/read.rs:204,271,355) — utilise covers_route_for (OK)
5. Correction du fallback
Le plan suggère localhost:8080 ou 0.0.0.0:8080 comme fallback. Le port doit provenir de la configuration du serveur (get_server_base_url() existe déjà dans pmoserver/src/lib.rs).
Correction : Le fallback utilise get_server_base_url() (disponible via GLOBAL_SERVER) :
- En LAN : pas de
X-Forwarded-*→get_server_base_url()→ URLs en IP locale - En WAN :
X-Forwarded-*présents → URLs en URL publique du reverse proxy
6. Fonction audio_route_for pas nécessaire maintenant
Le plan propose d'ajouter audio_route_for dans pmoaudiocache. Mais :
- Les fichiers audio sont servis par
pmoaudiocachelui-même (routes internes) -Aucune URL audio n'est retournée au frontend via JSON
Supprimer cette étape du plan.
Plan amendé
Étape 0 — Audit spécifique (à faire avant implémentation)
# Trouver les constructions d'URLs dans les tâches de fond (caches, sources)
grep -rn "covers_absolute_url_for\|PMO_SERVER_URL" --include="*.rs" | grep -v "pmocontrol\|pmoplaylist\|pmoqobuz"
# Vérifier les URLs dans les réponses JSON des handlers
grep -rn "album_art_uri" --include="*.rs" | grep -E "fn |->"
Identifier spécifiquement quels endpoints REST retournent des URLs au frontend.
Étape 1 — pmoserver/src/lib.rs : Ajouter BaseUrl + middleware
use axum::{extract::Request, middleware::Next, response::Response};
#[derive(Debug, Clone)]
pub struct BaseUrl(pub String);
impl BaseUrl {
pub fn url_for(&self, route: &str) -> String {
debug_assert!(route.starts_with('/'), "route must start with '/'");
format!("{}{}", self.0.trim_end_matches('/'), route)
}
}
pub async fn base_url_middleware(mut request: Request, next: Next) -> Response {
// Priorité : 1) X-Forwarded-* (reverse proxy), 2) get_server_base_url() (adresse configurée)
let base = get_request_base_url(request.headers())
.or_else(|| get_server_base_url())
.unwrap_or_else(|| {
panic!(
"BaseUrl: impossible de déterminer l'URL de base.\n\
Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP."
);
});
tracing::debug!("BaseUrl calculée : {}", base);
request.extensions_mut().insert(BaseUrl(base));
next.run(request).await
}
pub fn base_url_layer() -> axum::middleware::FromFnLayer {
axum::middleware::from_fn(base_url_middleware)
}
Comportement :
- Accès LAN (pas de proxy) :
get_server_base_url()→ URLs en IP locale configurée - Accès WAN (reverse proxy) :
X-Forwarded-*→ URLs en URL publique
Note : Si ni les headers ni le serveur ne sont disponibles, le middleware panic (fail-fast) car c'est une erreur de configuration.
Étape 2 — pmoserver/src/server.rs : Appliquer le layer
Dans Server::new(), ligne ~120-122 :
let registry_route = Router::new()
.route("/api/registry", get(get_api_registry))
.with_state(api_registry.clone())
.layer(base_url_layer()); // ← Ajouter ici (couche la plus intérieure)
Étape 3 — pmocache/src/lib.rs : Renommer sans déprecation
// Rename direct - pas de déprecation (soft en cours de dev, pas une library)
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
// PMO_SERVER_URL contient l'IP locale (LAN) - utilisé uniquement pour UPnP
// Fallback sur get_server_base_url() si dispo, sinon erreur
let base = std::env::var("PMO_SERVER_URL")
.or_else(|_| pmoserver::get_server_base_url().ok_or("PMO_SERVER_URL not set"))
.unwrap_or_else(|e| {
tracing::error!("covers_absolute_url_for_upnp: {}", e);
panic!("BaseUrl non disponible pour UPnP");
});
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
}
Étape 4 — Mettre à jour les appels UPnP
grep -rn "covers_absolute_url_for" --include="*.rs"
Modifier pmoupnp/src/cache_registry.rs:57 → covers_absolute_url_for_upnp
Étape 5 — Tâches de fond : stocker la route, pas l'URL
pmoradiofrance/src/metadata_cache.rs:263 :
// Avant :
let public_url = pmocache::covers_absolute_url_for(&pk, None);
// Après : stocker la route relative
let album_art_route = pmocache::covers_route_for(&pk, None);
Le handler REST qui retourne ces métadonnées devra extraire Extension<BaseUrl> et appliquer base_url.url_for().
pmoparadise/src/source.rs:216 : Même traitement.
Étape 6 — Vérification et tests
# Plus d'appels à covers_absolute_url_for dans les contextes HTTP
grep -rn "covers_absolute_url_for" --include="*.rs" | grep -v "pmocache\|pmoupnp"
# Tests du middleware
cargo test base_url
Questions en suspens
-
Fallback avec panic : Si ni les headers ni le serveur ne sont disponibles, le middleware panic au démarrage avec un message clair (ex: "BaseUrl: configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP"). → Décision utilisateur : OK, panic avec message clair.
-
Reverse proxy avec Authelia : NPM ajoutera les headers
X-Forwarded-*. Authelia gère l'authentification separately. Pas de vérification de header supplémentaire nécessaire pour le middleware BaseUrl. → Décision : Pas de vérification supplémentaire.
Problème complémentaire : URLs de covers des media servers externes
Contexte
Quand le control point accède à un media server externe sur le LAN (autre que pmomusic), les URLs d'articles (album_art_uri) retournées par ce media server externe contiennent des IPs locales du LAN externe (ex: http://192.168.1.100:8080/covers/...).
Ces URLs ne passent pas par notre système de caching et ne peuvent pas être rewritées par le middleware BaseUrl car elles sont :
- Recues depuis le réseau UPnP (pas via HTTP)
- Propagées directement dans les réponses REST/SSE sans transformation
Solution proposée : Proxy de covers avec cache
Créer un nouveau endpoint HTTP qui agit comme un proxy transparent :
- Détection : Si l'URL demandée est une URL LAN externe (pas une URL locale de pmomusic)
- Caching : Utiliser
cache.add_from_url()qui gère déjà la déduplication (pas de double-cache) - Rewriting : Retourner l'URL locale du cache (
/covers/image/{pk})
Note importante : pmocache::add_from_url() gère déjà :
- La vérification si l'URL est déjà en cache (ligne 673-683)
- Le calcul du pk basé sur le contenu (pas sur l'URL)
- La déduplication automatique pour les mêmes contenus
Implémentation
Nouvel endpoint dans pmocovers/src/lib.rs ou nouveau fichier pmocovers/src/proxy.rs :
#[derive(Debug, Deserialize)]
struct CoverProxyParams {
url: String,
}
#[derive(Debug, Serialize)]
struct CoverProxyResponse {
cached_url: String,
pk: String,
}
/// 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
pub async fn cover_proxy_handler(
Query(params): Query<CoverProxyParams>,
State(cache): State<CoverCache>,
Extension(base_url): Extension<pmoserver::BaseUrl>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let external_url = ¶ms.url;
// Ignorer si déjà une URL locale (ne pas se cacher soi-même)
if is_local_cover_url(external_url, &base_url) {
return Err((StatusCode::BAD_REQUEST, "URL is already a local cover"));
}
// Vérifier si c'est une URL LAN à proxyfier
if !should_proxy_url(external_url) {
return Err((StatusCode::BAD_REQUEST, "URL is not a LAN URL requiring proxy"));
}
// Ajouter au cache (add_from_url gère la déduplication)
let pk = cache.add_from_url(external_url, Some("external-covers"))
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
// Retourner l'URL locale
let local_url = base_url.url_for(&pmocache::covers_route_for(&pk, None));
Ok(Json(CoverProxyResponse { cached_url: local_url, pk }))
}
/// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic
/// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées
/// et mises en cache localement - c'est le comportement desired!
fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool {
// Only skip if it's OUR instance's base URL
// Covers from other pmomusic instances on LAN should be proxied and cached
url.starts_with(&base_url.0)
}
/// Vérifie si l'URL doit être proxyfiée (URL LAN externe)
fn should_proxy_url(url: &str) -> bool {
if let Ok(parsed) = url::Url::parse(url) {
if let Some(host) = parsed.host_str() {
// Proxy uniquement les URLs LAN (pas les URLs publiques)
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return ip.is_private() || ip.is_loopback();
}
// aussi les .local
return host.ends_with(".local") || host == "localhost";
}
}
false
}
Points importants :
- Utiliser
add_from_url()pour bénéficier de la déduplication automatique - Vérifier
is_local_cover_url()avec uniquement la comparaison de base_url pour éviter que notre instance ne se cache elle-même - Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées (comportement souhaité!)
- Le TTL sera celui par défaut du cache (configurable)
Mise à jour des handlers REST :
Dans pmocontrol/src/pmoserver_ext.rs et pmocontrol/src/sse.rs, transformer les album_art_uri LAN :
fn transform_external_cover_url(url: &str) -> String {
if is_lan_url(url) {
// Remplacer par l'URL du proxy
let encoded = urlencoding::encode(url);
return format!("/covers/proxy?url={}", encoded);
}
url.to_string()
}
Appels dans les handlers :
pmocontrol/src/pmoserver_ext.rs:2173:browse_container→ transformeralbum_art_uripmocontrol/src/pmoserver_ext.rs:2424: autre endpoint → même transformationpmocontrol/src/sse.rs:213:MetadataChangedevents → même transformation
TTL
- Le TTL sera celui par défaut du cache
pmocovers - C'est configurable via
pmoconfigsi besoin
Sécurité
- Limiter aux URLs LAN uniquement (
192.168.x.x,10.x.x.x,172.16-31.x.x,localhost) - Vérifier que l'URL n'est pas déjà une cover locale de pmomusic (éviter le cacheception)
- Ajouter un rate limiting pour éviter le flood de téléchargement
- Timeout de téléchargement : 10 secondes max
Résumé des fichiers à modifier
- Nouveau :
pmocovers/src/proxy.rs- Endpoint de proxy - Modifier :
pmocontrol/src/pmoserver_ext.rs- Transformer les album_art_uri - Modifier :
pmocontrol/src/sse.rs- Transformer les album_art_uri dans les événements
Plan: Passer le SSE en mode Async
Contexte
Le SSE de PMO Control est déjà async (fonctions pub async fn), mais le traitement des événements utilise des fonctions synchrones (fn renderer_event_to_payload → transform_cover_url_sync). Cela nécessite des workarounds (threads avec runtime tokio séparés).
Problèmes actuels
- Nested runtime:
std::thread::spawnavectokio::runtime::Runtime::new()dans chaque appel - Performance dégradée: Création d'un thread par URL de cover
- Code complexe: Workarounds pour exécuter de l'async dans du sync
Solution
Rendre le traitement des événements entièrement async :
- Modifier
renderer_event_to_payload→async fn renderer_event_to_payload - Modifier
transform_cover_url_sync→transform_cover_url(async) avec.awaitdirect - Supprimer le workaround
proxy_cover_url_syncdanspmocovers(quand les .await fonctionnent)
Avantages attendus
- Fluidité accrue: Pas de thread par cover,真正的 async/await
- Meilleure réactivité: Pas de blocking sur les événements SSE
- Code plus propre: Plus de workarounds, plus de runtime imbriqué
- Meilleure scalabilité: Plus de création de thread
Fichiers à modifier
-
pmocontrol/src/sse.rs:renderer_event_to_payload→async fnmedia_server_event_to_payload→async fnall_events_sse: utiliser les versions async
-
pmocontrol/src/pmoserver_ext.rs:- Utiliser
transform_cover_url(async) avec.awaitau lieu detransform_cover_url_sync
- Utiliser
-
pmocontrol/src/control_point.rs(si nécessaire):- Adapter les appels aux fonctions async