feat(cache): centralize absolute URL generation with PMO_SERVER_URL

Replace hardcoded relative URLs and manual base_url concatenation with a unified absolute URL API via pmocache::covers_absolute_url_for() and CacheTrait::absolute_url_for().

- Add pmocache as a required dependency to pmoparadise
- Introduce absolute_url_for() and covers_absolute_url_for() helpers using PMO_SERVER_URL env var (default: http://localhost:8080)
- Update all callers to use absolute URLs for covers and audio in streaming, playlists, Qobuz, Radio France, UPnP, and server startup
- Remove redundant route_for() usage in URL construction
- Add pmocache to Cargo.lock
This commit is contained in:
2026-03-24 12:02:53 +01:00
parent b690420550
commit 1164a11410
12 changed files with 51 additions and 41 deletions

1
Cargo.lock generated
View File

@@ -4147,6 +4147,7 @@ dependencies = [
"pmoaudio", "pmoaudio",
"pmoaudio-ext", "pmoaudio-ext",
"pmoaudiocache", "pmoaudiocache",
"pmocache",
"pmoconfig", "pmoconfig",
"pmocovers", "pmocovers",
"pmoflac", "pmoflac",

View File

@@ -88,10 +88,8 @@ impl IcyClientStream {
// Add cover URL if we have a cover_pk // Add cover URL if we have a cover_pk
if let Some(pk) = &meta.cover_pk { if let Some(pk) = &meta.cover_pk {
// Use relative URL /covers/image/{pk}/256 let cover_url = pmocache::covers_absolute_url_for(pk, None);
// This works when streaming from the same server that serves covers metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
// VLC and other players will resolve relative URLs correctly
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
} else if let Some(url) = &meta.cover_url { } else if let Some(url) = &meta.cover_url {
// Fallback to external cover URL if no local pk // Fallback to external cover URL if no local pk
metadata_str.push_str(&format!("StreamUrl='{}';", url)); metadata_str.push_str(&format!("StreamUrl='{}';", url));

View File

@@ -69,14 +69,7 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
/// Retourne la route relative pour accéder à un item du cache /// Retourne la route relative pour accéder à un item du cache
/// ///
/// # Arguments /// Format: `/{cache_name}/{cache_type}/{pk}[/{param}]`
///
/// * `pk` - Clé primaire de la piste
/// * `param` - Paramètre optionnel (ex: "orig", "128k", etc.)
///
/// # Returns
///
/// Route relative (ex: "/audio/flac/abc123" ou "/audio/tracks/abc123/orig")
fn route_for(&self, pk: &str, param: Option<&str>) -> String { fn route_for(&self, pk: &str, param: Option<&str>) -> String {
if let Some(p) = param { if let Some(p) = param {
format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p) format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p)
@@ -85,6 +78,16 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
} }
} }
/// Retourne l'URL absolue pour accéder à un item du cache
///
/// Utilise la variable d'environnement `PMO_SERVER_URL` comme base,
/// avec `http://localhost:8080` comme valeur par défaut.
fn absolute_url_for(&self, pk: &str, param: Option<&str>) -> String {
let base = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
format!("{}{}", base.trim_end_matches('/'), self.route_for(pk, param))
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache /// Télécharge un fichier depuis une URL et l'ajoute au cache
/// ///
/// # Arguments /// # Arguments

View File

@@ -144,6 +144,22 @@ pub use cache::{
CacheSubscription, CacheSubscription,
}; };
pub use cache_trait::{pk_from_content_header, FileCache}; pub use cache_trait::{pk_from_content_header, FileCache};
/// Retourne la route relative pour une cover: `/covers/image/{pk}[/{param}]`
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
if let Some(p) = param {
format!("/covers/image/{}/{}", pk, p)
} else {
format!("/covers/image/{}", pk)
}
}
/// Retourne l'URL absolue pour une cover via `PMO_SERVER_URL`
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
let base = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
}
pub use db::{CacheEntry, DB}; pub use db::{CacheEntry, DB};
pub use download::{ pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header, download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,

View File

@@ -63,6 +63,7 @@ pmoplaylist = { path = "../pmoplaylist" }
pmoconfig = { path = "../pmoconfig", optional = true } pmoconfig = { path = "../pmoconfig", optional = true }
# Cache support (OBLIGATOIRE - architecture refactorisée) # Cache support (OBLIGATOIRE - architecture refactorisée)
pmocache = { path = "../pmocache" }
pmocovers = { path = "../pmocovers" } pmocovers = { path = "../pmocovers" }
pmoaudiocache = { path = "../pmoaudiocache" } pmoaudiocache = { path = "../pmoaudiocache" }

View File

@@ -213,7 +213,7 @@ impl RadioParadiseSource {
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
let cover_url = cover_pk let cover_url = cover_pk
.as_ref() .as_ref()
.map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) .map(|pk| pmocache::covers_absolute_url_for(pk, None))
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) .or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
.or_else(|| Some(self.default_cover_url())); .or_else(|| Some(self.default_cover_url()));

View File

@@ -517,7 +517,7 @@ fn playlist_track_to_response(
} }
fn cover_url_from_pk(pk: &str) -> String { fn cover_url_from_pk(pk: &str) -> String {
format!("/covers/image/{}/256", pk) pmocache::covers_absolute_url_for(pk, None)
} }
fn normalize_cover_pk(input: Option<String>) -> Option<String> { fn normalize_cover_pk(input: Option<String>) -> Option<String> {

View File

@@ -182,7 +182,7 @@ impl ReadHandle {
let _remaining = self.remaining().await?; let _remaining = self.remaining().await?;
// Convertir cover_pk en URL si présent // Convertir cover_pk en URL si présent
let album_art = cover_pk.map(|pk| format!("/cover/{}", pk)); let album_art = cover_pk.map(|pk| pmocache::covers_absolute_url_for(&pk, None));
Ok(Container { Ok(Container {
id: self.playlist.id.clone(), id: self.playlist.id.clone(),
@@ -253,7 +253,7 @@ impl ReadHandle {
let track_number = meta.get_track_number().await.ok().flatten(); let track_number = meta.get_track_number().await.ok().flatten();
let cover_pk = meta.get_cover_pk().await.ok().flatten(); let cover_pk = meta.get_cover_pk().await.ok().flatten();
let cover_url = if let Some(pk) = cover_pk.as_ref() { let cover_url = if let Some(pk) = cover_pk.as_ref() {
Some(format!("/covers/jpeg/{}/256", pk)) Some(pmocache::covers_absolute_url_for(pk, None))
} else { } else {
meta.get_cover_url().await.ok().flatten() meta.get_cover_url().await.ok().flatten()
}; };

View File

@@ -299,7 +299,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>
if let Some(ref image_url) = album.image { if let Some(ref image_url) = album.image {
match cover_cache.add_from_url(image_url, None).await { match cover_cache.add_from_url(image_url, None).await {
Ok(pk) => { Ok(pk) => {
album.image_cached = Some(format!("/covers/images/{}", pk)); album.image_cached = Some(pmocache::covers_absolute_url_for(&pk, None));
} }
Err(e) => { Err(e) => {
tracing::warn!("Failed to cache album image: {}", e); tracing::warn!("Failed to cache album image: {}", e);

View File

@@ -251,15 +251,13 @@ impl CachedMetadata {
// Note: add_from_url() lance le téléchargement complet en arrière-plan. // Note: add_from_url() lance le téléchargement complet en arrière-plan.
// L'URL est valide immédiatement — si le fichier n'est pas encore prêt, // L'URL est valide immédiatement — si le fichier n'est pas encore prêt,
// le client web doit réessayer (retry avec backoff). // le client web doit réessayer (retry avec backoff).
let route = cache.route_for(&pk, None); let public_url = pmocache::covers_absolute_url_for(&pk, None);
let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route);
#[cfg(feature = "logging")] #[cfg(feature = "logging")]
tracing::debug!( tracing::debug!(
"Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}", "Cached cover - UUID: {}, PK: {}, public_url: {}",
uuid, uuid,
pk, pk,
route,
public_url public_url
); );

View File

@@ -114,6 +114,13 @@ impl Server {
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self { pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
let api_registry = Arc::new(RwLock::new(Vec::new())); let api_registry = Arc::new(RwLock::new(Vec::new()));
let base_url = base_url.into();
// Initialiser PMO_SERVER_URL pour que tous les caches puissent construire des URLs absolues
// sans avoir besoin de propager base_url manuellement.
// SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent.
unsafe { std::env::set_var("PMO_SERVER_URL", &base_url) };
// Créer le router initial avec l'endpoint de registre // Créer le router initial avec l'endpoint de registre
let registry_route = Router::new() let registry_route = Router::new()
.route("/api/registry", get(get_api_registry)) .route("/api/registry", get(get_api_registry))
@@ -121,7 +128,7 @@ impl Server {
Self { Self {
name: name.into(), name: name.into(),
base_url: base_url.into(), base_url,
http_port, http_port,
router: Arc::new(RwLock::new(registry_route)), router: Arc::new(RwLock::new(registry_route)),
api_router: Arc::new(RwLock::new(None)), api_router: Arc::new(RwLock::new(None)),

View File

@@ -54,18 +54,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
/// // url = "http://localhost:8080/covers/images/abc123/300" /// // url = "http://localhost:8080/covers/images/abc123/300"
/// ``` /// ```
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> { pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config Ok(pmocache::covers_absolute_url_for(
let base_url = pk,
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); size.map(|s| s.to_string()).as_deref(),
))
let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
let param = match size {
Some(size_) => Some(size_.to_string()),
None => None,
};
let route = cache.route_for(pk, param.as_deref());
Ok(format!("{}{}", base_url, route))
} }
/// Construit l'URL complète pour une piste audio /// Construit l'URL complète pour une piste audio
@@ -84,12 +76,6 @@ pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String>
/// // url = "http://localhost:8080/audio/tracks/abc123/stream" /// // url = "http://localhost:8080/audio/tracks/abc123/stream"
/// ``` /// ```
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> { pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url =
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?; let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
Ok(cache.absolute_url_for(pk, param))
let route = cache.route_for(pk, param);
Ok(format!("{}{}", base_url, route))
} }