[feat] Ajout d'un cache centralisé pour les images de couverture
- Nouveau fichier imageCache.ts : service singleton gérant le cache mémoire, les subscriptions et la gestion centralisée des retries - Refonte de useCoverImage.ts pour utiliser le nouveau cache, avec support du caching serveur (/api/covers) et backoff exponentiel - Ajout d'une version simplifiée useCover() pour les cas simples
This commit is contained in:
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Cache centralisé pour les images de couvertures
|
||||
*
|
||||
* Ce service fournit:
|
||||
* - Cache mémoire pour les états de chargement (évite les requêtes doubles)
|
||||
* - Intégration avec le cache serveur (/api/covers)
|
||||
* - Gestion centralisée des retries
|
||||
* - Subscription aux changements d'état (plusieurs composants partagent le même état)
|
||||
*/
|
||||
|
||||
import { ref, computed, onUnmounted, watch, type Ref } from 'vue';
|
||||
|
||||
// Types pour le cache
|
||||
export interface ImageCacheEntry {
|
||||
url: string;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
loading: boolean;
|
||||
retryCount: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCacheOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
useServerCache?: boolean;
|
||||
}
|
||||
|
||||
// Singleton - état du cache global
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, ImageCacheEntry>();
|
||||
private subscriptions = new Map<string, Set<(entry: ImageCacheEntry) => void>>();
|
||||
private options: ImageCacheOptions = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
useServerCache: true,
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
getOrCreate(url: string | null | undefined): ImageCacheEntry | null {
|
||||
if (!url) return null;
|
||||
|
||||
const normalizedUrl = url.replace(/[?&]_cb=[^&]*/, '');
|
||||
const cacheKey = normalizedUrl;
|
||||
|
||||
if (!this.cache.has(cacheKey)) {
|
||||
this.cache.set(cacheKey, {
|
||||
url: normalizedUrl,
|
||||
loaded: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
retryCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
subscribe(url: string | null | undefined, callback: (entry: ImageCacheEntry) => void): () => void {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return () => {};
|
||||
|
||||
const normalizedUrl = entry.url;
|
||||
|
||||
if (!this.subscriptions.has(normalizedUrl)) {
|
||||
this.subscriptions.set(normalizedUrl, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(normalizedUrl)!.add(callback);
|
||||
callback(entry);
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(normalizedUrl);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(normalizedUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startLoading(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markLoaded(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loaded = true;
|
||||
entry.error = false;
|
||||
entry.loading = false;
|
||||
entry.retryCount = 0;
|
||||
entry.lastError = null;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markError(url: string | null | undefined, error: string) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
entry.lastError = error;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
shouldRetry(url: string | null | undefined): boolean {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return false;
|
||||
|
||||
if (entry.retryCount >= (this.options.maxRetries ?? 5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.retryCount++;
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
getRetryDelay(retryCount: number): number {
|
||||
const baseDelay = this.options.retryDelay ?? 500;
|
||||
return baseDelay * Math.pow(2, retryCount - 1);
|
||||
}
|
||||
|
||||
private notifySubscribers(url: string) {
|
||||
const entry = this.cache.get(url);
|
||||
if (!entry) return;
|
||||
|
||||
const subs = this.subscriptions.get(url);
|
||||
if (subs) {
|
||||
subs.forEach(callback => {
|
||||
try {
|
||||
callback(entry);
|
||||
} catch (e) {
|
||||
console.error('[ImageCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.cache.forEach((entry, url) => {
|
||||
const hasSubs = this.subscriptions.has(url);
|
||||
if (!hasSubs && entry.loaded) {
|
||||
toDelete.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach(url => this.cache.delete(url));
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let loaded = 0;
|
||||
let loading = 0;
|
||||
let error = 0;
|
||||
let pending = 0;
|
||||
|
||||
this.cache.forEach(entry => {
|
||||
if (entry.loaded) loaded++;
|
||||
else if (entry.loading) loading++;
|
||||
else if (entry.error) error++;
|
||||
else pending++;
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
pending,
|
||||
subscribers: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const imageCache = new ImageCacheService();
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le cache d'images de manière reactive
|
||||
*/
|
||||
export function useImageCache(imageUrl: Ref<string | null | undefined>) {
|
||||
const entry = ref<ImageCacheEntry | null>(null);
|
||||
const cleanup = ref<(() => void) | null>(null);
|
||||
|
||||
const loading = computed(() => entry.value?.loading ?? false);
|
||||
const loaded = computed(() => entry.value?.loaded ?? false);
|
||||
const error = computed(() => entry.value?.error ?? false);
|
||||
const lastError = computed(() => entry.value?.lastError ?? null);
|
||||
const retryCount = computed(() => entry.value?.retryCount ?? 0);
|
||||
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUrl) => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
cleanup.value = imageCache.subscribe(newUrl, (newEntry) => {
|
||||
entry.value = newEntry;
|
||||
});
|
||||
} else {
|
||||
entry.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.startLoading(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastError,
|
||||
retryCount,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user