feat: implement image caching mechanism with configurable cache duration and flush functionality

This commit is contained in:
Julien Froidefond
2025-10-19 10:36:19 +02:00
parent 7d9bac5c51
commit 0c080bd525
17 changed files with 268 additions and 60 deletions

View File

@@ -52,6 +52,7 @@ export async function POST(request: NextRequest) {
seriesTTL: config.seriesTTL,
booksTTL: config.booksTTL,
imagesTTL: config.imagesTTL,
imageCacheMaxAge: config.imageCacheMaxAge,
},
});
} catch (error) {

View File

@@ -10,6 +10,7 @@ import { usePathname } from "next/navigation";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext";
import { ImageCacheProvider } from "@/contexts/ImageCacheContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
// Routes qui ne nécessitent pas d'authentification
@@ -135,38 +136,40 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && (
<ImageCacheProvider>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && (
<div
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
<div
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
<div
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined}
>
{!isPublicRoute && (
<Header
onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"}
/>
)}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined}
>
{!isPublicRoute && (
<Header
onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"}
/>
)}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
</ImageCacheProvider>
</ThemeProvider>
);
}

View File

@@ -3,12 +3,13 @@
import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { useToast } from "@/components/ui/use-toast";
import { Trash2, Loader2, HardDrive, List, ChevronDown, ChevronUp } from "lucide-react";
import { Trash2, Loader2, HardDrive, List, ChevronDown, ChevronUp, ImageOff } from "lucide-react";
import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch";
import { Label } from "@/components/ui/label";
import type { TTLConfigData } from "@/types/komga";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useImageCache } from "@/contexts/ImageCacheContext";
interface CacheSettingsProps {
initialTTLConfig: TTLConfigData | null;
@@ -35,6 +36,7 @@ interface ServiceWorkerCacheEntry {
export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
const { t } = useTranslate();
const { toast } = useToast();
const { flushImageCache } = useImageCache();
const [isCacheClearing, setIsCacheClearing] = useState(false);
const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false);
const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null);
@@ -56,6 +58,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
seriesTTL: 5,
booksTTL: 5,
imagesTTL: 1440,
imageCacheMaxAge: 2592000,
}
);
@@ -389,7 +392,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
}
};
const handleTTLChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFlushImageCache = () => {
flushImageCache();
toast({
title: t("settings.cache.title"),
description: t("settings.cache.messages.imageCacheFlushed"),
});
};
const handleTTLChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = event.target;
setTTLConfig((prev) => ({
...prev,
@@ -788,6 +799,30 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="space-y-2">
<div className="space-y-1">
<label htmlFor="imageCacheMaxAge" className="text-sm font-medium">
{t("settings.cache.ttl.imageCacheMaxAge.label")}
</label>
<p className="text-xs text-muted-foreground">
{t("settings.cache.ttl.imageCacheMaxAge.description")}
</p>
</div>
<select
id="imageCacheMaxAge"
name="imageCacheMaxAge"
value={ttlConfig.imageCacheMaxAge}
onChange={handleTTLChange}
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option>
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</option>
<option value="86400">{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}</option>
<option value="604800">{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}</option>
<option value="2592000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}</option>
<option value="31536000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}</option>
</select>
</div>
</div>
<div className="flex gap-3">
<button
@@ -829,6 +864,16 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
)}
</button>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={handleFlushImageCache}
className="flex-1 inline-flex items-center justify-center rounded-md bg-orange-500/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-white ring-offset-background transition-colors hover:bg-orange-500/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<ImageOff className="mr-2 h-4 w-4" />
{t("settings.cache.buttons.flushImageCache")}
</button>
</div>
</form>
</CardContent>
</Card>

View File

@@ -4,6 +4,7 @@ import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "./cover-utils";
import { useImageUrl } from "@/hooks/useImageUrl";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { MarkAsReadButton } from "./mark-as-read-button";
import { MarkAsUnreadButton } from "./mark-as-unread-button";
@@ -59,7 +60,8 @@ export function BookCover({
}: BookCoverProps) {
const { t } = useTranslate();
const imageUrl = getImageUrl("book", book.id);
const baseUrl = getImageUrl("book", book.id);
const imageUrl = useImageUrl(baseUrl);
const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book);

View File

@@ -20,6 +20,10 @@ export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries;
}
/**
* Génère l'URL de base pour une image (sans cache version)
* Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting
*/
export function getImageUrl(type: "series" | "book", id: string) {
if (type === "series") {
return `/api/komga/images/series/${id}/thumbnail`;

View File

@@ -4,6 +4,7 @@ import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "./cover-utils";
import { useImageUrl } from "@/hooks/useImageUrl";
export function SeriesCover({
series,
@@ -11,7 +12,8 @@ export function SeriesCover({
className,
showProgressUi = true,
}: SeriesCoverProps) {
const imageUrl = getImageUrl("series", series.id);
const baseUrl = getImageUrl("series", series.id);
const imageUrl = useImageUrl(baseUrl);
const isCompleted = series.booksCount === series.booksReadCount;
const readBooks = series.booksReadCount;

View File

@@ -0,0 +1,59 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
interface ImageCacheContextType {
cacheVersion: string;
flushImageCache: () => void;
getImageUrl: (baseUrl: string) => string;
}
const ImageCacheContext = createContext<ImageCacheContextType | undefined>(undefined);
export function ImageCacheProvider({ children }: { children: React.ReactNode }) {
const [cacheVersion, setCacheVersion] = useState<string>("");
// Initialiser la version depuis localStorage au montage
useEffect(() => {
const storedVersion = localStorage.getItem("imageCacheVersion");
if (storedVersion) {
setCacheVersion(storedVersion);
} else {
const newVersion = Date.now().toString();
setCacheVersion(newVersion);
localStorage.setItem("imageCacheVersion", newVersion);
}
}, []);
const flushImageCache = useCallback(() => {
const newVersion = Date.now().toString();
setCacheVersion(newVersion);
localStorage.setItem("imageCacheVersion", newVersion);
// eslint-disable-next-line no-console
console.log("🗑️ Image cache flushed - new version:", newVersion);
}, []);
const getImageUrl = useCallback(
(baseUrl: string) => {
if (!cacheVersion) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}v=${cacheVersion}`;
},
[cacheVersion]
);
return (
<ImageCacheContext.Provider value={{ cacheVersion, flushImageCache, getImageUrl }}>
{children}
</ImageCacheContext.Provider>
);
}
export function useImageCache() {
const context = useContext(ImageCacheContext);
if (context === undefined) {
throw new Error("useImageCache must be used within an ImageCacheProvider");
}
return context;
}

15
src/hooks/useImageUrl.ts Normal file
View File

@@ -0,0 +1,15 @@
import { useImageCache } from "@/contexts/ImageCacheContext";
import { useMemo } from "react";
/**
* Hook pour obtenir une URL d'image avec cache busting
* Ajoute automatiquement ?v={cacheVersion} à l'URL
*/
export function useImageUrl(baseUrl: string): string {
const { getImageUrl } = useImageCache();
return useMemo(() => {
return getImageUrl(baseUrl);
}, [baseUrl, getImageUrl]);
}

View File

@@ -145,19 +145,33 @@
"libraries": "Libraries TTL",
"series": "Series TTL",
"books": "Books TTL",
"images": "Images TTL"
"images": "Images TTL",
"imageCacheMaxAge": {
"label": "HTTP Image Cache Duration",
"description": "Duration for images to be cached in the browser",
"options": {
"noCache": "No cache (0s)",
"oneHour": "1 hour (3600s)",
"oneDay": "1 day (86400s)",
"oneWeek": "1 week (604800s)",
"oneMonth": "1 month (2592000s) - Recommended",
"oneYear": "1 year (31536000s)"
}
}
},
"buttons": {
"saveTTL": "Save TTL",
"clear": "Clear cache",
"clearing": "Clearing...",
"clearServiceWorker": "Clear service worker cache",
"clearingServiceWorker": "Clearing service worker cache..."
"clearingServiceWorker": "Clearing service worker cache...",
"flushImageCache": "Force reload images"
},
"messages": {
"ttlSaved": "TTL configuration saved successfully",
"cleared": "Server cache cleared successfully",
"serviceWorkerCleared": "Service worker cache cleared successfully"
"serviceWorkerCleared": "Service worker cache cleared successfully",
"imageCacheFlushed": "Images will be reloaded - refresh the page"
},
"error": {
"title": "Error clearing cache",

View File

@@ -145,19 +145,33 @@
"libraries": "TTL bibliothèques",
"series": "TTL séries",
"books": "TTL tomes",
"images": "TTL images"
"images": "TTL images",
"imageCacheMaxAge": {
"label": "Durée du cache HTTP des images",
"description": "Durée de conservation des images dans le cache du navigateur",
"options": {
"noCache": "Aucun cache (0s)",
"oneHour": "1 heure (3600s)",
"oneDay": "1 jour (86400s)",
"oneWeek": "1 semaine (604800s)",
"oneMonth": "1 mois (2592000s) - Recommandé",
"oneYear": "1 an (31536000s)"
}
}
},
"buttons": {
"saveTTL": "Sauvegarder les TTL",
"clear": "Vider le cache",
"clearing": "Suppression...",
"clearServiceWorker": "Vider le cache du service worker",
"clearingServiceWorker": "Suppression du cache service worker..."
"clearingServiceWorker": "Suppression du cache service worker...",
"flushImageCache": "Forcer le rechargement des images"
},
"messages": {
"ttlSaved": "La configuration des TTL a été sauvegardée avec succès",
"cleared": "Cache serveur supprimé avec succès",
"serviceWorkerCleared": "Cache du service worker supprimé avec succès"
"serviceWorkerCleared": "Cache du service worker supprimé avec succès",
"imageCacheFlushed": "Les images seront rechargées - rafraîchissez la page"
},
"error": {
"title": "Erreur lors de la suppression du cache",

View File

@@ -1,14 +1,25 @@
import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import { SeriesService } from "./series.service";
import type { Series } from "@/types/series";
export class BookService extends BaseApiService {
private static async getImageCacheMaxAge(): Promise<number> {
try {
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
return maxAge;
} catch (error) {
console.error('[ImageCache] Error fetching TTL config:', error);
return 2592000; // 30 jours par défaut en cas d'erreur
}
}
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
try {
return this.fetchWithCache<KomgaBookWithPages>(
@@ -102,10 +113,12 @@ export class BookService extends BaseApiService {
response.buffer.byteOffset + response.buffer.byteLength
) as ArrayBuffer;
const maxAge = await this.getImageCacheMaxAge();
return new Response(arrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} catch (error) {
@@ -117,6 +130,7 @@ export class BookService extends BaseApiService {
try {
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
const maxAge = await this.getImageCacheMaxAge();
// Si l'utilisateur préfère les vignettes, utiliser la miniature
if (preferences.showThumbnails) {
@@ -124,7 +138,7 @@ export class BookService extends BaseApiService {
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
}
@@ -149,10 +163,12 @@ export class BookService extends BaseApiService {
const response: ImageResponse = await ImageService.getImage(
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
);
const maxAge = await this.getImageCacheMaxAge();
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} catch (error) {

View File

@@ -90,6 +90,7 @@ export class ConfigDBService {
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
imageCacheMaxAge: data.imageCacheMaxAge,
},
create: {
userId: user.id,
@@ -99,6 +100,7 @@ export class ConfigDBService {
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
imageCacheMaxAge: data.imageCacheMaxAge,
},
});

View File

@@ -1,10 +1,11 @@
import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { KomgaBook, KomgaSeries, TTLConfig } from "@/types/komga";
import { BookService } from "./book.service";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { getServerCacheService } from "./server-cache.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
@@ -12,6 +13,16 @@ import type { UserPreferences } from "@/types/preferences";
import type { ServerCacheService } from "./server-cache.service";
export class SeriesService extends BaseApiService {
private static async getImageCacheMaxAge(): Promise<number> {
try {
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
return maxAge;
} catch (error) {
console.error('[ImageCache] Error fetching TTL config:', error);
return 2592000; // 30 jours par défaut en cas d'erreur
}
}
static async getSeries(seriesId: string): Promise<KomgaSeries> {
try {
return this.fetchWithCache<KomgaSeries>(
@@ -179,6 +190,7 @@ export class SeriesService extends BaseApiService {
try {
// Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences();
const maxAge = await this.getImageCacheMaxAge();
// Si l'utilisateur préfère les vignettes, utiliser la miniature
if (preferences.showThumbnails) {
@@ -186,7 +198,7 @@ export class SeriesService extends BaseApiService {
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
}

View File

@@ -22,6 +22,7 @@ export interface TTLConfigData {
seriesTTL: number;
booksTTL: number;
imagesTTL: number;
imageCacheMaxAge: number; // en secondes
}
export interface TTLConfig extends TTLConfigData {