Ajouter une route JPEG ou Cover Cache

This commit is contained in:
2025-11-28 21:22:39 +01:00
parent b04a90818f
commit 4e979a2d79
7 changed files with 118 additions and 48 deletions

View File

@@ -122,6 +122,10 @@
<p v-else><strong>Source URL:</strong> Unknown</p>
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
<div class="links">
<p><strong>WebP:</strong> <a :href="getImageUrl(selectedImage.pk)" target="_blank">{{ getImageUrl(selectedImage.pk) }}</a></p>
<p><strong>JPEG:</strong> <a :href="getJpegUrl(selectedImage.pk)" target="_blank">{{ getJpegUrl(selectedImage.pk) }}</a></p>
</div>
<div class="modal-actions">
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
📋 Copy URL
@@ -146,6 +150,7 @@ import {
purgeCache,
consolidateCache,
getImageUrl,
getJpegUrl,
getOriginUrl,
waitForDownload,
getDefaultImageUrl,
@@ -235,8 +240,10 @@ async function handleConsolidate(){
}
function copyImageUrl(pk:string){
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
alert("✅ URL copied!");
const webpUrl = window.location.origin + getImageUrl(pk);
const jpegUrl = window.location.origin + getJpegUrl(pk);
navigator.clipboard.writeText(`${webpUrl}\n${jpegUrl}`);
alert("✅ URLs copied (WebP + JPEG)!");
}
function resolveOrigin(entry: CacheEntry | null): string | undefined {

View File

@@ -193,6 +193,13 @@ export function getImageUrl(pk: string, size?: number): string {
return `/covers/image/${pk}`;
}
export function getJpegUrl(pk: string, size?: number): string {
if (size) {
return `/covers/jpeg/${pk}/${size}`;
}
return `/covers/jpeg/${pk}`;
}
/**
* SVG par défaut pour les images qui ne se chargent pas
*/

View File

@@ -169,6 +169,72 @@ fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator<CoversC
})
}
// ========================================================================
// Handlers JPEG (transcodage à la volée)
// ========================================================================
#[cfg(feature = "pmoserver")]
async fn serve_cover_jpeg(
axum::extract::State(cache): axum::extract::State<Arc<Cache>>,
axum::extract::Path(pk): axum::extract::Path<String>,
) -> axum::response::Response {
serve_jpeg_internal(cache, pk, None).await
}
#[cfg(feature = "pmoserver")]
async fn serve_cover_jpeg_with_size(
axum::extract::State(cache): axum::extract::State<Arc<Cache>>,
axum::extract::Path((pk, size)): axum::extract::Path<(String, String)>,
) -> axum::response::Response {
let size = size.parse::<u32>().ok();
serve_jpeg_internal(cache, pk, size).await
}
#[cfg(feature = "pmoserver")]
async fn serve_jpeg_internal(
cache: Arc<Cache>,
pk: String,
size: Option<u32>,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
use image::ImageFormat;
use std::io::Cursor;
let path = cache.get_file_path_with_qualifier(&pk, <CoversConfig as pmocache::cache::CacheConfig>::default_param());
if !path.exists() {
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
let res = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
let mut img = image::open(&path)?;
if let Some(size) = size {
img = crate::webp::ensure_square(&img, size);
}
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Jpeg)?;
Ok(buf.into_inner())
})
.await;
match res {
Ok(Ok(data)) => (
StatusCode::OK,
[("content-type", "image/jpeg")],
data,
)
.into_response(),
Ok(Err(e)) => {
tracing::warn!("JPEG transcode error for {}: {}", pk, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response()
}
Err(e) => {
tracing::warn!("JPEG transcode join error for {}: {}", pk, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response()
}
}
}
/// Trait d'extension pour ajouter le cache de couvertures à pmoserver
#[cfg(feature = "pmoserver")]
#[async_trait::async_trait]
@@ -224,7 +290,23 @@ impl CoverCacheExt for pmoserver::Server {
"image/webp",
Some(create_variant_generator()),
);
self.add_router("/", file_router).await;
// Router JPEG (transcodage à la volée depuis le WebP stocké)
// Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size}
let jpeg_router = axum::Router::new()
.route(
"/covers/jpeg/{pk}",
axum::routing::get(serve_cover_jpeg),
)
.route(
"/covers/jpeg/{pk}/{size}",
axum::routing::get(serve_cover_jpeg_with_size),
)
.with_state(cache.clone());
// Combiner WebP et JPEG dans un seul sous-router pour éviter tout overlap
let combined_router = file_router.merge(jpeg_router);
self.add_router("/", combined_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/covers, etc.
@@ -232,6 +314,9 @@ impl CoverCacheExt for pmoserver::Server {
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "covers").await;
// Enregistrer dans le singleton global pour éviter des initialisations multiples
register_cover_cache(cache.clone());
Ok(cache)
}

View File

@@ -70,12 +70,22 @@ Récupère l'image originale en WebP
### GET /covers/image/{pk}/{size}
Récupère une variante redimensionnée (ex: /covers/image/abc123/256)
### GET /covers/jpeg/{pk}
Récupère l'image transcodée en JPEG (pour les clients qui ne supportent pas WebP)
### GET /covers/jpeg/{pk}/{size}
Récupère une variante redimensionnée transcodée en JPEG (ex: /covers/jpeg/abc123/256)
## Format des images
Les images sont stockées au format WebP avec :
- Une version originale (`{pk}.orig.webp`)
- Des variantes de tailles générées à la demande (`{pk}.{size}.webp`)
Des routes JPEG sont proposées pour compatibilité UPnP (albumArtURI) :
- `/covers/jpeg/{pk}` (transcodage à la volée depuis le WebP)
- `/covers/jpeg/{pk}/{size}` (transcodage après redimensionnement)
## Clés (pk)
Chaque image est identifiée par une clé (pk) unique :

View File

@@ -28,7 +28,7 @@ pmoparadise = { path = "../pmoparadise", optional = true }
pmoconfig = { path = "../pmoconfig", optional = true }
anyhow = { version = "1.0", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmocovers = { path = "../pmocovers", optional = true }
pmocovers = { path = "../pmocovers", optional = true, features = ["pmoserver"] }
pmoplaylist = { path = "../pmoplaylist", optional = true }
tokio-util = { version = "0.7", features = ["io"], optional = true }
@@ -46,6 +46,7 @@ paradise = [
"dep:anyhow",
"dep:pmoaudiocache",
"dep:pmocovers",
"pmocovers/pmoserver",
"dep:pmoplaylist",
"dep:tokio-util"
]

View File

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

View File

@@ -289,49 +289,9 @@ impl UpnpServerExt for Server {
cache_dir: &str,
limit: usize,
) -> Result<Arc<CoverCache>, anyhow::Error> {
use pmocache::pmoserver_ext::{create_api_router, create_file_router_with_generator};
use pmocovers::new_cache;
let base_url = self.info().base_url.clone();
let cache = Arc::new(new_cache(cache_dir, limit)?);
// Routes de fichiers avec génération de variantes
// Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size}
let variant_generator: pmocache::pmoserver_ext::ParamGenerator<pmocovers::CoversConfig> =
Arc::new(|cache, pk, param| {
Box::pin(async move {
// Si le param est numérique, c'est une taille de variante
if let Ok(size) = param.parse::<usize>() {
match pmocovers::webp::generate_variant(&cache, &pk, size).await {
Ok(data) => return Some(data),
Err(e) => {
tracing::warn!(
"Cannot generate variant {}x{} for {}: {}",
size,
size,
pk,
e
);
return None;
}
}
}
None
})
});
let file_router =
create_file_router_with_generator(cache.clone(), "image/webp", Some(variant_generator));
self.add_router("/", file_router).await;
// API REST générique (pmocache)
let api_router = create_api_router(cache.clone());
let openapi = pmocovers::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "covers").await;
// Enregistrer le cache dans le registre global
pmocovers::register_cover_cache(cache.clone());
// Délègue à l'implémentation pmocovers (qui enregistre WebP + JPEG + API)
use pmocovers::CoverCacheExt;
let cache = pmocovers::CoverCacheExt::init_cover_cache(self, cache_dir, limit).await?;
Ok(cache)
}