refactor: replace absolute URLs with route-based cover URLs and generalize caching

- Remplacer les appels à `covers_absolute_url_for` par `covers_route_for` pour générer des URLs relatives basées sur les routes
- Introduire le trait `CoverCacheable` pour généraliser le cache des covers (Album, Playlist, Artist, Track)
- Remplacer les fonctions spécifiques (`cache_album_covers`, etc.) par une fonction générique `cache_covers`
- Simplifier le code en unifiant la logique de mise en cache des covers
- Corriger l'initialisation de `PMO_SERVER_URL` pour utiliser `base_url()` (incluant le port) au lieu d'une URL brute
This commit is contained in:
2026-03-24 15:02:14 +01:00
parent 1164a11410
commit 76b7c126c2
5 changed files with 118 additions and 48 deletions

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 {
pmocache::covers_absolute_url_for(pk, None) pmocache::covers_route_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| pmocache::covers_absolute_url_for(&pk, None)); let album_art = cover_pk.map(|pk| pmocache::covers_route_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(pmocache::covers_absolute_url_for(pk, None)) Some(pmocache::covers_route_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(pmocache::covers_absolute_url_for(&pk, None)); album.image_cached = Some(pmocache::covers_route_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

@@ -19,6 +19,39 @@ use std::time::{Duration, SystemTime};
/// TTL pour les playlists d'albums (7 jours) /// TTL pour les playlists d'albums (7 jours)
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
/// Trait pour les types dont on peut cacher la cover image.
trait CoverCacheable {
fn image_url(&self) -> Option<&str>;
fn set_image_cached(&mut self, url: String);
}
impl CoverCacheable for crate::models::Album {
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}
impl CoverCacheable for crate::models::Playlist {
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}
impl CoverCacheable for crate::models::Artist {
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}
/// Pour Track, la cover est celle de l'album.
impl CoverCacheable for crate::models::Track {
fn image_url(&self) -> Option<&str> {
self.album.as_ref()?.image.as_deref()
}
fn set_image_cached(&mut self, url: String) {
if let Some(ref mut album) = self.album {
album.image_cached = Some(url);
}
}
}
/// Default image for Qobuz (300x300 WebP, embedded in binary) /// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -853,20 +886,50 @@ impl QobuzSource {
} }
} }
/// Cache les covers d'une liste d'albums en parallèle. /// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`).
/// Retourne les albums avec `image_cached` mis à jour si la cover a pu être mise en cache. async fn cache_covers<T>(&self, items: Vec<T>) -> Vec<T>
async fn cache_album_covers(&self, albums: Vec<crate::models::Album>) -> Vec<crate::models::Album> { where
let futs: Vec<_> = albums.into_iter().map(|mut album| { T: CoverCacheable + Send + 'static,
{
let futs: Vec<_> = items.into_iter().map(|mut item| {
let source = self.clone(); let source = self.clone();
async move { async move {
if let Some(ref image_url) = album.image.clone() { if let Some(image_url) = item.image_url().map(str::to_string) {
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await {
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
album.image_cached = Some(url); item.set_image_cached(url);
} }
} }
} }
album item
}
}).collect();
tokio::task::JoinSet::from_iter(futs).join_all().await
}
async fn cache_album_covers(&self, albums: Vec<crate::models::Album>) -> Vec<crate::models::Album> {
self.cache_covers(albums).await
}
async fn cache_playlist_covers(&self, playlists: Vec<crate::models::Playlist>) -> Vec<crate::models::Playlist> {
self.cache_covers(playlists).await
}
/// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album).
async fn cache_track_covers(&self, tracks: Vec<crate::models::Track>) -> Vec<crate::models::Track> {
let futs: Vec<_> = tracks.into_iter().map(|mut track| {
let source = self.clone();
async move {
if let Some(ref mut album) = track.album {
if let Some(ref image_url) = album.image.clone() {
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
album.image_cached = Some(url);
}
}
}
}
track
} }
}).collect(); }).collect();
tokio::task::JoinSet::from_iter(futs) tokio::task::JoinSet::from_iter(futs)
@@ -874,19 +937,19 @@ impl QobuzSource {
.await .await
} }
/// Cache les covers d'une liste de playlists en parallèle. /// Cache les covers d'une liste d'artistes en parallèle.
async fn cache_playlist_covers(&self, playlists: Vec<crate::models::Playlist>) -> Vec<crate::models::Playlist> { async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
let futs: Vec<_> = playlists.into_iter().map(|mut playlist| { let futs: Vec<_> = artists.into_iter().map(|mut artist| {
let source = self.clone(); let source = self.clone();
async move { async move {
if let Some(ref image_url) = playlist.image.clone() { if let Some(ref image_url) = artist.image.clone() {
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
playlist.image_cached = Some(url); artist.image_cached = Some(url);
} }
} }
} }
playlist artist
} }
}).collect(); }).collect();
tokio::task::JoinSet::from_iter(futs) tokio::task::JoinSet::from_iter(futs)
@@ -932,6 +995,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let tracks = self.cache_covers(tracks).await;
let items: Vec<Item> = tracks let items: Vec<Item> = tracks
.into_iter() .into_iter()
.filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok()) .filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok())
@@ -949,23 +1013,21 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let artists = self.cache_covers(artists).await;
let containers: Vec<Container> = artists let containers: Vec<Container> = artists
.into_iter() .into_iter()
.filter_map(|artist| { .map(|artist| Container {
// Créer un container pour chaque artiste id: format!("qobuz:artist:{}", artist.id),
Some(Container { parent_id: "qobuz:favorites:artists".to_string(),
id: format!("qobuz:artist:{}", artist.id), restricted: Some("1".to_string()),
parent_id: "qobuz:favorites:artists".to_string(), child_count: None,
restricted: Some("1".to_string()), searchable: Some("1".to_string()),
child_count: None, title: artist.name.clone(),
searchable: Some("1".to_string()), class: "object.container".to_string(),
title: artist.name.clone(), artist: Some(artist.name.clone()),
class: "object.container".to_string(), album_art: artist.image_cached.clone(),
artist: Some(artist.name.clone()), containers: vec![],
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), items: vec![],
containers: vec![],
items: vec![],
})
}) })
.collect(); .collect();
@@ -1165,6 +1227,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let artists = self.cache_covers(artists).await;
let containers: Vec<Container> = artists let containers: Vec<Container> = artists
.into_iter() .into_iter()
.map(|artist| Container { .map(|artist| Container {
@@ -1176,7 +1239,7 @@ impl QobuzSource {
title: artist.name.clone(), title: artist.name.clone(),
class: "object.container".to_string(), class: "object.container".to_string(),
artist: Some(artist.name.clone()), artist: Some(artist.name.clone()),
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), album_art: artist.image_cached.clone(),
containers: vec![], containers: vec![],
items: vec![], items: vec![],
}) })
@@ -1194,6 +1257,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let playlists = self.cache_covers(playlists).await;
let parent_id = format!("qobuz:discover:playlists:{}", tag); let parent_id = format!("qobuz:discover:playlists:{}", tag);
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
@@ -1559,7 +1623,6 @@ impl MusicSource for QobuzSource {
} }
ObjectIdType::Playlist(playlist_id) => { ObjectIdType::Playlist(playlist_id) => {
// Get tracks in playlist
let tracks = self let tracks = self
.inner .inner
.client .client
@@ -1567,6 +1630,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let tracks = self.cache_covers(tracks).await;
let items: Vec<Item> = tracks let items: Vec<Item> = tracks
.into_iter() .into_iter()
.filter_map(|track| { .filter_map(|track| {
@@ -1580,7 +1644,6 @@ impl MusicSource for QobuzSource {
} }
ObjectIdType::Artist(artist_id) => { ObjectIdType::Artist(artist_id) => {
// Get albums by artist
let albums = self let albums = self
.inner .inner
.client .client
@@ -1588,6 +1651,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_covers(albums).await;
let containers: Vec<Container> = albums let containers: Vec<Container> = albums
.into_iter() .into_iter()
.filter_map(|album| { .filter_map(|album| {
@@ -1738,6 +1802,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let all_tracks = self.cache_covers(all_tracks).await;
let items: Vec<Item> = all_tracks let items: Vec<Item> = all_tracks
.into_iter() .into_iter()
.skip(offset) .skip(offset)
@@ -1757,15 +1822,16 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Convert albums to containers and tracks to items let (albums, tracks) = tokio::join!(
let containers: Vec<Container> = results self.cache_covers(results.albums),
.albums self.cache_covers(results.tracks),
);
let containers: Vec<Container> = albums
.into_iter() .into_iter()
.filter_map(|album| album.to_didl_container("qobuz").ok()) .filter_map(|album| album.to_didl_container("qobuz").ok())
.collect(); .collect();
let items: Vec<Item> = results let items: Vec<Item> = tracks
.tracks
.into_iter() .into_iter()
.filter_map(|track| track.to_didl_item("qobuz").ok()) .filter_map(|track| track.to_didl_item("qobuz").ok())
.collect(); .collect();
@@ -1979,6 +2045,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let playlists = self.cache_covers(playlists).await;
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
.filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) .filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
@@ -2061,6 +2128,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_covers(albums).await;
let containers: Vec<Container> = albums let containers: Vec<Container> = albums
.into_iter() .into_iter()
.skip(offset) .skip(offset)

View File

@@ -116,17 +116,12 @@ impl Server {
let base_url = base_url.into(); 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))
.with_state(api_registry.clone()); .with_state(api_registry.clone());
Self { let server = Self {
name: name.into(), name: name.into(),
base_url, base_url,
http_port, http_port,
@@ -136,7 +131,14 @@ impl Server {
log_state: None, log_state: None,
api_registry, api_registry,
shutdown_token: CancellationToken::new(), shutdown_token: CancellationToken::new(),
} };
// Initialiser PMO_SERVER_URL avec l'URL complète (incluant le port).
// base_url() normalise l'URL en ajoutant le port si absent.
// SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent.
unsafe { std::env::set_var("PMO_SERVER_URL", server.base_url()) };
server
} }
pub fn new_configured() -> Self { pub fn new_configured() -> Self {