push-pqqsxyupswry #21
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user