diff --git a/.gitignore b/.gitignore index 5e4a3c5..cbb590b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ next-env.d.ts .idea .vscode +.cache + # Environment variables .env .env.local diff --git a/src/app/api/komga/cache/mode/route.ts b/src/app/api/komga/cache/mode/route.ts new file mode 100644 index 0000000..bd5b925 --- /dev/null +++ b/src/app/api/komga/cache/mode/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { serverCacheService } from "@/lib/services/server-cache.service"; + +export async function GET() { + return NextResponse.json({ mode: serverCacheService.getCacheMode() }); +} + +export async function POST(request: Request) { + try { + const { mode } = await request.json(); + if (mode !== "file" && mode !== "memory") { + return NextResponse.json( + { error: "Invalid mode. Must be 'file' or 'memory'" }, + { status: 400 } + ); + } + + serverCacheService.setCacheMode(mode); + return NextResponse.json({ mode: serverCacheService.getCacheMode() }); + } catch (error) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } +} diff --git a/src/components/settings/CacheModeSwitch.tsx b/src/components/settings/CacheModeSwitch.tsx new file mode 100644 index 0000000..9612c33 --- /dev/null +++ b/src/components/settings/CacheModeSwitch.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { usePreferences } from "@/contexts/PreferencesContext"; + +export function CacheModeSwitch() { + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const { preferences, updatePreferences } = usePreferences(); + + const handleToggle = async (checked: boolean) => { + setIsLoading(true); + try { + // Mettre à jour les préférences + await updatePreferences({ cacheMode: checked ? "memory" : "file" }); + + // Mettre à jour le mode de cache côté serveur + const res = await fetch("/api/komga/cache/mode", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ mode: checked ? "memory" : "file" }), + }); + + if (!res.ok) throw new Error(); + + toast({ + title: "Mode de cache modifié", + description: `Le cache est maintenant en mode ${checked ? "mémoire" : "fichier"}`, + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Erreur", + description: "Impossible de modifier le mode de cache", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/src/components/settings/ClientSettings.tsx b/src/components/settings/ClientSettings.tsx index 228caf8..5da6692 100644 --- a/src/components/settings/ClientSettings.tsx +++ b/src/components/settings/ClientSettings.tsx @@ -8,6 +8,7 @@ import { useToast } from "@/components/ui/use-toast"; import { usePreferences } from "@/contexts/PreferencesContext"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch"; interface KomgaConfig { url: string; @@ -416,6 +417,16 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin

+
+
+ +

+ Le cache en mémoire est plus rapide mais ne persiste pas entre les redémarrages +

