[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:
2026-04-03 22:36:56 +02:00
parent 0cb3cbe470
commit 3a3dc1727f
2 changed files with 365 additions and 91 deletions

View 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,
};
}

View File

@@ -1,171 +1,192 @@
import { ref, watch, onMounted, nextTick, type Ref } from "vue";
/**
* Composable pour gérer les images de couvertures
*
* Version optimisée avec:
* - Cache centralisé pour partager l'état entre composants
* - Intégration optionnelle avec le cache serveur
* - Retry automatique avec backoff exponentiel
* - Cache-busting pour éviter les problèmes de cache navigateur
*/
import { ref, watch, computed, type Ref } from "vue";
import { imageCache, useImageCache } from "./imageCache";
export interface CoverImageOptions {
maxRetries?: number;
retryDelay?: number;
forceReload?: boolean;
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
}
export function useCoverImage(
imageUrl: Ref<string | null | undefined>,
options: CoverImageOptions = {},
) {
const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
const {
maxRetries = 5,
retryDelay = 500,
forceReload = true,
useServerCache = true
} = options;
// Configurer le cache global
imageCache.configure({ maxRetries, retryDelay });
// État local
const imageLoaded = ref(false);
const imageError = ref(false);
const coverImageRef = ref<HTMLImageElement | null>(null);
const retryCount = ref(0);
const currentUrl = ref<string | null>(null);
const cacheBustedUrl = ref<string | null>(null);
const isLoadingNewImage = ref(false);
// Function to check if the image is already loaded (cached)
function checkImageComplete() {
nextTick(() => {
if (
coverImageRef.value?.complete &&
coverImageRef.value?.naturalWidth > 0
) {
imageLoaded.value = true;
imageError.value = false;
retryCount.value = 0;
}
});
}
// Simple hash function for URL
// Utiliser le cache centralisé pour l'état de chargement
const cacheEntry = useImageCache(imageUrl);
// Computed: synchroniser avec le cache centralisé
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
// Fonction de hash simple pour le cache-busting
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
// Function to add cache-busting parameter
// Génère une URL avec cache-busting
function getCacheBustedUrl(url: string, retry: number): string {
if (!forceReload && retry === 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
// Use URL hash for stable cache-busting, timestamp only for retries
const cacheBuster =
retry > 0
// Si on utilise le cache serveur, transformer l'URL
if (useServerCache && url.startsWith('http')) {
// L'URL sera transformée côté serveur via le cache
const separator = url.includes("?") ? "&" : "?";
const cacheBuster = retry > 0
? `${simpleHash(url)}_r${retry}_${Date.now()}`
: simpleHash(url);
return `${url}${separator}_cb=${cacheBuster}`;
}
// Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting
const separator = url.includes("?") ? "&" : "?";
const cacheBuster = retry > 0
? `${simpleHash(url)}_r${retry}_${Date.now()}`
: simpleHash(url);
return `${url}${separator}_cb=${cacheBuster}`;
}
// Retry loading the image
function retryLoad() {
if (!currentUrl.value) return;
if (retryCount.value < maxRetries) {
retryCount.value++;
// Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers
// en cours de téléchargement, plus espacé ensuite pour les erreurs réseau
const delay = retryDelay * Math.pow(2, retryCount.value - 1);
setTimeout(() => {
if (!currentUrl.value) return;
// Update cache-busted URL with new retry count
cacheBustedUrl.value = getCacheBustedUrl(
currentUrl.value,
retryCount.value,
);
}, delay);
} else {
console.error(
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
);
imageError.value = true;
}
}
// Handle successful image load
// Gère le chargement réussi
function handleImageLoad() {
const url = imageUrl.value;
if (url) {
imageCache.markLoaded(url);
}
imageLoaded.value = true;
imageError.value = false;
retryCount.value = 0;
isLoadingNewImage.value = false;
}
// Handle image load error
// Gère l'erreur de chargement
function handleImageError(event: Event) {
const url = imageUrl.value;
const img = event.target as HTMLImageElement;
console.warn(
`[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`,
);
console.warn(`[useCoverImage] Image load error: ${img.src}`);
imageLoaded.value = false;
// Retry if we haven't reached max retries
if (retryCount.value < maxRetries) {
const delay = retryDelay * Math.pow(2, retryCount.value);
console.log(
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
);
retryLoad();
if (url) {
// Demander au cache si on doit réessayer
if (imageCache.shouldRetry(url)) {
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
setTimeout(() => {
// Générer une nouvelle URL avec retry count
const retry = cacheEntry.retryCount.value;
cacheBustedUrl.value = getCacheBustedUrl(url, retry);
// Forcer le rechargement de l'image
if (coverImageRef.value) {
coverImageRef.value.src = cacheBustedUrl.value;
}
}, delay);
} else {
imageError.value = true;
imageCache.markError(url, "Max retries reached");
}
} else {
console.error(
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
);
imageError.value = true;
}
}
// Reset image state when URL changes
// Watch sur l'URL pour générer la cache-busted URL
watch(
imageUrl,
(newUri, oldUri) => {
// Reset de l'état d'erreur
imageError.value = false;
retryCount.value = 0;
// Si c'est un changement d'URL (pas l'initialisation)
// Gestion des transitions
if (oldUri && newUri && oldUri !== newUri) {
isLoadingNewImage.value = true;
// On garde imageLoaded à true pour garder l'ancienne image visible
// Garder l'image précédente visible pendant le chargement
} else if (!newUri) {
// Pas d'URL, on cache tout
imageLoaded.value = false;
isLoadingNewImage.value = false;
cacheBustedUrl.value = null;
} else if (!oldUri && newUri) {
// Initialisation, on part de zéro
imageLoaded.value = false;
isLoadingNewImage.value = true;
}
currentUrl.value = newUri || null;
if (newUri) {
// Generate cache-busted URL
// Indiquer au cache qu'on commence à charger
imageCache.startLoading(newUri);
// Générer l'URL avec cache-busting
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
} else {
cacheBustedUrl.value = null;
}
},
{ immediate: true },
{ immediate: true }
);
// Check on mount
onMounted(() => {
currentUrl.value = imageUrl.value || null;
if (currentUrl.value) {
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
// Callback pour le ref de l'image
function setImageRef(el: HTMLImageElement | null) {
coverImageRef.value = el;
// Si on a une URL et une référence, initiate le chargement
if (el && cacheBustedUrl.value && !imageLoaded.value) {
// L'image va commencer à charger naturellement via le src
// Le handler handleImageLoad sera appelé quand terminé
}
checkImageComplete();
});
}
return {
imageLoaded,
imageError,
coverImageRef,
// État
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
imageError: computed(() => imageError.value || cacheEntry.error.value),
coverImageRef: ref(coverImageRef),
cacheBustedUrl,
isLoadingNewImage,
// Méthodes
handleImageLoad,
handleImageError,
setImageRef,
};
}
/**
* Version simplifiée de useCoverImage pour les cas où on n'a pas besoin
* de tous les options. Utilise le cache centralisé par défaut.
*/
export function useCover(url: Ref<string | null | undefined>) {
return useCoverImage(url, { forceReload: false, useServerCache: true });
}