Ajoute pmocovers
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,8 +10,7 @@ xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.DS_Strore/**
|
||||
**/.DS_Strore
|
||||
.DS_Store
|
||||
/target/
|
||||
.pmomusic_covers
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
|
||||
899
Cargo.lock
generated
899
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
|
||||
|
||||
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use pmoserver::{
|
||||
ServerBuilder
|
||||
};
|
||||
use pmoapp::{Webapp, WebAppExt};
|
||||
use pmocovers::CoverCacheExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -19,6 +20,18 @@ async fn main() {
|
||||
// Initialiser le logging et enregistrer les routes de logs
|
||||
server.init_logging(LoggingOptions::default()).await;
|
||||
|
||||
|
||||
info!("📡 Registering the cover cache...");
|
||||
let cache = server.init_cover_cache_configured()
|
||||
.await
|
||||
.expect("Cannot initialise the image cache");
|
||||
|
||||
info!("✅ Cover cache ready at {}",
|
||||
cache.cache_dir(),
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Routes de base
|
||||
server
|
||||
.add_route("/info", || async {
|
||||
@@ -26,6 +39,7 @@ async fn main() {
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div>
|
||||
<nav>
|
||||
<router-link to="/">Accueil</router-link> |
|
||||
<router-link to="/logs">Logs</router-link>
|
||||
<router-link to="/logs">Logs</router-link> |
|
||||
<router-link to="/covers-cache">Cover Cache</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="cover-cache-manager">
|
||||
<div class="header">
|
||||
<h2>🖼️ Cover Cache Manager</h2>
|
||||
<div class="stats">
|
||||
<span>{{ images.length }} images</span>
|
||||
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="add-form">
|
||||
<h3>➕ Add New Cover</h3>
|
||||
<form @submit.prevent="handleAddImage">
|
||||
<div class="form-group">
|
||||
<input
|
||||
v-model="newImageUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/cover.jpg"
|
||||
required
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
<button type="submit" :disabled="isAdding || !newImageUrl">
|
||||
{{ isAdding ? "Adding..." : "Add Image" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="addError" class="error">❌ {{ addError }}</p>
|
||||
<p v-if="addSuccess" class="success">✅ {{ addSuccess }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles -->
|
||||
<div class="controls">
|
||||
<div class="sort-controls">
|
||||
<label>Sort by:</label>
|
||||
<select v-model="sortBy">
|
||||
<option value="hits">Most Used</option>
|
||||
<option value="last_used">Recently Used</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="refreshImages" :disabled="isLoading">
|
||||
🔄 {{ isLoading ? "Loading..." : "Refresh" }}
|
||||
</button>
|
||||
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
|
||||
🔧 {{ isConsolidating ? "Consolidating..." : "Consolidate" }}
|
||||
</button>
|
||||
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
|
||||
🗑️ {{ isPurging ? "Purging..." : "Purge All" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Galerie d'images -->
|
||||
<div v-if="isLoading && images.length === 0" class="loading-state">
|
||||
⏳ Loading images...
|
||||
</div>
|
||||
|
||||
<div v-else-if="images.length === 0" class="empty-state">
|
||||
📭 No images in cache. Add one using the form above!
|
||||
</div>
|
||||
|
||||
<div v-else class="image-grid">
|
||||
<div
|
||||
v-for="image in sortedImages"
|
||||
:key="image.pk"
|
||||
class="image-card"
|
||||
@click="selectedImage = image"
|
||||
>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(image.pk, 256)"
|
||||
:alt="image.source_url"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<span class="hits">👁️ {{ image.hits }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<div class="pk">{{ image.pk }}</div>
|
||||
<div class="url" :title="image.source_url">
|
||||
{{ truncateUrl(image.source_url) }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="image.last_used" class="last-used">
|
||||
🕐 {{ formatDate(image.last_used) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<button
|
||||
@click.stop="handleDeleteImage(image.pk)"
|
||||
class="btn-delete"
|
||||
:disabled="deletingImages.has(image.pk)"
|
||||
>
|
||||
{{ deletingImages.has(image.pk) ? "..." : "🗑️" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détails -->
|
||||
<div v-if="selectedImage" class="modal" @click="selectedImage = null">
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||
<img
|
||||
:src="getImageUrl(selectedImage.pk)"
|
||||
:alt="selectedImage.source_url"
|
||||
class="modal-image"
|
||||
/>
|
||||
<div class="modal-info">
|
||||
<h3>Image Details</h3>
|
||||
<p><strong>PK:</strong> {{ selectedImage.pk }}</p>
|
||||
<p><strong>Source URL:</strong> <a :href="selectedImage.source_url" target="_blank">{{ selectedImage.source_url }}</a></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="modal-actions">
|
||||
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||
📋 Copy URL
|
||||
</button>
|
||||
<button @click="handleDeleteImage(selectedImage.pk); selectedImage = null" class="btn-danger">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import type { CacheEntry } from "../services/coverCache";
|
||||
import {
|
||||
listImages,
|
||||
addImage,
|
||||
deleteImage,
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getImageUrl,
|
||||
} from "../services/coverCache";
|
||||
|
||||
// --- États ---
|
||||
const images = ref<CacheEntry[]>([]);
|
||||
const selectedImage = ref<CacheEntry | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||
|
||||
// Formulaire d'ajout
|
||||
const newImageUrl = ref("");
|
||||
const isAdding = ref(false);
|
||||
const addError = ref("");
|
||||
const addSuccess = ref("");
|
||||
|
||||
// Contrôles
|
||||
const isConsolidating = ref(false);
|
||||
const isPurging = ref(false);
|
||||
const deletingImages = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||
|
||||
const sortedImages = computed(() => {
|
||||
const arr = [...images.value];
|
||||
switch (sortBy.value) {
|
||||
case "hits": return arr.sort((a,b)=>b.hits-a.hits);
|
||||
case "last_used":
|
||||
return arr.sort((a,b)=>{
|
||||
if(!a.last_used) return 1;
|
||||
if(!b.last_used) return -1;
|
||||
return new Date(b.last_used).getTime()-new Date(a.last_used).getTime();
|
||||
});
|
||||
case "recent": return arr.reverse();
|
||||
default: return arr;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fonctions ---
|
||||
async function refreshImages() {
|
||||
isLoading.value = true;
|
||||
try { images.value = await listImages(); }
|
||||
finally { isLoading.value = false; }
|
||||
}
|
||||
|
||||
async function handleAddImage() {
|
||||
if(!newImageUrl.value) return;
|
||||
isAdding.value = true; addError.value=""; addSuccess.value="";
|
||||
try {
|
||||
const result = await addImage(newImageUrl.value);
|
||||
addSuccess.value = `Image added! PK: ${result.pk}`;
|
||||
newImageUrl.value = "";
|
||||
await refreshImages();
|
||||
} catch(e:any) { addError.value = e.message ?? "Failed to add image"; }
|
||||
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",1500); }
|
||||
}
|
||||
|
||||
async function handleDeleteImage(pk:string){
|
||||
if(!confirm(`Delete image ${pk}?`)) return;
|
||||
deletingImages.value.add(pk);
|
||||
try{ await deleteImage(pk); await refreshImages(); }
|
||||
finally{ deletingImages.value.delete(pk); }
|
||||
}
|
||||
|
||||
async function handlePurge(){
|
||||
if(!confirm("⚠️ Delete ALL images?")) return;
|
||||
isPurging.value = true;
|
||||
try{ await purgeCache(); await refreshImages(); }
|
||||
finally{ isPurging.value=false; }
|
||||
}
|
||||
|
||||
async function handleConsolidate(){
|
||||
if(!confirm("Consolidate cache?")) return;
|
||||
isConsolidating.value=true;
|
||||
try{ await consolidateCache(); await refreshImages(); }
|
||||
finally{ isConsolidating.value=false; }
|
||||
}
|
||||
|
||||
function copyImageUrl(pk:string){
|
||||
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
|
||||
alert("✅ URL copied!");
|
||||
}
|
||||
|
||||
function truncateUrl(url:string,maxLength=40){ return url.length<=maxLength?url:url.slice(0,maxLength-3)+"..."; }
|
||||
function formatDate(dateString:string){
|
||||
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||
}
|
||||
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
|
||||
|
||||
onMounted(()=>refreshImages());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cover-cache-manager {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
} /* Formulaire d'ajout */
|
||||
.add-form {
|
||||
background: #2a2a2a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.add-form h3 {
|
||||
margin-top: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-group input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-group button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.form-group button:hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
.form-group button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.success {
|
||||
color: #51cf66;
|
||||
margin-top: 0.5rem;
|
||||
} /* Contrôles */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.sort-controls label {
|
||||
color: #999;
|
||||
}
|
||||
.sort-controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:not(.btn-danger):not(.btn-secondary) {
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
}
|
||||
button:not(.btn-danger):not(.btn-secondary):hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #666;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ff6b6b;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #ee5a52;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
} /* États */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
font-size: 1.2rem;
|
||||
} /* Grille d'images */
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.image-card {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.image-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 100%; /* Ratio 1:1 */
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-wrapper img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.hits {
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
.pk {
|
||||
font-family: monospace;
|
||||
color: #61dafb;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.url {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
}
|
||||
.image-actions {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
background: #555;
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #ff6b6b;
|
||||
} /* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
.modal-content {
|
||||
background: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.modal-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.modal-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-info h3 {
|
||||
margin-top: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.modal-info p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.modal-info a {
|
||||
color: #61dafb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.modal-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Service API pour interagir avec le cache d'images de couvertures
|
||||
*/
|
||||
|
||||
export interface CacheEntry {
|
||||
pk: string;
|
||||
source_url: string;
|
||||
hits: number;
|
||||
last_used: string | null;
|
||||
}
|
||||
|
||||
export interface AddImageRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AddImageResponse {
|
||||
pk: string;
|
||||
url: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les images en cache
|
||||
*/
|
||||
export async function listImages(): Promise<CacheEntry[]> {
|
||||
const response = await fetch("/api/covers/images");
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch images");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'une image spécifique
|
||||
*/
|
||||
export async function getImageInfo(pk: string): Promise<CacheEntry> {
|
||||
const response = await fetch(`/api/covers/images/${pk}`);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch image info");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une nouvelle image au cache depuis une URL
|
||||
*/
|
||||
export async function addImage(url: string): Promise<AddImageResponse> {
|
||||
const response = await fetch("/api/covers/images", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to add image");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une image du cache
|
||||
*/
|
||||
export async function deleteImage(pk: string): Promise<void> {
|
||||
const response = await fetch(`/api/covers/images/${pk}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to delete image");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge complètement le cache
|
||||
*/
|
||||
export async function purgeCache(): Promise<void> {
|
||||
const response = await fetch("/api/covers/images", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to purge cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolide le cache (re-télécharge les images manquantes)
|
||||
*/
|
||||
export async function consolidateCache(): Promise<void> {
|
||||
const response = await fetch("/api/covers/images/consolidate", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to consolidate cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL pour afficher une image
|
||||
*/
|
||||
export function getImageUrl(pk: string, size?: number): string {
|
||||
if (size) {
|
||||
return `/covers/images/${pk}/${size}`;
|
||||
}
|
||||
return `/covers/images/${pk}`;
|
||||
}
|
||||
@@ -40,10 +40,13 @@ impl Clone for Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
|
||||
let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?;
|
||||
|
||||
// Essayer de charger depuis différents emplacements
|
||||
if !filename.is_empty() {
|
||||
info!(config_file=%path, "Trying to load config");
|
||||
@@ -97,8 +100,11 @@ impl Config {
|
||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||
};
|
||||
|
||||
let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
config_value = Self::lower_keys_value(config_value);
|
||||
|
||||
let external_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
merge_yaml(&mut default_value, &external_value);
|
||||
let mut config_value = Self::lower_keys_value(default_value);
|
||||
|
||||
Self::apply_env_overrides(&mut config_value);
|
||||
|
||||
if path.is_empty() || !Self::is_writable(&path) {
|
||||
@@ -175,8 +181,10 @@ impl Config {
|
||||
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
||||
let mut current = data;
|
||||
for (i, key) in path.iter().enumerate() {
|
||||
|
||||
if let Value::Mapping(map) = current {
|
||||
let key = key.to_lowercase();
|
||||
|
||||
if let Some(next) = map.get(&Value::String(key)) {
|
||||
current = next;
|
||||
} else {
|
||||
@@ -317,3 +325,17 @@ impl Config {
|
||||
pub fn get_config() -> Arc<Config> {
|
||||
CONFIG.clone()
|
||||
}
|
||||
|
||||
fn merge_yaml(default: &mut Value, external: &Value) {
|
||||
match (default, external) {
|
||||
(Value::Mapping(dmap), Value::Mapping(emap)) => {
|
||||
for (k, v) in emap {
|
||||
match dmap.get_mut(k) {
|
||||
Some(dv) => merge_yaml(dv, v),
|
||||
None => { dmap.insert(k.clone(), v.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, e) => *d = e.clone(), // pour les scalaires ou séquences, on remplace
|
||||
}
|
||||
}
|
||||
|
||||
39
pmocovers/Cargo.toml
Normal file
39
pmocovers/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "pmocovers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Gestion d'images
|
||||
image = "0.25"
|
||||
webp = "0.3"
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
# Cryptographie
|
||||
sha1 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Serveur HTTP (optionnel pour l'extension)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"]
|
||||
311
pmocovers/src/api.rs
Normal file
311
pmocovers/src/api.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! API REST pour la gestion du cache de couvertures
|
||||
//!
|
||||
//! Ce module expose une API REST documentée avec OpenAPI/Swagger pour :
|
||||
//! - Lister les images en cache
|
||||
//! - Ajouter des images depuis une URL
|
||||
//! - Supprimer des images
|
||||
//! - Consulter les statistiques
|
||||
|
||||
use crate::{Cache, CacheEntry};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Requête pour ajouter une image au cache
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AddImageRequest {
|
||||
/// URL de l'image source
|
||||
#[schema(example = "https://example.com/cover.jpg")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Réponse après ajout d'une image
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AddImageResponse {
|
||||
/// Clé primaire (pk) de l'image ajoutée
|
||||
#[schema(example = "1a2b3c4d5e6f7a8b")]
|
||||
pub pk: String,
|
||||
/// URL source de l'image
|
||||
#[schema(example = "https://example.com/cover.jpg")]
|
||||
pub url: String,
|
||||
/// Message de succès
|
||||
#[schema(example = "Image added successfully")]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Réponse de suppression d'une image
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DeleteImageResponse {
|
||||
/// Message de succès
|
||||
#[schema(example = "Image deleted successfully")]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Réponse d'erreur générique
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
/// Code d'erreur
|
||||
#[schema(example = "NOT_FOUND")]
|
||||
pub error: String,
|
||||
/// Message descriptif
|
||||
#[schema(example = "Image not found in cache")]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Liste toutes les images en cache avec leurs statistiques
|
||||
///
|
||||
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/covers",
|
||||
responses(
|
||||
(status = 200, description = "Liste des images en cache", body = Vec<CacheEntry>),
|
||||
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn list_images(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
|
||||
match cache.db.get_all() {
|
||||
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "DATABASE_ERROR".to_string(),
|
||||
message: format!("Cannot retrieve cache entries: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les informations d'une image spécifique
|
||||
///
|
||||
/// Retourne les métadonnées d'une image identifiée par sa clé (pk).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/covers/{pk}",
|
||||
params(
|
||||
("pk" = String, Path, description = "Clé primaire de l'image", example = "1a2b3c4d5e6f7a8b")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Informations de l'image", body = CacheEntry),
|
||||
(status = 404, description = "Image non trouvée", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn get_image_info(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match cache.db.get(&pk) {
|
||||
Ok(entry) => (StatusCode::OK, Json(entry)).into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Image with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une image au cache depuis une URL
|
||||
///
|
||||
/// Télécharge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache.
|
||||
/// Si l'image existe déjà, elle est mise à jour.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/covers",
|
||||
request_body = AddImageRequest,
|
||||
responses(
|
||||
(status = 201, description = "Image ajoutée avec succès", body = AddImageResponse),
|
||||
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors du téléchargement ou de la conversion", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn add_image(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
Json(req): Json<AddImageRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if req.url.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "INVALID_REQUEST".to_string(),
|
||||
message: "URL cannot be empty".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match cache.add_from_url(&req.url).await {
|
||||
Ok(pk) => (
|
||||
StatusCode::CREATED,
|
||||
Json(AddImageResponse {
|
||||
pk,
|
||||
url: req.url,
|
||||
message: "Image added successfully".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "PROCESSING_ERROR".to_string(),
|
||||
message: format!("Cannot add image: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime une image du cache
|
||||
///
|
||||
/// Supprime l'image et toutes ses variantes du disque et de la base de données.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/covers/{pk}",
|
||||
params(
|
||||
("pk" = String, Path, description = "Clé primaire de l'image à supprimer", example = "1a2b3c4d5e6f7a8b")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Image supprimée avec succès", body = DeleteImageResponse),
|
||||
(status = 404, description = "Image non trouvée", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de la suppression", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn delete_image(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Vérifier que l'image existe
|
||||
if cache.db.get(&pk).is_err() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Image with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Supprimer les fichiers (original + variantes)
|
||||
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
|
||||
if orig_path.exists() {
|
||||
if let Err(e) = tokio::fs::remove_file(&orig_path).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "FILE_DELETE_ERROR".to_string(),
|
||||
message: format!("Cannot delete original file: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer toutes les variantes (*.{pk}.*.webp)
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) {
|
||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer de la base de données
|
||||
match cache.db.delete(&pk) {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(DeleteImageResponse {
|
||||
message: format!("Image '{}' deleted successfully", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "DATABASE_ERROR".to_string(),
|
||||
message: format!("Cannot delete from database: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Purge complètement le cache
|
||||
///
|
||||
/// Supprime toutes les images et vide la base de données. Opération irréversible.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/covers",
|
||||
responses(
|
||||
(status = 200, description = "Cache purgé avec succès", body = DeleteImageResponse),
|
||||
(status = 500, description = "Erreur lors de la purge", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn purge_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
|
||||
match cache.purge().await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(DeleteImageResponse {
|
||||
message: "Cache purged successfully".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "PURGE_ERROR".to_string(),
|
||||
message: format!("Cannot purge cache: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consolide le cache
|
||||
///
|
||||
/// Re-télécharge les images manquantes et supprime les fichiers orphelins.
|
||||
/// Utile pour réparer un cache corrompu.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/covers/consolidate",
|
||||
responses(
|
||||
(status = 200, description = "Cache consolidé avec succès", body = DeleteImageResponse),
|
||||
(status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn consolidate_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
|
||||
match cache.consolidate().await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(DeleteImageResponse {
|
||||
message: "Cache consolidated successfully".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "CONSOLIDATE_ERROR".to_string(),
|
||||
message: format!("Cannot consolidate cache: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
145
pmocovers/src/cache.rs
Normal file
145
pmocovers/src/cache.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use anyhow::{anyhow, Result};
|
||||
use sha1::{Sha1, Digest};
|
||||
use tokio::sync::Mutex;
|
||||
use crate::db::DB;
|
||||
use crate::webp;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Cache {
|
||||
pub(crate) dir: PathBuf,
|
||||
pub(crate) limit: usize,
|
||||
pub db: DB,
|
||||
mu: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let db = DB::init(&PathBuf::from(dir).join("cache.db"))?;
|
||||
|
||||
Ok(Self {
|
||||
dir: PathBuf::from(dir),
|
||||
limit,
|
||||
db,
|
||||
mu: Arc::new(Mutex::new(())),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn add_from_url(&self, url: &str) -> Result<String> {
|
||||
let response = reqwest::get(url).await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Bad status: {}", response.status()));
|
||||
}
|
||||
|
||||
let data = response.bytes().await?;
|
||||
self.add(url, &data).await
|
||||
}
|
||||
|
||||
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
|
||||
let pk = pk_from_url(url);
|
||||
|
||||
if self.db.get(&pk).is_ok() {
|
||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
||||
if orig_path.exists() {
|
||||
return Ok(pk);
|
||||
}
|
||||
}
|
||||
|
||||
self.add_from_url(url).await
|
||||
}
|
||||
|
||||
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
|
||||
let pk = pk_from_url(url);
|
||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
||||
|
||||
let _lock = self.mu.lock().await;
|
||||
|
||||
if !orig_path.exists() {
|
||||
let img = image::load_from_memory(data)?;
|
||||
let webp_data = webp::encode_webp(&img)?;
|
||||
tokio::fs::write(&orig_path, webp_data).await?;
|
||||
}
|
||||
|
||||
self.db.add(&pk, url)?;
|
||||
Ok(pk)
|
||||
}
|
||||
|
||||
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
|
||||
let _lock = self.mu.lock().await;
|
||||
|
||||
self.db.get(pk)?;
|
||||
self.db.update_hit(pk)?;
|
||||
|
||||
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
|
||||
if orig_path.exists() {
|
||||
Ok(orig_path)
|
||||
} else {
|
||||
Err(anyhow!("File not found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn purge(&self) -> Result<()> {
|
||||
let _lock = self.mu.lock().await;
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&self.dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.path().is_file() {
|
||||
tokio::fs::remove_file(entry.path()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
|
||||
}
|
||||
|
||||
pub async fn consolidate(&self) -> Result<()> {
|
||||
let _lock = self.mu.lock().await;
|
||||
|
||||
let entries = self.db.get_all()?;
|
||||
|
||||
for entry in entries {
|
||||
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
|
||||
if !orig_path.exists() {
|
||||
match reqwest::get(&entry.source_url).await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let data = response.bytes().await?;
|
||||
self.add(&entry.source_url, &data).await?;
|
||||
}
|
||||
_ => {
|
||||
self.db.delete(&entry.pk)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
|
||||
while let Some(entry) = dir_entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if file_name.ends_with(".orig.webp") {
|
||||
let pk = file_name.trim_end_matches(".orig.webp");
|
||||
if self.db.get(pk).is_err() {
|
||||
tokio::fs::remove_file(path).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cache_dir(&self) -> String {
|
||||
self.dir.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn pk_from_url(url: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
hex::encode(&result[..8])
|
||||
}
|
||||
118
pmocovers/src/db.rs
Normal file
118
pmocovers/src/db.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::Serialize;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
|
||||
pub struct CacheEntry {
|
||||
/// Clé primaire unique de l'image (hash SHA1 de l'URL)
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))]
|
||||
pub pk: String,
|
||||
/// URL source de l'image
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))]
|
||||
pub source_url: String,
|
||||
/// Nombre d'accès à l'image
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = 42))]
|
||||
pub hits: i32,
|
||||
/// Date/heure du dernier accès (RFC3339)
|
||||
#[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DB {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl DB {
|
||||
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS covers (
|
||||
pk TEXT PRIMARY KEY,
|
||||
source_url TEXT,
|
||||
hits INTEGER DEFAULT 0,
|
||||
last_used TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO covers (pk, source_url, hits, last_used)
|
||||
VALUES (?1, ?2, 0, ?3)
|
||||
ON CONFLICT(pk) DO UPDATE SET
|
||||
source_url = excluded.source_url,
|
||||
last_used = excluded.last_used",
|
||||
params![pk, url, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1",
|
||||
[pk],
|
||||
|row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
hits: row.get(2)?,
|
||||
last_used: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
|
||||
params![Utc::now().to_rfc3339(), pk],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn purge(&self) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM covers", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC",
|
||||
)?;
|
||||
|
||||
let entries = stmt.query_map([], |row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
source_url: row.get(1)?,
|
||||
hits: row.get(2)?,
|
||||
last_used: row.get(3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
277
pmocovers/src/lib.rs
Normal file
277
pmocovers/src/lib.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic
|
||||
//!
|
||||
//! Cette crate fournit un système de cache d'images optimisé pour les couvertures d'albums,
|
||||
//! avec conversion automatique en WebP et génération de variantes de tailles.
|
||||
//!
|
||||
//! ## Vue d'ensemble
|
||||
//!
|
||||
//! `pmocovers` gère le téléchargement, la conversion, le stockage et la distribution
|
||||
//! d'images de couvertures d'albums, avec :
|
||||
//! - Conversion automatique en WebP pour réduire la taille
|
||||
//! - Génération de variantes de tailles à la demande
|
||||
//! - Cache persistant avec base de données SQLite
|
||||
//! - API HTTP pour récupérer les images
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! ### 📦 Gestion du cache
|
||||
//! - Téléchargement automatique depuis des URLs
|
||||
//! - Conversion des images en WebP (format optimisé)
|
||||
//! - Stockage persistant sur disque
|
||||
//! - Base de données SQLite pour le tracking
|
||||
//!
|
||||
//! ### 🎨 Génération de variantes
|
||||
//! - Redimensionnement automatique à la demande
|
||||
//! - Création d'images carrées avec centrage
|
||||
//! - Cache des variantes générées
|
||||
//! - Support de multiples tailles
|
||||
//!
|
||||
//! ### 📊 Statistiques d'utilisation
|
||||
//! - Comptage des accès (hits)
|
||||
//! - Suivi de la dernière utilisation
|
||||
//! - API de statistiques complètes
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! `pmocovers` suit le pattern d'extension des autres crates PMO :
|
||||
//!
|
||||
//! - `pmoserver` définit un serveur HTTP générique
|
||||
//! - `pmocovers` étend ce serveur avec des méthodes de cache via un trait
|
||||
//! - Le serveur n'a pas besoin de connaître `pmocovers`
|
||||
//!
|
||||
//! ## Structure des fichiers
|
||||
//!
|
||||
//! ```text
|
||||
//! pmocovers/
|
||||
//! ├── Cargo.toml
|
||||
//! ├── src/
|
||||
//! │ ├── lib.rs # Module principal (ce fichier)
|
||||
//! │ ├── cache.rs # Gestion du cache
|
||||
//! │ ├── db.rs # Base de données SQLite
|
||||
//! │ ├── webp.rs # Conversion et redimensionnement WebP
|
||||
//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server
|
||||
//! └── cache/ # Répertoire de cache (généré)
|
||||
//! ├── cache.db # Base SQLite
|
||||
//! ├── *.orig.webp # Images originales
|
||||
//! └── *.{size}.webp # Variantes de tailles
|
||||
//! ```
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple basique avec configuration automatique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocovers::CoverCacheExt;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut server = ServerBuilder::new_configured().build();
|
||||
//!
|
||||
//! // Utilise automatiquement la config (pmoconfig)
|
||||
//! server.init_cover_cache_configured().await?;
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! server.wait().await;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Exemple avec paramètres personnalisés
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocovers::CoverCacheExt;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
|
||||
//!
|
||||
//! // Paramètres personnalisés
|
||||
//! server.init_cover_cache("./cache", 1000).await?;
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! server.wait().await;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Utilisation du cache directement
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocovers::Cache;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = Cache::new("./cache", 1000)?;
|
||||
//!
|
||||
//! // Ajouter une image depuis une URL
|
||||
//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
|
||||
//! println!("Image ajoutée avec clé: {}", pk);
|
||||
//!
|
||||
//! // Récupérer l'image originale
|
||||
//! let path = cache.get(&pk).await?;
|
||||
//! println!("Image stockée à: {:?}", path);
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## API HTTP
|
||||
//!
|
||||
//! Une fois enregistré sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles :
|
||||
//!
|
||||
//! ### GET /covers/images/{pk}
|
||||
//! Récupère l'image originale en WebP
|
||||
//!
|
||||
//! ### GET /covers/images/{pk}/{size}
|
||||
//! Récupère une variante de taille spécifique (ex: `/covers/images/abc123/256`)
|
||||
//!
|
||||
//! ### GET /covers/stats
|
||||
//! Récupère les statistiques du cache (JSON)
|
||||
//!
|
||||
//! ## Format des clés (pk)
|
||||
//!
|
||||
//! Les images sont identifiées par une clé (pk) dérivée de l'URL source :
|
||||
//! - Hash SHA1 de l'URL
|
||||
//! - Encodé en hexadécimal (8 premiers octets)
|
||||
//! - Exemple: `"1a2b3c4d5e6f7a8b"`
|
||||
//!
|
||||
//! ## Stockage
|
||||
//!
|
||||
//! Les fichiers sont organisés comme suit :
|
||||
//!
|
||||
//! ```text
|
||||
//! cache/
|
||||
//! ├── cache.db # Base SQLite
|
||||
//! ├── 1a2b3c4d.orig.webp # Image originale
|
||||
//! ├── 1a2b3c4d.256.webp # Variante 256x256
|
||||
//! └── 1a2b3c4d.512.webp # Variante 512x512
|
||||
//! ```
|
||||
//!
|
||||
//! ## Opérations de maintenance
|
||||
//!
|
||||
//! ### Purge du cache
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! # use pmocovers::Cache;
|
||||
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
|
||||
//! // Supprimer tous les fichiers et entrées DB
|
||||
//! cache.purge().await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Consolidation du cache
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! # use pmocovers::Cache;
|
||||
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
|
||||
//! // Re-télécharger les images manquantes et supprimer les orphelins
|
||||
//! cache.consolidate().await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dépendances principales
|
||||
//!
|
||||
//! - `image` : Chargement et manipulation d'images
|
||||
//! - `webp` : Encodage WebP
|
||||
//! - `rusqlite` : Base de données SQLite
|
||||
//! - `reqwest` : Téléchargement HTTP
|
||||
//! - `sha1` : Génération de clés
|
||||
//!
|
||||
//! ## Voir aussi
|
||||
//!
|
||||
//! - [`pmoserver`] : Serveur HTTP Axum
|
||||
//! - [`pmoapp`] : Application web frontend
|
||||
//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer
|
||||
|
||||
pub mod cache;
|
||||
pub mod db;
|
||||
pub mod webp;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod api;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod openapi;
|
||||
|
||||
pub use cache::Cache;
|
||||
pub use db::{CacheEntry, DB};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use openapi::ApiDoc;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache d'images.
|
||||
///
|
||||
/// Ce trait permet à `pmocovers` d'ajouter des méthodes d'extension sur des types
|
||||
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmocovers`.
|
||||
///
|
||||
/// # Architecture
|
||||
///
|
||||
/// Similaire au pattern utilisé par `pmoapp` pour `WebAppExt`, ce trait permet
|
||||
/// une extension propre et découplée :
|
||||
///
|
||||
/// - `pmoserver` définit un serveur HTTP générique
|
||||
/// - `pmocovers` étend ce serveur avec des méthodes de cache via ce trait
|
||||
/// - Le serveur n'a pas besoin de connaître `pmocovers`
|
||||
pub trait CoverCacheExt {
|
||||
/// Initialise le cache d'images et enregistre les routes HTTP.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_dir` - Répertoire de stockage du cache
|
||||
/// * `limit` - Limite de taille du cache (en nombre d'images)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Arc<Cache>` - Instance partagée du cache
|
||||
///
|
||||
/// # Routes enregistrées
|
||||
///
|
||||
/// - `GET /covers/images/{pk}` - Image originale
|
||||
/// - `GET /covers/images/{pk}/{size}` - Variante de taille
|
||||
/// - `GET /covers/stats` - Statistiques
|
||||
/// - `GET /api/covers` - Liste des images (API REST)
|
||||
/// - `POST /api/covers` - Ajouter une image (API REST)
|
||||
/// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST)
|
||||
/// - `GET /swagger-ui` - Documentation interactive
|
||||
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result<Arc<Cache>>;
|
||||
|
||||
/// Initialise le cache d'images avec la configuration par défaut.
|
||||
///
|
||||
/// Utilise automatiquement les paramètres de `pmoconfig::Config` :
|
||||
/// - `host.cover_cache.directory` pour le répertoire
|
||||
/// - `host.cover_cache.size` pour la limite de taille
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Arc<Cache>` - Instance partagée du cache
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pmocovers::CoverCacheExt;
|
||||
/// use pmoserver::ServerBuilder;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// let mut server = ServerBuilder::new_configured().build();
|
||||
///
|
||||
/// // Utilise automatiquement la config
|
||||
/// server.init_cover_cache_configured().await?;
|
||||
///
|
||||
/// server.start().await;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
async fn init_cover_cache_configured(&mut self) -> Result<Arc<Cache>>;
|
||||
}
|
||||
|
||||
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
mod pmoserver_impl;
|
||||
70
pmocovers/src/openapi.rs
Normal file
70
pmocovers/src/openapi.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Documentation OpenAPI pour l'API REST du cache de couvertures
|
||||
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::api::list_images,
|
||||
crate::api::get_image_info,
|
||||
crate::api::add_image,
|
||||
crate::api::delete_image,
|
||||
crate::api::purge_cache,
|
||||
crate::api::consolidate_cache,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
crate::db::CacheEntry,
|
||||
crate::api::AddImageRequest,
|
||||
crate::api::AddImageResponse,
|
||||
crate::api::DeleteImageResponse,
|
||||
crate::api::ErrorResponse,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "covers", description = "Gestion du cache d'images de couvertures")
|
||||
),
|
||||
info(
|
||||
title = "PMOCovers API",
|
||||
version = "0.1.0",
|
||||
description = r#"
|
||||
# API de gestion du cache d'images de couvertures
|
||||
|
||||
Cette API permet de gérer un cache d'images optimisé pour les couvertures d'albums.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Ajout d'images** : Téléchargement depuis une URL avec conversion automatique en WebP
|
||||
- **Consultation** : Liste des images avec statistiques d'utilisation
|
||||
- **Suppression** : Suppression individuelle ou purge complète
|
||||
- **Maintenance** : Consolidation du cache pour réparer les incohérences
|
||||
|
||||
## 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`)
|
||||
|
||||
## Clés (pk)
|
||||
|
||||
Chaque image est identifiée par une clé (pk) unique :
|
||||
- Hash SHA1 des 8 premiers octets de l'URL source
|
||||
- Encodage hexadécimal
|
||||
- Exemple : `1a2b3c4d5e6f7a8b`
|
||||
|
||||
## Statistiques
|
||||
|
||||
Le système suit automatiquement :
|
||||
- Le nombre d'accès (hits)
|
||||
- La date du dernier accès
|
||||
- L'URL source originale
|
||||
"#,
|
||||
contact(
|
||||
name = "PMOMusic",
|
||||
),
|
||||
license(
|
||||
name = "MIT",
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
166
pmocovers/src/pmoserver_impl.rs
Normal file
166
pmocovers/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Implémentation du trait CoverCacheExt pour le serveur pmoserver
|
||||
//!
|
||||
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités de cache d'images en
|
||||
//! implémentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implémentation
|
||||
//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! `pmocovers` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`.
|
||||
//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalités à un type
|
||||
//! externe via un trait, similaire au pattern utilisé par `pmoapp` pour `WebAppExt`.
|
||||
//!
|
||||
//! ## Exemple d'utilisation
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocovers::CoverCacheExt;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//!
|
||||
//! # async fn example() -> anyhow::Result<()> {
|
||||
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
|
||||
//!
|
||||
//! // Le trait CoverCacheExt est automatiquement disponible
|
||||
//! let cache = server.init_cover_cache("./cache", 1000).await?;
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use crate::{api, Cache, CoverCacheExt};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use pmoserver::Server;
|
||||
use tracing::{debug, info};
|
||||
use std::sync::Arc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
|
||||
|
||||
/// Handler pour GET /covers/images/{pk}
|
||||
async fn get_cover_image(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
// Extraire pk du path
|
||||
let path = req.uri().path();
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
if parts.len() < 4 {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let pk = parts[3];
|
||||
|
||||
match cache.get(pk).await {
|
||||
Ok(file_path) => {
|
||||
match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "image/webp")],
|
||||
data,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
|
||||
}
|
||||
}
|
||||
Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour GET /covers/images/{pk}/{size}
|
||||
async fn get_cover_variant(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
// Extraire pk et size du path
|
||||
let path = req.uri().path();
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
if parts.len() < 5 {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let pk = parts[3];
|
||||
let size = match parts[4].parse::<usize>() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(),
|
||||
};
|
||||
|
||||
match crate::webp::generate_variant(&cache, pk, size).await {
|
||||
Ok(data) => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "image/webp")],
|
||||
data,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour GET /covers/stats
|
||||
async fn get_cover_stats(State(cache): State<Arc<Cache>>) -> Response {
|
||||
match cache.db.get_all() {
|
||||
Ok(entries) => Json(entries).into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
impl CoverCacheExt for Server {
|
||||
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<Arc<Cache>> {
|
||||
let cache = Arc::new(Cache::new(cache_dir, limit)?);
|
||||
|
||||
// Enregistrer les routes HTTP classiques
|
||||
self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await;
|
||||
self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await;
|
||||
|
||||
// Router API RESTful
|
||||
// Router API RESTful monté sur /api/covers
|
||||
let api_router = Router::new()
|
||||
// Liste et ajout
|
||||
.route(
|
||||
"/images/",
|
||||
get(api::list_images) // GET /api/covers
|
||||
.post(api::add_image) // POST /api/covers
|
||||
.delete(api::purge_cache), // DELETE /api/covers
|
||||
)
|
||||
// Ressource unique
|
||||
.route(
|
||||
"/images/{pk}",
|
||||
get(api::get_image_info) // GET /api/covers/{pk}
|
||||
.delete(api::delete_image), // DELETE /api/covers/{pk}
|
||||
)
|
||||
// Action spécifique
|
||||
.route(
|
||||
"/images/consolidate",
|
||||
post(api::consolidate_cache), // POST /api/covers/consolidate
|
||||
)
|
||||
.with_state(cache.clone());
|
||||
|
||||
// Documentation OpenAPI via Utoipa
|
||||
let openapi = crate::ApiDoc::openapi();
|
||||
|
||||
// Enregistrer l'API avec Swagger UI
|
||||
// /api/covers/images... et /swagger-ui/covers
|
||||
self.add_openapi(api_router, openapi, "covers").await;
|
||||
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
async fn init_cover_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
|
||||
let config = pmoconfig::get_config();
|
||||
|
||||
let cache_dir = config.get_cover_cache_dir()?;
|
||||
let limit = config.get_cover_cache_size()?;
|
||||
|
||||
info!("cache directory {}, size {}",cache_dir,limit);
|
||||
|
||||
self.init_cover_cache(&cache_dir, limit).await
|
||||
}
|
||||
}
|
||||
61
pmocovers/src/webp.rs
Normal file
61
pmocovers/src/webp.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use anyhow::Result;
|
||||
use image::{DynamicImage, imageops::FilterType};
|
||||
use webp::{Encoder, WebPMemory};
|
||||
|
||||
pub fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>> {
|
||||
let rgb_img = img.to_rgba8();
|
||||
let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height());
|
||||
let webp_data: WebPMemory = encoder.encode(85.0);
|
||||
Ok(webp_data.to_vec())
|
||||
}
|
||||
|
||||
pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage {
|
||||
let (width, height) = (img.width(), img.height());
|
||||
|
||||
// Calculer le ratio de mise à l'échelle
|
||||
let scale = if width > height {
|
||||
size as f32 / width as f32
|
||||
} else {
|
||||
size as f32 / height as f32
|
||||
};
|
||||
|
||||
let new_width = (width as f32 * scale) as u32;
|
||||
let new_height = (height as f32 * scale) as u32;
|
||||
|
||||
// Redimensionner l'image
|
||||
let resized = img.resize(new_width, new_height, FilterType::Lanczos3);
|
||||
|
||||
// Créer une image carrée avec fond transparent
|
||||
let mut square = DynamicImage::new_rgba8(size, size);
|
||||
|
||||
// Calculer la position pour centrer l'image redimensionnée
|
||||
let x = (size - new_width) / 2;
|
||||
let y = (size - new_height) / 2;
|
||||
|
||||
// Copier l'image redimensionnée au centre du carré
|
||||
image::imageops::overlay(&mut square, &resized, x.into(), y.into());
|
||||
|
||||
square
|
||||
}
|
||||
|
||||
pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result<Vec<u8>> {
|
||||
let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size));
|
||||
|
||||
if variant_path.exists() {
|
||||
return Ok(tokio::fs::read(variant_path).await?);
|
||||
}
|
||||
|
||||
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
|
||||
|
||||
// Charger l'image de manière synchrone (image::open n'est pas async)
|
||||
let img = tokio::task::spawn_blocking(move || {
|
||||
image::open(orig_path)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let square = ensure_square(&img, size as u32);
|
||||
let webp_data = encode_webp(&square)?;
|
||||
|
||||
tokio::fs::write(&variant_path, &webp_data).await?;
|
||||
Ok(webp_data)
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()`
|
||||
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
||||
|
||||
use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse};
|
||||
use axum::handler::Handler;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::get;
|
||||
@@ -25,7 +26,6 @@ use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||
use tracing::info;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
use crate::logs::{LogState, LoggingOptions, init_logging, log_sse, log_dump};
|
||||
|
||||
/// Info serveur sérialisable
|
||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||
@@ -321,9 +321,7 @@ impl Server {
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let route = Router::new()
|
||||
.route("/", get(handler))
|
||||
.with_state(state);
|
||||
let route = Router::new().route("/", get(handler)).with_state(state);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
@@ -391,14 +389,16 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une API documentée avec OpenAPI
|
||||
/// Ajoute une API documentée avec OpenAPI et Swagger UI
|
||||
///
|
||||
/// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui`
|
||||
/// Cette méthode fusionne le `api_router` fourni avec le router principal du serveur.
|
||||
/// Chaque appel peut ajouter une nouvelle API distincte, avec sa propre documentation Swagger.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `api_router` - Router Axum contenant les routes API
|
||||
/// * `openapi` - Spécification OpenAPI générée par utoipa
|
||||
/// * `openapi` - Spécification OpenAPI générée par `utoipa`
|
||||
/// * `name` - Nom unique pour cette API, utilisé pour différencier le chemin Swagger UI et le JSON OpenAPI
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
@@ -418,7 +418,7 @@ impl Server {
|
||||
/// paths(get_users),
|
||||
/// components(schemas(User))
|
||||
/// )]
|
||||
/// struct ApiDoc;
|
||||
/// struct ApiDoc1;
|
||||
///
|
||||
/// #[utoipa::path(
|
||||
/// get,
|
||||
@@ -429,24 +429,64 @@ impl Server {
|
||||
/// Json(vec![])
|
||||
/// }
|
||||
///
|
||||
/// let api_router = Router::new()
|
||||
/// .route("/users", get(get_users));
|
||||
/// #[derive(utoipa::OpenApi)]
|
||||
/// #[openapi(
|
||||
/// paths(get_products),
|
||||
/// components(schemas(Product))
|
||||
/// )]
|
||||
/// struct ApiDoc2;
|
||||
///
|
||||
/// server.add_openapi(api_router, ApiDoc::openapi()).await;
|
||||
/// #[utoipa::path(
|
||||
/// get,
|
||||
/// path = "/products",
|
||||
/// responses((status = 200, description = "List products"))
|
||||
/// )]
|
||||
/// async fn get_products() -> Json<Vec<Product>> {
|
||||
/// Json(vec![])
|
||||
/// }
|
||||
///
|
||||
/// let api_router1 = Router::new().route("/users", get(get_users));
|
||||
/// let api_router2 = Router::new().route("/products", get(get_products));
|
||||
///
|
||||
/// // Ajouter les deux API au serveur, chacune avec son nom unique
|
||||
/// server.add_openapi(api_router1, ApiDoc1::openapi(), "api1").await;
|
||||
/// server.add_openapi(api_router2, ApiDoc2::openapi(), "api2").await;
|
||||
/// ```
|
||||
pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) {
|
||||
// Stocker le routeur API
|
||||
///
|
||||
/// Résultat :
|
||||
///
|
||||
/// - `/users` et `/products` sont accessibles via Axum.
|
||||
/// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante.
|
||||
/// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spécifications OpenAPI respectives.
|
||||
|
||||
pub async fn add_openapi(
|
||||
&mut self,
|
||||
api_router: Router,
|
||||
openapi: utoipa::openapi::OpenApi,
|
||||
name: &str, // nom unique pour différencier Swagger et OpenAPI
|
||||
) {
|
||||
use axum::routing::get;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
// Stocker le router API dans self.api_router si tu veux y accéder plus tard
|
||||
let mut api_r = self.api_router.write().await;
|
||||
*api_r = Some(api_router);
|
||||
*api_r = Some(api_router.clone());
|
||||
|
||||
// Ajouter Swagger UI
|
||||
let swagger = SwaggerUi::new("/swagger-ui")
|
||||
.url("/api-docs/openapi.json", openapi);
|
||||
// Générer des chemins uniques pour Swagger UI et OpenAPI JSON
|
||||
let swagger_path = format!("/swagger-ui/{}", name);
|
||||
let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str());
|
||||
|
||||
let openapi_json_path = format!("/api-docs/{}.json", name);
|
||||
let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str());
|
||||
|
||||
let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi);
|
||||
|
||||
// Fusionner avec le router principal
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).merge(swagger);
|
||||
let mut combined = std::mem::take(&mut *r);
|
||||
combined = combined.merge(api_router).merge(swagger);
|
||||
*r = combined;
|
||||
}
|
||||
|
||||
/// Démarre le serveur HTTP
|
||||
///
|
||||
/// Lance le serveur sur le port configuré et met en place la gestion
|
||||
@@ -465,7 +505,10 @@ impl Server {
|
||||
/// ```
|
||||
pub async fn start(&mut self) {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port));
|
||||
info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port);
|
||||
info!(
|
||||
"Server {} running at [http://{}:{}](http://{}:{})",
|
||||
self.name, self.base_url, self.http_port, self.base_url, self.http_port
|
||||
);
|
||||
|
||||
// Merger le routeur API si présent
|
||||
let api_router = self.api_router.read().await;
|
||||
@@ -545,8 +588,10 @@ impl Server {
|
||||
let log_state = init_logging(options);
|
||||
|
||||
// Enregistrer automatiquement les routes de logging
|
||||
self.add_handler_with_state("/log-sse", log_sse, log_state.clone()).await;
|
||||
self.add_handler_with_state("/log-dump", log_dump, log_state.clone()).await;
|
||||
self.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
||||
.await;
|
||||
self.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
||||
.await;
|
||||
|
||||
self.log_state = Some(log_state);
|
||||
}
|
||||
@@ -580,7 +625,7 @@ impl ServerBuilder {
|
||||
Self {
|
||||
name: "PMO-Music-Server".to_string(),
|
||||
base_url: config.get_base_url(),
|
||||
http_port: config.get_http_port()
|
||||
http_port: config.get_http_port(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user