+
+ +
+ {/* Formulaire TTL */}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..b117c6f --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index fb57da1..747756b 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -1,7 +1,16 @@ "use client"; import React, { createContext, useContext, useEffect, useState } from "react"; -import { UserPreferences } from "@/lib/services/preferences.service"; + +export interface UserPreferences { + showThumbnails: boolean; + cacheMode: "memory" | "file"; +} + +const defaultPreferences: UserPreferences = { + showThumbnails: true, + cacheMode: "memory", +}; interface PreferencesContextType { preferences: UserPreferences; @@ -12,9 +21,7 @@ interface PreferencesContextType { const PreferencesContext = createContext(undefined); export function PreferencesProvider({ children }: { children: React.ReactNode }) { - const [preferences, setPreferences] = useState({ - showThumbnails: true, - }); + const [preferences, setPreferences] = useState(defaultPreferences); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -26,6 +33,8 @@ export function PreferencesProvider({ children }: { children: React.ReactNode }) setPreferences(data); } catch (error) { console.error("Erreur lors de la récupération des préférences:", error); + // En cas d'erreur, on garde les préférences par défaut + setPreferences(defaultPreferences); } finally { setIsLoading(false); } diff --git a/src/lib/models/preferences.model.ts b/src/lib/models/preferences.model.ts index 238d8f9..bba84ab 100644 --- a/src/lib/models/preferences.model.ts +++ b/src/lib/models/preferences.model.ts @@ -11,6 +11,11 @@ const preferencesSchema = new mongoose.Schema( type: Boolean, default: true, }, + cacheMode: { + type: String, + enum: ["memory", "file"], + default: "memory", + }, }, { timestamps: true, diff --git a/src/lib/services/preferences.service.ts b/src/lib/services/preferences.service.ts index b6b936f..deffcfc 100644 --- a/src/lib/services/preferences.service.ts +++ b/src/lib/services/preferences.service.ts @@ -9,8 +9,14 @@ interface User { export interface UserPreferences { showThumbnails: boolean; + cacheMode: "memory" | "file"; } +const defaultPreferences: UserPreferences = { + showThumbnails: true, + cacheMode: "memory", +}; + export class PreferencesService { private static async getCurrentUser(): Promise { const userCookie = cookies().get("stripUser"); @@ -27,39 +33,46 @@ export class PreferencesService { } } - static async getPreferences(): Promise { - await connectDB(); - const user = await this.getCurrentUser(); - - const preferences = await PreferencesModel.findOne({ userId: user.id }); - if (!preferences) { - // Créer les préférences par défaut si elles n'existent pas - const defaultPreferences = await PreferencesModel.create({ - userId: user.id, - showThumbnails: true, - }); + static async getPreferences(userId: string): Promise { + try { + const preferences = await PreferencesModel.findOne({ userId }); + if (!preferences) { + return { + showThumbnails: true, + cacheMode: "memory", + }; + } return { - showThumbnails: defaultPreferences.showThumbnails, + showThumbnails: preferences.showThumbnails, + cacheMode: preferences.cacheMode || "memory", + }; + } catch (error) { + console.error("Error getting preferences:", error); + return { + showThumbnails: true, + cacheMode: "memory", }; } - - return { - showThumbnails: preferences.showThumbnails, - }; } - static async updatePreferences(preferences: Partial): Promise { - await connectDB(); - const user = await this.getCurrentUser(); + static async updatePreferences( + userId: string, + preferences: Partial + ): Promise { + try { + const updatedPreferences = await PreferencesModel.findOneAndUpdate( + { userId }, + { $set: preferences }, + { new: true, upsert: true } + ); - const updatedPreferences = await PreferencesModel.findOneAndUpdate( - { userId: user.id }, - { $set: preferences }, - { new: true, upsert: true } - ); - - return { - showThumbnails: updatedPreferences.showThumbnails, - }; + return { + showThumbnails: updatedPreferences.showThumbnails, + cacheMode: updatedPreferences.cacheMode || "memory", + }; + } catch (error) { + console.error("Error updating preferences:", error); + throw error; + } } } diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index 838bdf3..537c353 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -1,6 +1,19 @@ +import fs from "fs"; +import path from "path"; + +type CacheMode = "file" | "memory"; + +interface CacheConfig { + mode: CacheMode; +} + class ServerCacheService { private static instance: ServerCacheService; - private cache: Map = new Map(); + private cacheDir: string; + private memoryCache: Map = new Map(); + private config: CacheConfig = { + mode: "memory", + }; // Configuration des temps de cache en millisecondes private static readonly fiveMinutes = 5 * 60 * 1000; @@ -21,7 +34,83 @@ class ServerCacheService { }; private constructor() { - // Private constructor to prevent external instantiation + this.cacheDir = path.join(process.cwd(), ".cache"); + this.ensureCacheDirectory(); + this.cleanExpiredCache(); + } + + private ensureCacheDirectory(): void { + if (!fs.existsSync(this.cacheDir)) { + fs.mkdirSync(this.cacheDir, { recursive: true }); + } + } + + private getCacheFilePath(key: string): string { + // Nettoyer la clé des caractères spéciaux et des doubles slashes + const sanitizedKey = key.replace(/[<>:"|?*]/g, "_").replace(/\/+/g, "/"); + + const filePath = path.join(this.cacheDir, `${sanitizedKey}.json`); + + return filePath; + } + + private cleanExpiredCache(): void { + if (!fs.existsSync(this.cacheDir)) return; + + const cleanDirectory = (dirPath: string): boolean => { + if (!fs.existsSync(dirPath)) return true; + + const items = fs.readdirSync(dirPath); + let isEmpty = true; + + for (const item of items) { + const itemPath = path.join(dirPath, item); + + try { + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + const isSubDirEmpty = cleanDirectory(itemPath); + if (isSubDirEmpty) { + try { + fs.rmdirSync(itemPath); + } catch (_error) { + isEmpty = false; + } + } else { + isEmpty = false; + } + } else if (stats.isFile() && item.endsWith(".json")) { + try { + const content = fs.readFileSync(itemPath, "utf-8"); + const cached = JSON.parse(content); + if (cached.expiry < Date.now()) { + fs.unlinkSync(itemPath); + } else { + isEmpty = false; + } + } catch (_error) { + // Si le fichier est corrompu, on le supprime + try { + fs.unlinkSync(itemPath); + } catch (_) { + isEmpty = false; + } + } + } else { + isEmpty = false; + } + } catch (_error) { + // En cas d'erreur sur le fichier/dossier, on continue + isEmpty = false; + continue; + } + } + + return isEmpty; + }; + + cleanDirectory(this.cacheDir); } public static getInstance(): ServerCacheService { @@ -39,44 +128,203 @@ class ServerCacheService { return ServerCacheService.DEFAULT_TTL[type]; } + public setCacheMode(mode: CacheMode): void { + if (this.config.mode === mode) return; + + // Si on passe de mémoire à fichier, on sauvegarde le cache en mémoire + if (mode === "file" && this.config.mode === "memory") { + this.memoryCache.forEach((value, key) => { + if (value.expiry > Date.now()) { + this.saveToFile(key, value); + } + }); + this.memoryCache.clear(); + } + // Si on passe de fichier à mémoire, on charge le cache fichier en mémoire + else if (mode === "memory" && this.config.mode === "file") { + this.loadFileCacheToMemory(); + } + + this.config.mode = mode; + console.log(`Cache mode switched to: ${mode}`); + } + + public getCacheMode(): CacheMode { + return this.config.mode; + } + + private loadFileCacheToMemory(): void { + if (!fs.existsSync(this.cacheDir)) return; + + const loadDirectory = (dirPath: string) => { + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + try { + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + loadDirectory(itemPath); + } else if (stats.isFile() && item.endsWith(".json")) { + try { + const content = fs.readFileSync(itemPath, "utf-8"); + const cached = JSON.parse(content); + if (cached.expiry > Date.now()) { + const key = path.relative(this.cacheDir, itemPath).slice(0, -5); // Remove .json + this.memoryCache.set(key, cached); + } + } catch (_error) { + // Ignore les fichiers corrompus + } + } + } catch (_error) { + // Ignore les erreurs d'accès + } + } + }; + + loadDirectory(this.cacheDir); + } + + private saveToFile(key: string, value: { data: unknown; expiry: number }): void { + const filePath = this.getCacheFilePath(key); + const dirPath = path.dirname(filePath); + + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(value), "utf-8"); + } catch (_error) { + // Ignore les erreurs d'écriture + } + } + /** * Met en cache des données avec une durée de vie */ set(key: string, data: any, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"): void { - this.cache.set(key, { + const cacheData = { data, expiry: Date.now() + this.getTTL(type), - }); + }; + + if (this.config.mode === "memory") { + this.memoryCache.set(key, cacheData); + } else { + const filePath = this.getCacheFilePath(key); + const dirPath = path.dirname(filePath); + + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(cacheData), "utf-8"); + } catch (error) { + console.error(`Error writing cache file ${filePath}:`, error); + } + } } /** * Récupère des données du cache si elles sont valides */ get(key: string): any | null { - const cached = this.cache.get(key); - if (!cached) return null; + if (this.config.mode === "memory") { + const cached = this.memoryCache.get(key); + if (!cached) return null; - const now = Date.now(); - if (cached.expiry > now) { - return cached.data; + if (cached.expiry > Date.now()) { + return cached.data; + } + + this.memoryCache.delete(key); + return null; } - this.cache.delete(key); - return null; + const filePath = this.getCacheFilePath(key); + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const content = fs.readFileSync(filePath, "utf-8"); + const cached = JSON.parse(content); + + if (cached.expiry > Date.now()) { + return cached.data; + } + + fs.unlinkSync(filePath); + return null; + } catch (error) { + console.error(`Error reading cache file ${filePath}:`, error); + return null; + } } /** * Supprime une entrée du cache */ delete(key: string): void { - this.cache.delete(key); + if (this.config.mode === "memory") { + this.memoryCache.delete(key); + } else { + const filePath = this.getCacheFilePath(key); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } } /** * Vide le cache */ clear(): void { - this.cache.clear(); + if (this.config.mode === "memory") { + this.memoryCache.clear(); + return; + } + + if (!fs.existsSync(this.cacheDir)) return; + + const removeDirectory = (dirPath: string) => { + if (!fs.existsSync(dirPath)) return; + + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + try { + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + removeDirectory(itemPath); + try { + fs.rmdirSync(itemPath); + } catch (_error) { + console.error(`Could not remove directory ${itemPath}`); + } + } else { + try { + fs.unlinkSync(itemPath); + } catch (_error) { + console.error(`Could not remove file ${itemPath}`); + } + } + } catch (_error) { + console.error(`Error accessing ${itemPath}`); + } + } + }; + + try { + removeDirectory(this.cacheDir); + console.log("Cache cleared successfully"); + } catch (error) { + console.error("Error clearing cache:", error); + } } /** @@ -87,19 +335,14 @@ class ServerCacheService { fetcher: () => Promise, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT" ): Promise { - const now = Date.now(); - const cached = this.cache.get(key); - - if (cached && cached.expiry > now) { - return cached.data as T; + const cached = this.get(key); + if (cached !== null) { + return cached as T; } try { const data = await fetcher(); - this.cache.set(key, { - data, - expiry: now + this.getTTL(type), - }); + this.set(key, data, type); return data; } catch (error) { throw error; @@ -107,7 +350,7 @@ class ServerCacheService { } invalidate(key: string): void { - this.cache.delete(key); + this.delete(key); } }