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

@@ -67,11 +67,16 @@ COPY --from=builder /app/next-env.d.ts ./
COPY --from=builder /app/tailwind.config.ts ./ COPY --from=builder /app/tailwind.config.ts ./
COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/scripts ./scripts
# Copy entrypoint script
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
# Add non-root user for security # Add non-root user for security
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \ adduser --system --uid 1001 nextjs && \
mkdir -p /app/.cache && \ mkdir -p /app/.cache && \
chown -R nextjs:nodejs /app /app/.cache chown -R nextjs:nodejs /app /app/.cache && \
chown nextjs:nodejs docker-entrypoint.sh
USER nextjs USER nextjs
@@ -86,5 +91,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \ HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start the application (init DB then start) # Start the application (push schema, init DB, then start)
CMD ["pnpm", "start:prod"] CMD ["./docker-entrypoint.sh"]

12
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
echo "🔄 Pushing Prisma schema to database..."
npx prisma db push --skip-generate --accept-data-loss
echo "🔧 Initializing database..."
node scripts/init-db.mjs
echo "🚀 Starting application..."
exec pnpm start

View File

@@ -43,16 +43,17 @@ model KomgaConfig {
} }
model TTLConfig { model TTLConfig {
id String @id @default(auto()) @map("_id") @db.ObjectId id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @unique userId String @unique
defaultTTL Int @default(5) defaultTTL Int @default(5)
homeTTL Int @default(5) homeTTL Int @default(5)
librariesTTL Int @default(1440) librariesTTL Int @default(1440)
seriesTTL Int @default(5) seriesTTL Int @default(5)
booksTTL Int @default(5) booksTTL Int @default(5)
imagesTTL Int @default(1440) imagesTTL Int @default(1440)
createdAt DateTime @default(now()) imageCacheMaxAge Int @default(2592000) // 30 jours en secondes
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

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

View File

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

View File

@@ -3,12 +3,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useToast } from "@/components/ui/use-toast"; 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 { CacheModeSwitch } from "@/components/settings/CacheModeSwitch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { TTLConfigData } from "@/types/komga"; import type { TTLConfigData } from "@/types/komga";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useImageCache } from "@/contexts/ImageCacheContext";
interface CacheSettingsProps { interface CacheSettingsProps {
initialTTLConfig: TTLConfigData | null; initialTTLConfig: TTLConfigData | null;
@@ -35,6 +36,7 @@ interface ServiceWorkerCacheEntry {
export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const { flushImageCache } = useImageCache();
const [isCacheClearing, setIsCacheClearing] = useState(false); const [isCacheClearing, setIsCacheClearing] = useState(false);
const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false); const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false);
const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null); const [serverCacheSize, setServerCacheSize] = useState<CacheSizeInfo | null>(null);
@@ -56,6 +58,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
seriesTTL: 5, seriesTTL: 5,
booksTTL: 5, booksTTL: 5,
imagesTTL: 1440, 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; const { name, value } = event.target;
setTTLConfig((prev) => ({ setTTLConfig((prev) => ({
...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" 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>
<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>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
@@ -829,6 +864,16 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
)} )}
</button> </button>
</div> </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> </form>
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

@@ -20,6 +20,10 @@ export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries; 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) { export function getImageUrl(type: "series" | "book", id: string) {
if (type === "series") { if (type === "series") {
return `/api/komga/images/series/${id}/thumbnail`; return `/api/komga/images/series/${id}/thumbnail`;

View File

@@ -4,6 +4,7 @@ import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils"; import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "./cover-utils"; import { getImageUrl } from "./cover-utils";
import { useImageUrl } from "@/hooks/useImageUrl";
export function SeriesCover({ export function SeriesCover({
series, series,
@@ -11,7 +12,8 @@ export function SeriesCover({
className, className,
showProgressUi = true, showProgressUi = true,
}: SeriesCoverProps) { }: SeriesCoverProps) {
const imageUrl = getImageUrl("series", series.id); const baseUrl = getImageUrl("series", series.id);
const imageUrl = useImageUrl(baseUrl);
const isCompleted = series.booksCount === series.booksReadCount; const isCompleted = series.booksCount === series.booksReadCount;
const readBooks = 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", "libraries": "Libraries TTL",
"series": "Series TTL", "series": "Series TTL",
"books": "Books 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": { "buttons": {
"saveTTL": "Save TTL", "saveTTL": "Save TTL",
"clear": "Clear cache", "clear": "Clear cache",
"clearing": "Clearing...", "clearing": "Clearing...",
"clearServiceWorker": "Clear service worker cache", "clearServiceWorker": "Clear service worker cache",
"clearingServiceWorker": "Clearing service worker cache..." "clearingServiceWorker": "Clearing service worker cache...",
"flushImageCache": "Force reload images"
}, },
"messages": { "messages": {
"ttlSaved": "TTL configuration saved successfully", "ttlSaved": "TTL configuration saved successfully",
"cleared": "Server cache cleared 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": { "error": {
"title": "Error clearing cache", "title": "Error clearing cache",

View File

@@ -145,19 +145,33 @@
"libraries": "TTL bibliothèques", "libraries": "TTL bibliothèques",
"series": "TTL séries", "series": "TTL séries",
"books": "TTL tomes", "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": { "buttons": {
"saveTTL": "Sauvegarder les TTL", "saveTTL": "Sauvegarder les TTL",
"clear": "Vider le cache", "clear": "Vider le cache",
"clearing": "Suppression...", "clearing": "Suppression...",
"clearServiceWorker": "Vider le cache du service worker", "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": { "messages": {
"ttlSaved": "La configuration des TTL a été sauvegardée avec succès", "ttlSaved": "La configuration des TTL a été sauvegardée avec succès",
"cleared": "Cache serveur supprimé 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": { "error": {
"title": "Erreur lors de la suppression du cache", "title": "Erreur lors de la suppression du cache",

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library"; 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 { BookService } from "./book.service";
import type { ImageResponse } from "./image.service"; import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { getServerCacheService } from "./server-cache.service"; import { getServerCacheService } from "./server-cache.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
@@ -12,6 +13,16 @@ import type { UserPreferences } from "@/types/preferences";
import type { ServerCacheService } from "./server-cache.service"; import type { ServerCacheService } from "./server-cache.service";
export class SeriesService extends BaseApiService { 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> { static async getSeries(seriesId: string): Promise<KomgaSeries> {
try { try {
return this.fetchWithCache<KomgaSeries>( return this.fetchWithCache<KomgaSeries>(
@@ -179,6 +190,7 @@ export class SeriesService extends BaseApiService {
try { try {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences(); const preferences: UserPreferences = await PreferencesService.getPreferences();
const maxAge = await this.getImageCacheMaxAge();
// Si l'utilisateur préfère les vignettes, utiliser la miniature // Si l'utilisateur préfère les vignettes, utiliser la miniature
if (preferences.showThumbnails) { if (preferences.showThumbnails) {
@@ -186,7 +198,7 @@ export class SeriesService extends BaseApiService {
return new Response(response.buffer.buffer as ArrayBuffer, { return new Response(response.buffer.buffer as ArrayBuffer, {
headers: { headers: {
"Content-Type": response.contentType || "image/jpeg", "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; seriesTTL: number;
booksTTL: number; booksTTL: number;
imagesTTL: number; imagesTTL: number;
imageCacheMaxAge: number; // en secondes
} }
export interface TTLConfig extends TTLConfigData { export interface TTLConfig extends TTLConfigData {