refactor: remove caching-related API endpoints and configurations, update preferences structure, and clean up unused services
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m22s

This commit is contained in:
Julien Froidefond
2026-01-03 18:55:12 +01:00
parent acd26ea427
commit 512e9a480f
49 changed files with 244 additions and 4073 deletions

View File

@@ -27,22 +27,6 @@
- **Body** : `{ url: string, username: string, password: string }` - **Body** : `{ url: string, username: string, password: string }`
- **Réponse** : `{ message: string, config: Config }` - **Réponse** : `{ message: string, config: Config }`
### GET /api/komga/ttl-config
- **Description** : Récupération de la configuration TTL du cache
- **Réponse** : `{ defaultTTL: number, homeTTL: number, ... }`
### POST /api/komga/ttl-config
- **Description** : Sauvegarde de la configuration TTL
- **Body** : `{ defaultTTL: number, homeTTL: number, ... }`
- **Réponse** : `{ message: string, config: TTLConfig }`
### GET /api/komga/cache/mode
- **Description** : Récupération du mode de cache actuel
- **Réponse** : `{ mode: string }`
## 📚 Bibliothèques ## 📚 Bibliothèques
### GET /api/komga/libraries ### GET /api/komga/libraries
@@ -123,13 +107,13 @@
### GET /api/preferences ### GET /api/preferences
- **Description** : Récupération des préférences utilisateur - **Description** : Récupération des préférences utilisateur
- **Réponse** : `{ showThumbnails: boolean, cacheMode: "memory" | "file", showOnlyUnread: boolean, debug: boolean }` - **Réponse** : `{ showThumbnails: boolean, showOnlyUnread: boolean, displayMode: object, background: object, readerPrefetchCount: number }`
### PUT /api/preferences ### PUT /api/preferences
- **Description** : Mise à jour des préférences utilisateur - **Description** : Mise à jour des préférences utilisateur
- **Body** : `{ showThumbnails?: boolean, cacheMode?: "memory" | "file", showOnlyUnread?: boolean, debug?: boolean }` - **Body** : `{ showThumbnails?: boolean, showOnlyUnread?: boolean, displayMode?: object, background?: object, readerPrefetchCount?: number }`
- **Réponse** : `{ showThumbnails: boolean, cacheMode: "memory" | "file", showOnlyUnread: boolean, debug: boolean }` - **Réponse** : `{ showThumbnails: boolean, showOnlyUnread: boolean, displayMode: object, background: object, readerPrefetchCount: number }`
## 🧪 Test ## 🧪 Test

View File

@@ -1,549 +0,0 @@
# Système de Caching
Ce document décrit l'architecture et les stratégies de caching de StripStream.
## Vue d'ensemble
Le système de caching est organisé en **3 couches indépendantes** avec des responsabilités clairement définies :
```
┌─────────────────────────────────────────────────────────────┐
│ NAVIGATEUR │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Service Worker (Cache API) │ │
│ │ → Offline support │ │
│ │ → Images (covers + pages) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SERVEUR NEXT.JS │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ServerCacheService │ │
│ │ → Données API Komga │ │
│ │ → Stale-while-revalidate │ │
│ │ → Mode fichier ou mémoire │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SERVEUR KOMGA │
└─────────────────────────────────────────────────────────────┘
```
## Couche 1 : Service Worker (Client)
### Fichier
`public/sw.js`
### Responsabilité
- Support offline de l'application
- Cache persistant des images (couvertures et pages de livres)
- Cache des ressources statiques Next.js
### Stratégies
#### Images : Cache-First
```javascript
// Pour toutes les images (covers + pages)
const isImageResource = (url) => {
return (
(url.includes("/api/v1/books/") &&
(url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) ||
(url.includes("/api/komga/images/") &&
(url.includes("/series/") || url.includes("/books/")) &&
url.includes("/thumbnail"))
);
};
```
**Comportement** :
1. Vérifier si l'image est dans le cache
2. Si oui → retourner depuis le cache
3. Si non → fetch depuis le réseau
4. Si succès → mettre en cache + retourner
5. Si échec → retourner 404
**Avantages** :
- Performance maximale (lecture instantanée depuis le cache)
- Fonctionne offline une fois les images chargées
- Économise la bande passante
#### Navigation et ressources statiques : Network-First
```javascript
// Pour les pages et ressources _next/static
event.respondWith(
fetch(request)
.then((response) => {
// Mise en cache si succès
if (response.ok && (isNextStaticResource || isNavigation)) {
cache.put(request, response.clone());
}
return response;
})
.catch(async () => {
// Fallback sur le cache si offline
const cachedResponse = await cache.match(request);
if (cachedResponse) return cachedResponse;
// Page offline si navigation
if (request.mode === "navigate") {
return cache.match("/offline.html");
}
})
);
```
**Avantages** :
- Toujours la dernière version quand online
- Fallback offline si nécessaire
- Navigation fluide même sans connexion
### Caches
| Cache | Usage | Stratégie | Taille |
| ----------------------- | ---------------------------- | ------------- | -------- |
| `stripstream-cache-v1` | Ressources statiques + pages | Network-First | ~5 MB |
| `stripstream-images-v1` | Images (covers + pages) | Cache-First | Illimité |
### Nettoyage
- Automatique lors de l'activation du Service Worker
- Suppression des anciennes versions de cache
- Pas d'expiration (contrôlé par l'utilisateur via les paramètres du navigateur)
## Couche 2 : ServerCacheService (Serveur)
### Fichier
`src/lib/services/server-cache.service.ts`
### Responsabilité
- Cache des réponses API Komga côté serveur
- Optimisation des temps de réponse
- Réduction de la charge sur Komga
### Stratégie : Stale-While-Revalidate
Cette stratégie est **la clé de la performance** de l'application.
#### Principe
```
Requête → Cache existe ?
├─ Non → Fetch normal + mise en cache
└─ Oui → Cache valide ?
├─ Oui → Retourne immédiatement
└─ Non → Retourne le cache expiré (stale)
ET revalide en background
```
#### Implémentation
```typescript
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
): Promise<T> {
const cacheKey = `${user.id}-${key}`;
const cachedResult = this.getStale(cacheKey);
if (cachedResult !== null) {
const { data, isStale } = cachedResult;
// Si le cache est expiré, revalider en background
if (isStale) {
this.revalidateInBackground(cacheKey, fetcher, type, key);
}
return data as T; // Retour immédiat
}
// Pas de cache, fetch normal
const data = await fetcher();
this.set(cacheKey, data, type);
return data;
}
```
#### Avantages
**Temps de réponse constant** : Le cache expiré est retourné instantanément
**Données fraîches** : Revalidation en background pour la prochaine requête
**Pas de délai** : L'utilisateur ne subit jamais l'attente de revalidation
**Résilience** : Même si Komga est lent, l'app reste rapide
#### Inconvénients
⚠️ Les données peuvent être légèrement obsolètes (jusqu'au prochain refresh)
⚠️ Nécessite un cache initialisé (première requête toujours lente)
### Modes de stockage
L'utilisateur peut choisir entre deux modes :
#### Mode Mémoire (par défaut)
```typescript
cacheMode: "memory";
```
- Cache stocké en RAM
- **Performances** : Très rapide (lecture < 1ms)
- **Persistance** : Perdu au redémarrage du serveur
- **Capacité** : Limitée par la RAM disponible
- **Idéal pour** : Développement, faible charge
#### Mode Fichier
```typescript
cacheMode: "file";
```
- Cache stocké sur disque (`.cache/`)
- **Performances** : Rapide (lecture 5-10ms)
- **Persistance** : Survit aux redémarrages
- **Capacité** : Limitée par l'espace disque
- **Idéal pour** : Production, haute charge
### Time-To-Live (TTL)
Chaque type de données a un TTL configuré :
| Type | TTL par défaut | Justification |
| ----------- | -------------- | ---------------------------------- |
| `DEFAULT` | 5 minutes | Données génériques |
| `HOME` | 10 minutes | Page d'accueil (données agrégées) |
| `LIBRARIES` | 24 heures | Bibliothèques (rarement modifiées) |
| `SERIES` | 5 minutes | Séries (métadonnées + progression) |
| `BOOKS` | 5 minutes | Livres (métadonnées + progression) |
| `IMAGES` | 7 jours | Images (immuables) |
#### Configuration personnalisée
Les TTL peuvent être personnalisés par l'utilisateur via la base de données :
```typescript
// Modèle Prisma : TTLConfig
{
defaultTTL: 5 * 60 * 1000,
homeTTL: 10 * 60 * 1000,
librariesTTL: 24 * 60 * 60 * 1000,
seriesTTL: 5 * 60 * 1000,
booksTTL: 5 * 60 * 1000,
imagesTTL: 7 * 24 * 60 * 60 * 1000,
}
```
### Isolation par utilisateur
Chaque utilisateur a son propre cache :
```typescript
const cacheKey = `${user.id}-${key}`;
```
**Avantages** :
- Pas de collision entre utilisateurs
- Progression de lecture individuelle
- Préférences personnalisées
### Invalidation du cache
Le cache peut être invalidé :
#### Manuellement
```typescript
await cacheService.delete(key); // Une clé
await cacheService.deleteAll(prefix); // Toutes les clés avec préfixe
await cacheService.clear(); // Tout le cache
```
#### Automatiquement
- Lors d'une mise à jour de progression
- Lors d'un changement de favoris
- Lors de la suppression d'une série
#### API
```
DELETE /api/komga/cache/clear // Vider tout le cache
DELETE /api/komga/home // Invalider le cache home
```
## Couche 3 : Cache HTTP (Navigateur)
### Responsabilité
- Cache basique géré par le navigateur
- Headers HTTP standard
### Configuration
#### Next.js ISR (Incremental Static Regeneration)
```typescript
export const revalidate = 60; // Revalidation toutes les 60 secondes
```
Utilisé uniquement pour les routes avec rendu statique.
#### Headers explicites (désactivé)
Les headers HTTP explicites ont été **supprimés** car :
- Le ServerCacheService gère déjà le caching efficacement
- Évite la confusion entre plusieurs couches de cache
- Simplifie le debugging
Avant (supprimé) :
```typescript
NextResponse.json(data, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
```
Maintenant :
```typescript
NextResponse.json(data); // Pas de headers
```
## Flow de données complet
Exemple : Chargement de la page d'accueil
```
1. Utilisateur → GET /
2. Next.js → HomeService.getHomeData()
3. HomeService → ServerCacheService.getOrSet("home-ongoing", ...)
4. ServerCacheService
├─ Cache valide ? → Retourne immédiatement
├─ Cache expiré ? → Retourne cache + revalide en background
└─ Pas de cache ? → Fetch Komga + mise en cache
5. Response → Client
6. Images → Service Worker (Cache-First)
├─ En cache ? → Lecture instantanée
└─ Pas en cache ? → Fetch + mise en cache
```
### Temps de réponse typiques
| Scénario | Temps | Détails |
| ----------------------------- | ----------- | -------------------------- |
| Cache ServerCache valide + SW | ~50ms | Optimal |
| Cache ServerCache expiré + SW | ~50ms | Revalidation en background |
| Pas de cache ServerCache + SW | ~200-500ms | Première requête |
| Cache SW uniquement | ~10ms | Images seulement |
| Tout à froid | ~500-1000ms | Pire cas |
## Cas d'usage
### 1. Première visite
```
User → App → Komga (tous les caches vides)
Temps : ~500-1000ms
```
### 2. Visite suivante (online)
```
User → ServerCache (valide) → Images SW
Temps : ~50ms
```
### 3. Cache expiré (online)
```
User → ServerCache (stale) → Retour immédiat
Revalidation background → Mise à jour cache
Temps ressenti : ~50ms (aucun délai)
```
### 4. Mode offline
```
User → Service Worker cache uniquement
Fonctionnalités :
✅ Navigation entre pages déjà visitées
✅ Consultation des images déjà vues
❌ Nouvelles données (nécessite connexion)
```
## Monitoring et debug
### Logs de cache (recommandé pour le dev)
Activez les logs détaillés du cache serveur :
```bash
# Dans docker-compose.dev.yml ou .env
CACHE_DEBUG=true
```
**Format des logs** :
```
[CACHE HIT] home-ongoing | HOME | 0.45ms # Cache valide
[CACHE STALE] home-ongoing | HOME | 0.52ms # Cache expiré (retourné + revalidation)
[CACHE MISS] home-ongoing | HOME # Pas de cache
[CACHE SET] home-ongoing | HOME | 324.18ms # Mise en cache
[CACHE REVALIDATE] home-ongoing | HOME | 287ms # Revalidation background
```
📖 **Documentation complète** : [docs/cache-debug.md](./cache-debug.md)
### API de monitoring
#### Taille du cache serveur
```bash
GET /api/komga/cache/size
Response: { sizeInBytes: 15728640, itemCount: 234 }
```
#### Mode de cache actuel
```bash
GET /api/komga/cache/mode
Response: { mode: "memory" }
```
#### Changer le mode
```bash
POST /api/komga/cache/mode
Body: { mode: "file" }
```
#### Vider le cache
```bash
POST /api/komga/cache/clear
```
### DevTools du navigateur
#### Network Tab
- Temps de réponse < 50ms = cache serveur
- Headers `X-Cache` si configurés
- Onglet "Timing" pour détails
#### Application → Cache Storage
Inspecter le Service Worker :
- `stripstream-cache-v1` : Ressources statiques
- `stripstream-images-v1` : Images
Actions disponibles :
- Voir le contenu
- Supprimer des entrées
- Vider complètement
#### Application → Service Workers
- État du Service Worker
- "Unregister" pour le désactiver
- "Update" pour forcer une mise à jour
## Optimisations futures possibles
### 1. Cache Redis (optionnel)
- Pour un déploiement multi-instances
- Cache partagé entre plusieurs serveurs
- TTL natif Redis
### 2. Compression
- Compresser les données en cache (Brotli/Gzip)
- Économie d'espace disque/mémoire
- Trade-off CPU vs espace
### 3. Prefetching intelligent
- Précharger les séries en cours de lecture
- Précharger les pages suivantes dans le reader
- Basé sur l'historique utilisateur
### 4. Cache Analytics
- Ratio hit/miss
- Temps de réponse moyens
- Identification des données les plus consultées
## Bonnes pratiques
### Pour les développeurs
**Utiliser BaseApiService.fetchWithCache()**
```typescript
await this.fetchWithCache<T>(
"cache-key",
async () => this.fetchFromApi(...),
"HOME" // Type de TTL
);
```
**Invalider le cache après modification**
```typescript
await HomeService.invalidateHomeCache();
```
**Choisir le bon TTL**
- Court (1-5 min) : Données qui changent souvent
- Moyen (10-30 min) : Données agrégées
- Long (24h+) : Données quasi-statiques
**Ne pas cacher les mutations**
Les POST/PUT/DELETE ne doivent jamais être cachés
**Ne pas oublier l'isolation utilisateur**
Toujours préfixer avec `userId` pour les données personnelles
### Pour les utilisateurs
- **Mode mémoire** : Plus rapide, mais cache perdu au redémarrage
- **Mode fichier** : Persistant, idéal pour production
- **Vider le cache** : En cas de problème d'affichage
- **Offline** : Consulter les pages déjà visitées
## Conclusion
Le système de caching de StripStream est conçu pour :
🎯 **Performance** : Temps de réponse constants grâce au stale-while-revalidate
🔒 **Fiabilité** : Fonctionne même si Komga est lent ou inaccessible
💾 **Flexibilité** : Mode mémoire ou fichier selon les besoins
🚀 **Offline-first** : Support complet du mode hors ligne
🧹 **Simplicité** : 3 couches bien définies, pas de redondance
Le système est maintenu simple avec des responsabilités claires pour chaque couche, facilitant la maintenance et l'évolution future.

View File

@@ -7,10 +7,12 @@ Service de gestion de l'authentification
### Méthodes ### Méthodes
- `loginUser(email: string, password: string): Promise<UserData>` - `loginUser(email: string, password: string): Promise<UserData>`
- Authentifie un utilisateur - Authentifie un utilisateur
- Retourne les données utilisateur - Retourne les données utilisateur
- `createUser(email: string, password: string): Promise<UserData>` - `createUser(email: string, password: string): Promise<UserData>`
- Crée un nouvel utilisateur - Crée un nouvel utilisateur
- Retourne les données utilisateur - Retourne les données utilisateur
@@ -24,10 +26,11 @@ Service de gestion des bibliothèques
### Méthodes ### Méthodes
- `getLibraries(): Promise<Library[]>` - `getLibraries(): Promise<Library[]>`
- Récupère la liste des bibliothèques - Récupère la liste des bibliothèques
- Met en cache les résultats
- `getLibrary(libraryId: string): Promise<Library>` - `getLibrary(libraryId: string): Promise<Library>`
- Récupère une bibliothèque spécifique - Récupère une bibliothèque spécifique
- Lance une erreur si non trouvée - Lance une erreur si non trouvée
@@ -47,9 +50,11 @@ Service de gestion des séries
### Méthodes ### Méthodes
- `getSeries(seriesId: string): Promise<Series>` - `getSeries(seriesId: string): Promise<Series>`
- Récupère les détails d'une série - Récupère les détails d'une série
- `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>` - `getSeriesBooks(seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false): Promise<LibraryResponse<KomgaBook>>`
- Récupère les livres d'une série - Récupère les livres d'une série
- Supporte la pagination et le filtrage - Supporte la pagination et le filtrage
@@ -63,15 +68,19 @@ Service de gestion des livres
### Méthodes ### Méthodes
- `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>` - `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>`
- Récupère les détails d'un livre et ses pages - Récupère les détails d'un livre et ses pages
- `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>` - `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>`
- Met à jour la progression de lecture - Met à jour la progression de lecture
- `getPage(bookId: string, pageNumber: number): Promise<Response>` - `getPage(bookId: string, pageNumber: number): Promise<Response>`
- Récupère une page spécifique d'un livre - Récupère une page spécifique d'un livre
- `getCover(bookId: string): Promise<Response>` - `getCover(bookId: string): Promise<Response>`
- Récupère la couverture d'un livre - Récupère la couverture d'un livre
- `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>` - `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>`
@@ -84,13 +93,15 @@ Service de gestion des images
### Méthodes ### Méthodes
- `getImage(path: string): Promise<ImageResponse>` - `getImage(path: string): Promise<ImageResponse>`
- Récupère une image depuis le serveur - Récupère une image depuis le serveur
- Gère le cache des images
- `getSeriesThumbnailUrl(seriesId: string): string` - `getSeriesThumbnailUrl(seriesId: string): string`
- Génère l'URL de la miniature d'une série - Génère l'URL de la miniature d'une série
- `getBookThumbnailUrl(bookId: string): string` - `getBookThumbnailUrl(bookId: string): string`
- Génère l'URL de la miniature d'un livre - Génère l'URL de la miniature d'un livre
- `getBookPageUrl(bookId: string, pageNumber: number): string` - `getBookPageUrl(bookId: string, pageNumber: number): string`
@@ -103,29 +114,20 @@ Service de gestion de la configuration
### Méthodes ### Méthodes
- `getConfig(): Promise<Config>` - `getConfig(): Promise<Config>`
- Récupère la configuration Komga - Récupère la configuration Komga
- `saveConfig(config: Config): Promise<Config>` - `saveConfig(config: Config): Promise<Config>`
- Sauvegarde la configuration Komga - Sauvegarde la configuration Komga
- `getTTLConfig(): Promise<TTLConfig>` - `getTTLConfig(): Promise<TTLConfig>`
- Récupère la configuration TTL - Récupère la configuration TTL
- `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>` - `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>`
- Sauvegarde la configuration TTL - Sauvegarde la configuration TTL
## 🔄 ServerCacheService
Service de gestion du cache serveur
### Méthodes
- `getCacheMode(): string`
- Récupère le mode de cache actuel
- `clearCache(): void`
- Vide le cache serveur
## ⭐ FavoriteService ## ⭐ FavoriteService
Service de gestion des favoris Service de gestion des favoris
@@ -142,6 +144,7 @@ Service de gestion des préférences
### Méthodes ### Méthodes
- `getPreferences(): Promise<Preferences>` - `getPreferences(): Promise<Preferences>`
- Récupère les préférences utilisateur - Récupère les préférences utilisateur
- `savePreferences(preferences: Preferences): Promise<void>` - `savePreferences(preferences: Preferences): Promise<void>`
@@ -164,13 +167,12 @@ Service de base pour les appels API
### Méthodes ### Méthodes
- `buildUrl(config: Config, path: string, params?: Record<string, string>): string` - `buildUrl(config: Config, path: string, params?: Record<string, string>): string`
- Construit une URL d'API - Construit une URL d'API
- `getAuthHeaders(config: Config): Headers` - `getAuthHeaders(config: Config): Headers`
- Génère les en-têtes d'authentification - Génère les en-têtes d'authentification
- `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>` - `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>`
- Effectue un appel API avec gestion d'erreurs - Effectue un appel API avec gestion d'erreurs
- `fetchWithCache<T>(key: string, fetcher: () => Promise<T>, type: CacheType): Promise<T>`
- Effectue un appel API avec mise en cache

View File

@@ -21,7 +21,6 @@ model User {
// Relations // Relations
config KomgaConfig? config KomgaConfig?
ttlConfig TTLConfig?
preferences Preferences? preferences Preferences?
favorites Favorite[] favorites Favorite[]
@@ -42,34 +41,13 @@ model KomgaConfig {
@@map("komgaconfigs") @@map("komgaconfigs")
} }
model TTLConfig {
id Int @id @default(autoincrement())
userId Int @unique
defaultTTL Int @default(5)
homeTTL Int @default(5)
librariesTTL Int @default(1440)
seriesTTL Int @default(5)
booksTTL Int @default(5)
imagesTTL Int @default(1440)
imageCacheMaxAge Int @default(2592000) // 30 jours en secondes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("ttlconfigs")
}
model Preferences { model Preferences {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int @unique userId Int @unique
showThumbnails Boolean @default(true) showThumbnails Boolean @default(true)
cacheMode String @default("memory") // "memory" | "file"
showOnlyUnread Boolean @default(false) showOnlyUnread Boolean @default(false)
displayMode Json displayMode Json
background Json background Json
komgaMaxConcurrentRequests Int @default(5)
circuitBreakerConfig Json
readerPrefetchCount Int @default(5) readerPrefetchCount Int @default(5)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -92,4 +70,3 @@ model Favorite {
@@index([userId]) @@index([userId])
@@map("favorites") @@map("favorites")
} }

View File

@@ -1,11 +1,8 @@
// StripStream Service Worker - Version 1 // StripStream Service Worker - Version 1
// Architecture: Cache-as-you-go for images and static resources only // Architecture: Cache-as-you-go for static resources only
// API data caching is handled by ServerCacheService on the server
const VERSION = "v1"; const VERSION = "v1";
const STATIC_CACHE = `stripstream-static-${VERSION}`; const STATIC_CACHE = `stripstream-static-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
const DATA_CACHE = `stripstream-data-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`; const RSC_CACHE = `stripstream-rsc-${VERSION}`;
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
@@ -20,29 +17,18 @@ function isNextStaticResource(url) {
return url.includes("/_next/static/"); return url.includes("/_next/static/");
} }
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
function isApiDataRequest(url) {
return url.includes("/api/komga/") && !isImageRequest(url);
}
function isNextRSCRequest(request) { function isNextRSCRequest(request) {
const url = new URL(request.url); const url = new URL(request.url);
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
} }
// Removed: shouldCacheApiData - API data is no longer cached by SW
// API data caching is handled by ServerCacheService on the server
// ============================================================================ // ============================================================================
// Cache Strategies // Cache Strategies
// ============================================================================ // ============================================================================
/** /**
* Cache-First: Serve from cache, fallback to network * Cache-First: Serve from cache, fallback to network
* Used for: Images, Next.js static resources * Used for: Next.js static resources
*/ */
async function cacheFirstStrategy(request, cacheName, options = {}) { async function cacheFirstStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
@@ -70,7 +56,7 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
/** /**
* Stale-While-Revalidate: Serve from cache immediately, update in background * Stale-While-Revalidate: Serve from cache immediately, update in background
* Used for: API data, RSC payloads * Used for: RSC payloads
*/ */
async function staleWhileRevalidateStrategy(request, cacheName) { async function staleWhileRevalidateStrategy(request, cacheName) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
@@ -202,39 +188,24 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Route 1: Images → Cache-First with ignoreSearch // Route 1: Next.js RSC payloads → Stale-While-Revalidate
if (isImageRequest(url.href)) {
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
return;
}
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
if (isNextRSCRequest(request)) { if (isNextRSCRequest(request)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE)); event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
return; return;
} }
// Route 3: API data → Network only (no SW caching) // Route 2: Next.js static resources → Cache-First with ignoreSearch
// API data caching is handled by ServerCacheService on the server
// This avoids double caching and simplifies cache invalidation
if (isApiDataRequest(url.href)) {
// Let the request pass through to the network
// ServerCacheService will handle caching server-side
return;
}
// Route 4: Next.js static resources → Cache-First with ignoreSearch
if (isNextStaticResource(url.href)) { if (isNextStaticResource(url.href)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
return; return;
} }
// Route 5: Navigation → Network-First with SPA fallback // Route 3: Navigation → Network-First with SPA fallback
if (request.mode === "navigate") { if (request.mode === "navigate") {
event.respondWith(navigationStrategy(request)); event.respondWith(navigationStrategy(request));
return; return;
} }
// Route 6: Everything else → Network only (no caching) // Route 4: Everything else → Network only (no caching)
// This includes: API auth, preferences, and other dynamic content // This includes: API calls, images, and other dynamic content
}); });

View File

@@ -1,7 +1,6 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { BookService } from "@/lib/services/book.service"; import { BookService } from "@/lib/services/book.service";
import { SeriesService } from "@/lib/services/series.service";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -30,16 +29,6 @@ export async function PATCH(
await BookService.updateReadProgress(bookId, page, completed); await BookService.updateReadProgress(bookId, page, completed);
// Invalider le cache de la série après avoir mis à jour la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "📖 Progression mise à jour avec succès" }); return NextResponse.json({ message: "📖 Progression mise à jour avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:"); logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:");
@@ -77,16 +66,6 @@ export async function DELETE(
await BookService.deleteReadProgress(bookId); await BookService.deleteReadProgress(bookId);
// Invalider le cache de la série après avoir supprimé la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" }); return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression de la progression:"); logger.error({ err: error }, "Erreur lors de la suppression de la progression:");

View File

@@ -1,46 +0,0 @@
import { NextResponse } from "next/server";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import { LibraryService } from "@/lib/services/library.service";
import { HomeService } from "@/lib/services/home.service";
import { SeriesService } from "@/lib/services/series.service";
import { revalidatePath } from "next/cache";
import type { NextRequest } from "next/server";
import logger from "@/lib/logger";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ libraryId: string; seriesId: string }> }
) {
try {
const { libraryId, seriesId } = await params;
await HomeService.invalidateHomeCache();
revalidatePath("/");
if (libraryId) {
await LibraryService.invalidateLibrarySeriesCache(libraryId);
revalidatePath(`/library/${libraryId}`);
}
if (seriesId) {
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
revalidatePath(`/series/${seriesId}`);
}
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
} catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.CLEAR_ERROR,
name: "Cache clear error",
message: getErrorMessage(ERROR_CODES.CACHE.CLEAR_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import type { ServerCacheService } from "@/lib/services/server-cache.service";
import { getServerCacheService } from "@/lib/services/server-cache.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import { revalidatePath } from "next/cache";
import logger from "@/lib/logger";
export async function POST() {
try {
const cacheService: ServerCacheService = await getServerCacheService();
await cacheService.clear();
// Revalider toutes les pages importantes après le vidage du cache
revalidatePath("/");
revalidatePath("/libraries");
revalidatePath("/series");
revalidatePath("/books");
return NextResponse.json({ message: "🧹 Cache vidé avec succès" });
} catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression du cache:");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.CLEAR_ERROR,
name: "Cache clear error",
message: getErrorMessage(ERROR_CODES.CACHE.CLEAR_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -1,27 +0,0 @@
import { NextResponse } from "next/server";
import type { ServerCacheService } from "@/lib/services/server-cache.service";
import { getServerCacheService } from "@/lib/services/server-cache.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import logger from "@/lib/logger";
export async function GET() {
try {
const cacheService: ServerCacheService = await getServerCacheService();
const entries = await cacheService.getCacheEntries();
return NextResponse.json({ entries });
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.SIZE_FETCH_ERROR,
name: "Cache entries fetch error",
message: getErrorMessage(ERROR_CODES.CACHE.SIZE_FETCH_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -1,60 +0,0 @@
import { NextResponse } from "next/server";
import type { CacheMode, ServerCacheService } from "@/lib/services/server-cache.service";
import { getServerCacheService } from "@/lib/services/server-cache.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import type { NextRequest } from "next/server";
import logger from "@/lib/logger";
export async function GET() {
try {
const cacheService: ServerCacheService = await getServerCacheService();
return NextResponse.json({ mode: cacheService.getCacheMode() });
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération du mode de cache:");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.MODE_FETCH_ERROR,
name: "Cache mode fetch error",
message: getErrorMessage(ERROR_CODES.CACHE.MODE_FETCH_ERROR),
},
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const { mode }: { mode: CacheMode } = await request.json();
if (mode !== "file" && mode !== "memory") {
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.INVALID_MODE,
name: "Invalid cache mode",
message: getErrorMessage(ERROR_CODES.CACHE.INVALID_MODE),
},
},
{ status: 400 }
);
}
const cacheService: ServerCacheService = await getServerCacheService();
cacheService.setCacheMode(mode);
return NextResponse.json({ mode: cacheService.getCacheMode() });
} catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour du mode de cache:");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.MODE_UPDATE_ERROR,
name: "Cache mode update error",
message: getErrorMessage(ERROR_CODES.CACHE.MODE_UPDATE_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
import type { ServerCacheService } from "@/lib/services/server-cache.service";
import { getServerCacheService } from "@/lib/services/server-cache.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import logger from "@/lib/logger";
export async function GET() {
try {
const cacheService: ServerCacheService = await getServerCacheService();
const { sizeInBytes, itemCount } = await cacheService.getCacheSize();
return NextResponse.json({
sizeInBytes,
itemCount,
mode: cacheService.getCacheMode(),
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.SIZE_FETCH_ERROR,
name: "Cache size fetch error",
message: getErrorMessage(ERROR_CODES.CACHE.SIZE_FETCH_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -35,34 +35,3 @@ export async function GET() {
); );
} }
} }
export async function DELETE() {
try {
await HomeService.invalidateHomeCache();
return NextResponse.json({ success: true });
} catch (error) {
logger.error({ err: error }, "API Home - Erreur lors de l'invalidation du cache:");
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Cache invalidation error",
message: getErrorMessage(error.code),
},
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.DELETE_ERROR,
name: "Cache invalidation error",
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -53,40 +53,3 @@ export async function GET(
); );
} }
} }
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ libraryId: string }> }
) {
try {
const libraryId: string = (await params).libraryId;
await LibraryService.invalidateLibrarySeriesCache(libraryId);
return NextResponse.json({ success: true });
} catch (error) {
logger.error({ err: error }, "API Library Cache Invalidation - Erreur:");
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Cache invalidation error",
message: getErrorMessage(error.code),
},
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.DELETE_ERROR,
name: "Cache invalidation error",
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -52,43 +52,3 @@ export async function GET(
); );
} }
} }
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ seriesId: string }> }
) {
try {
const seriesId: string = (await params).seriesId;
await Promise.all([
SeriesService.invalidateSeriesBooksCache(seriesId),
SeriesService.invalidateSeriesCache(seriesId),
]);
return NextResponse.json({ success: true });
} catch (error) {
logger.error({ err: error }, "API Series Cache Invalidation - Erreur:");
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Cache invalidation error",
message: getErrorMessage(error.code),
},
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.CACHE.DELETE_ERROR,
name: "Cache invalidation error",
message: getErrorMessage(ERROR_CODES.CACHE.DELETE_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -1,87 +0,0 @@
import { NextResponse } from "next/server";
import { ConfigDBService } from "@/lib/services/config-db.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { TTLConfig } from "@/types/komga";
import { getErrorMessage } from "@/utils/errors";
import type { NextRequest } from "next/server";
import logger from "@/lib/logger";
export async function GET() {
try {
const config: TTLConfig | null = await ConfigDBService.getTTLConfig();
return NextResponse.json(config);
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la configuration TTL");
if (error instanceof Error) {
if (error.message === getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED)) {
return NextResponse.json(
{
error: {
name: "Unauthorized",
code: ERROR_CODES.MIDDLEWARE.UNAUTHORIZED,
message: getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED),
},
},
{ status: 401 }
);
}
}
return NextResponse.json(
{
error: {
name: "TTL fetch error",
code: ERROR_CODES.CONFIG.TTL_FETCH_ERROR,
message: getErrorMessage(ERROR_CODES.CONFIG.TTL_FETCH_ERROR),
},
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const data = await request.json();
const config: TTLConfig = await ConfigDBService.saveTTLConfig(data);
return NextResponse.json({
message: "⏱️ Configuration TTL sauvegardée avec succès",
config: {
defaultTTL: config.defaultTTL,
homeTTL: config.homeTTL,
librariesTTL: config.librariesTTL,
seriesTTL: config.seriesTTL,
booksTTL: config.booksTTL,
imagesTTL: config.imagesTTL,
imageCacheMaxAge: config.imageCacheMaxAge,
},
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la sauvegarde de la configuration TTL");
if (
error instanceof Error &&
error.message === getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED)
) {
return NextResponse.json(
{
error: {
name: "Unauthorized",
code: ERROR_CODES.MIDDLEWARE.UNAUTHORIZED,
message: getErrorMessage(ERROR_CODES.MIDDLEWARE.UNAUTHORIZED),
},
},
{ status: 401 }
);
}
return NextResponse.json(
{
error: {
name: "TTL save error",
code: ERROR_CODES.CONFIG.TTL_SAVE_ERROR,
message: getErrorMessage(ERROR_CODES.CONFIG.TTL_SAVE_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -59,9 +59,7 @@ export function ClientLibraryPage({
params.append("search", search); params.append("search", search);
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`);
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -84,16 +82,6 @@ export function ClientLibraryPage({
const handleRefresh = async (libraryId: string) => { const handleRefresh = async (libraryId: string) => {
try { try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
method: "DELETE",
});
if (!cacheResponse.ok) {
throw new Error("Error invalidating cache");
}
// Recharger les données
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(currentPage - 1), page: String(currentPage - 1),
size: String(effectivePageSize), size: String(effectivePageSize),
@@ -105,7 +93,7 @@ export function ClientLibraryPage({
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload", // Force un nouveau fetch après invalidation cache: "reload",
}); });
if (!response.ok) { if (!response.ok) {
@@ -139,7 +127,7 @@ export function ClientLibraryPage({
} }
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, { const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload", // Force un nouveau fetch lors du retry cache: "reload",
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -49,9 +49,7 @@ export function ClientSeriesPage({
unread: String(unreadOnly), unread: String(unreadOnly),
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`);
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -74,16 +72,6 @@ export function ClientSeriesPage({
const handleRefresh = async (seriesId: string) => { const handleRefresh = async (seriesId: string) => {
try { try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, {
method: "DELETE",
});
if (!cacheResponse.ok) {
throw new Error("Erreur lors de l'invalidation du cache");
}
// Recharger les données
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(currentPage - 1), page: String(currentPage - 1),
size: String(effectivePageSize), size: String(effectivePageSize),
@@ -91,7 +79,7 @@ export function ClientSeriesPage({
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload", // Force un nouveau fetch après invalidation cache: "reload",
}); });
if (!response.ok) { if (!response.ok) {
@@ -121,7 +109,7 @@ export function ClientSeriesPage({
}); });
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, { const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload", // Force un nouveau fetch lors du retry cache: "reload",
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -1,7 +1,7 @@
import { ConfigDBService } from "@/lib/services/config-db.service"; import { ConfigDBService } from "@/lib/services/config-db.service";
import { ClientSettings } from "@/components/settings/ClientSettings"; import { ClientSettings } from "@/components/settings/ClientSettings";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { KomgaConfig, TTLConfig } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +13,6 @@ export const metadata: Metadata = {
export default async function SettingsPage() { export default async function SettingsPage() {
let config: KomgaConfig | null = null; let config: KomgaConfig | null = null;
let ttlConfig: TTLConfig | null = null;
try { try {
// Récupérer la configuration Komga // Récupérer la configuration Komga
@@ -27,13 +26,10 @@ export default async function SettingsPage() {
password: null, password: null,
}; };
} }
// Récupérer la configuration TTL
ttlConfig = await ConfigDBService.getTTLConfig();
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:"); logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial // On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
} }
return <ClientSettings initialConfig={config} initialTTLConfig={ttlConfig} />; return <ClientSettings initialConfig={config} />;
} }

View File

@@ -22,9 +22,7 @@ export function ClientHomePage() {
setError(null); setError(null);
try { try {
const response = await fetch("/api/komga/home", { const response = await fetch("/api/komga/home");
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
@@ -56,18 +54,8 @@ export function ClientHomePage() {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
// Invalider le cache via l'API
const deleteResponse = await fetch("/api/komga/home", {
method: "DELETE",
});
if (!deleteResponse.ok) {
throw new Error("Erreur lors de l'invalidation du cache");
}
// Récupérer les nouvelles données
const response = await fetch("/api/komga/home", { const response = await fetch("/api/komga/home", {
cache: "reload", // Force un nouveau fetch après invalidation cache: "reload",
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -10,7 +10,6 @@ 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";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
@@ -152,7 +151,6 @@ export default function ClientLayout({
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ImageCacheProvider>
{/* Background fixe pour les images et gradients */} {/* Background fixe pour les images et gradients */}
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />} {hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div <div
@@ -184,7 +182,6 @@ export default function ClientLayout({
<Toaster /> <Toaster />
<NetworkStatus /> <NetworkStatus />
</div> </div>
</ImageCacheProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -75,8 +75,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
}, []); }, []);
// Prefetch current and next pages // Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits
useEffect(() => { useEffect(() => {
// Prefetch pages starting from current page // Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount); prefetchPages(currentPage, prefetchCount);

View File

@@ -99,8 +99,6 @@ export function useImageLoader({
); );
// Prefetch multiple pages starting from a given page // Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback( const prefetchPages = useCallback(
async (startPage: number, count: number = prefetchCount) => { async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = []; const pagesToPrefetch = [];

View File

@@ -2,7 +2,7 @@ import { useTranslate } from "@/hooks/useTranslate";
import { usePreferences } from "@/contexts/PreferencesContext"; import { usePreferences } from "@/contexts/PreferencesContext";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Activity, Shield } from "lucide-react"; import { Activity } from "lucide-react";
import { SliderControl } from "@/components/ui/slider-control"; import { SliderControl } from "@/components/ui/slider-control";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
@@ -11,25 +11,6 @@ export function AdvancedSettings() {
const { toast } = useToast(); const { toast } = useToast();
const { preferences, updatePreferences } = usePreferences(); const { preferences, updatePreferences } = usePreferences();
const handleMaxConcurrentChange = async (value: number) => {
try {
await updatePreferences({
komgaMaxConcurrentRequests: value,
});
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
logger.error({ err: error }, "Erreur:");
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
};
const handlePrefetchChange = async (value: number) => { const handlePrefetchChange = async (value: number) => {
try { try {
await updatePreferences({ await updatePreferences({
@@ -49,28 +30,6 @@ export function AdvancedSettings() {
} }
}; };
const handleCircuitBreakerChange = async (field: string, value: number) => {
try {
await updatePreferences({
circuitBreakerConfig: {
...preferences.circuitBreakerConfig,
[field]: value,
},
});
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
logger.error({ err: error }, "Erreur:");
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Performance Settings */} {/* Performance Settings */}
@@ -85,18 +44,6 @@ export function AdvancedSettings() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<SliderControl
label={t("settings.advanced.maxConcurrentRequests.label")}
value={preferences.komgaMaxConcurrentRequests}
min={1}
max={10}
step={1}
description={t("settings.advanced.maxConcurrentRequests.description")}
onChange={handleMaxConcurrentChange}
/>
<div className="border-t" />
<SliderControl <SliderControl
label={t("settings.advanced.prefetchCount.label")} label={t("settings.advanced.prefetchCount.label")}
value={preferences.readerPrefetchCount} value={preferences.readerPrefetchCount}
@@ -108,54 +55,6 @@ export function AdvancedSettings() {
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Circuit Breaker Configuration */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("settings.advanced.circuitBreaker.title")}</CardTitle>
</div>
<CardDescription>{t("settings.advanced.circuitBreaker.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SliderControl
label={t("settings.advanced.circuitBreaker.threshold.label")}
value={preferences.circuitBreakerConfig.threshold ?? 5}
min={1}
max={20}
step={1}
description={t("settings.advanced.circuitBreaker.threshold.description")}
onChange={(value) => handleCircuitBreakerChange("threshold", value)}
/>
<div className="border-t" />
<SliderControl
label={t("settings.advanced.circuitBreaker.timeout.label")}
value={preferences.circuitBreakerConfig.timeout ?? 30000}
min={1000}
max={120000}
step={1000}
description={t("settings.advanced.circuitBreaker.timeout.description")}
onChange={(value) => handleCircuitBreakerChange("timeout", value)}
formatValue={(value) => `${value / 1000}s`}
/>
<div className="border-t" />
<SliderControl
label={t("settings.advanced.circuitBreaker.resetTimeout.label")}
value={preferences.circuitBreakerConfig.resetTimeout ?? 60000}
min={10000}
max={600000}
step={1000}
description={t("settings.advanced.circuitBreaker.resetTimeout.description")}
onChange={(value) => handleCircuitBreakerChange("resetTimeout", value)}
formatValue={(value) => `${value / 1000}s`}
/>
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@@ -1,61 +0,0 @@
"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";
import logger from "@/lib/logger";
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) {
logger.error({ err: error }, "Erreur lors de la modification du mode de cache:");
toast({
variant: "destructive",
title: "Erreur",
description: "Impossible de modifier le mode de cache",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center space-x-2">
<Switch
id="cache-mode"
checked={preferences.cacheMode === "memory"}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
<Label htmlFor="cache-mode" className="text-sm text-muted-foreground">
Cache en mémoire {isLoading && "(chargement...)"}
</Label>
</div>
);
}

View File

@@ -1,938 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { useToast } from "@/components/ui/use-toast";
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";
import logger from "@/lib/logger";
interface CacheSettingsProps {
initialTTLConfig: TTLConfigData | null;
}
interface CacheSizeInfo {
sizeInBytes: number;
itemCount: number;
}
interface CacheEntry {
key: string;
size: number;
expiry: number;
isExpired: boolean;
}
interface ServiceWorkerCacheEntry {
url: string;
size: number;
cacheName: string;
}
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);
const [swCacheSize, setSwCacheSize] = useState<number | null>(null);
const [apiCacheSize, setApiCacheSize] = useState<number | null>(null);
const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true);
const [cacheEntries, setCacheEntries] = useState<CacheEntry[]>([]);
const [isLoadingEntries, setIsLoadingEntries] = useState(false);
const [showEntries, setShowEntries] = useState(false);
const [swCacheEntries, setSwCacheEntries] = useState<ServiceWorkerCacheEntry[]>([]);
const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false);
const [showSwEntries, setShowSwEntries] = useState(false);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [expandedVersions, setExpandedVersions] = useState<Record<string, boolean>>({});
const [ttlConfig, setTTLConfig] = useState<TTLConfigData>(
initialTTLConfig || {
defaultTTL: 5,
homeTTL: 5,
librariesTTL: 1440,
seriesTTL: 5,
booksTTL: 5,
imagesTTL: 1440,
imageCacheMaxAge: 2592000,
}
);
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleString();
};
const getTimeRemaining = (expiry: number): string => {
const now = Date.now();
const diff = expiry - now;
if (diff < 0) return t("settings.cache.entries.expired");
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return t("settings.cache.entries.daysRemaining", { count: days });
if (hours > 0) return t("settings.cache.entries.hoursRemaining", { count: hours });
if (minutes > 0) return t("settings.cache.entries.minutesRemaining", { count: minutes });
return t("settings.cache.entries.lessThanMinute");
};
const getCacheType = (key: string): string => {
if (key.includes("/home")) return "HOME";
if (key.includes("/libraries")) return "LIBRARIES";
if (key.includes("/series/")) return "SERIES";
if (key.includes("/books/")) return "BOOKS";
if (key.includes("/images/")) return "IMAGES";
return "DEFAULT";
};
const fetchCacheSize = async () => {
setIsLoadingCacheSize(true);
try {
// Récupérer la taille du cache serveur
const serverResponse = await fetch("/api/komga/cache/size");
if (serverResponse.ok) {
const serverData = await serverResponse.json();
setServerCacheSize({
sizeInBytes: serverData.sizeInBytes,
itemCount: serverData.itemCount,
});
}
// Calculer la taille du cache Service Worker
if ("caches" in window) {
const cacheNames = await caches.keys();
let totalSize = 0;
let apiSize = 0;
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (response) {
const blob = await response.clone().blob();
totalSize += blob.size;
// Calculer la taille du cache API séparément
if (cacheName.includes("api")) {
apiSize += blob.size;
}
}
}
}
setSwCacheSize(totalSize);
setApiCacheSize(apiSize);
}
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la taille du cache:");
} finally {
setIsLoadingCacheSize(false);
}
};
const fetchCacheEntries = async () => {
setIsLoadingEntries(true);
try {
const response = await fetch("/api/komga/cache/entries");
if (response.ok) {
const data = await response.json();
setCacheEntries(data.entries);
}
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache:");
} finally {
setIsLoadingEntries(false);
}
};
const toggleShowEntries = () => {
if (!showEntries && cacheEntries.length === 0) {
fetchCacheEntries();
}
setShowEntries(!showEntries);
};
const fetchSwCacheEntries = async () => {
setIsLoadingSwEntries(true);
try {
if ("caches" in window) {
const entries: ServiceWorkerCacheEntry[] = [];
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (response) {
const blob = await response.clone().blob();
entries.push({
url: request.url,
size: blob.size,
cacheName,
});
}
}
}
setSwCacheEntries(entries);
}
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des entrées du cache SW:");
} finally {
setIsLoadingSwEntries(false);
}
};
const toggleShowSwEntries = () => {
if (!showSwEntries && swCacheEntries.length === 0) {
fetchSwCacheEntries();
}
setShowSwEntries(!showSwEntries);
};
const getPathGroup = (url: string): string => {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const segments = path.split("/").filter(Boolean);
if (segments.length === 0) return "/";
// Pour /api/komga/images, grouper par type (series/books)
if (
segments[0] === "api" &&
segments[1] === "komga" &&
segments[2] === "images" &&
segments[3]
) {
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
}
// Pour les autres, garder juste le premier segment
return `/${segments[0]}`;
} catch {
return "Autres";
}
};
const getBaseUrl = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.pathname;
} catch {
return url;
}
};
const groupVersions = (entries: ServiceWorkerCacheEntry[]) => {
const grouped = entries.reduce(
(acc, entry) => {
const baseUrl = getBaseUrl(entry.url);
if (!acc[baseUrl]) {
acc[baseUrl] = [];
}
acc[baseUrl].push(entry);
return acc;
},
{} as Record<string, ServiceWorkerCacheEntry[]>
);
// Trier par date (le plus récent en premier) basé sur le paramètre v
Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get("v") || "0";
const bVersion = new URL(b.url).searchParams.get("v") || "0";
return Number(bVersion) - Number(aVersion);
});
});
return grouped;
};
const groupEntriesByPath = (entries: ServiceWorkerCacheEntry[]) => {
const grouped = entries.reduce(
(acc, entry) => {
const pathGroup = getPathGroup(entry.url);
if (!acc[pathGroup]) {
acc[pathGroup] = [];
}
acc[pathGroup].push(entry);
return acc;
},
{} as Record<string, ServiceWorkerCacheEntry[]>
);
// Trier chaque groupe par taille décroissante
Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => b.size - a.size);
});
// Trier les groupes par taille totale décroissante
const sortedGroups: Record<string, ServiceWorkerCacheEntry[]> = {};
Object.entries(grouped)
.sort((a, b) => {
const aSize = getTotalSizeByType(a[1]);
const bSize = getTotalSizeByType(b[1]);
return bSize - aSize;
})
.forEach(([key, value]) => {
sortedGroups[key] = value;
});
return sortedGroups;
};
const getTotalSizeByType = (entries: ServiceWorkerCacheEntry[]): number => {
return entries.reduce((sum, entry) => sum + entry.size, 0);
};
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) => ({
...prev,
[groupName]: !prev[groupName],
}));
};
const toggleVersions = (fileName: string) => {
setExpandedVersions((prev) => ({
...prev,
[fileName]: !prev[fileName],
}));
};
useEffect(() => {
fetchCacheSize();
}, []);
const handleClearCache = async () => {
setIsCacheClearing(true);
try {
const response = await fetch("/api/komga/cache/clear", {
method: "POST",
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t("settings.cache.error.message"));
}
toast({
title: t("settings.cache.title"),
description: t("settings.cache.messages.cleared"),
});
// Rafraîchir la taille du cache et les entrées
await fetchCacheSize();
if (showEntries) {
await fetchCacheEntries();
}
if (showSwEntries) {
await fetchSwCacheEntries();
}
} catch (error) {
logger.error({ err: error }, "Erreur:");
toast({
variant: "destructive",
title: t("settings.cache.error.title"),
description: t("settings.cache.error.message"),
});
} finally {
setIsCacheClearing(false);
}
};
const handleClearServiceWorkerCache = async () => {
setIsServiceWorkerClearing(true);
try {
if ("serviceWorker" in navigator && "caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
// Forcer la mise à jour du service worker
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
toast({
title: t("settings.cache.title"),
description: t("settings.cache.messages.serviceWorkerCleared"),
});
// Rafraîchir la taille du cache et les entrées
await fetchCacheSize();
if (showEntries) {
await fetchCacheEntries();
}
if (showSwEntries) {
await fetchSwCacheEntries();
}
// Recharger la page après 1 seconde pour réenregistrer le SW
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression des caches:");
toast({
variant: "destructive",
title: t("settings.cache.error.title"),
description: t("settings.cache.error.serviceWorkerMessage"),
});
} finally {
setIsServiceWorkerClearing(false);
}
};
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,
[name]: parseInt(value || "0", 10),
}));
};
const handleSaveTTL = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const response = await fetch("/api/komga/ttl-config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(ttlConfig),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || t("settings.cache.error.message"));
}
toast({
title: t("settings.cache.title"),
description: t("settings.cache.messages.ttlSaved"),
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la sauvegarde:");
toast({
variant: "destructive",
title: t("settings.cache.error.title"),
description: t("settings.cache.error.messagettl"),
});
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5" />
{t("settings.cache.title")}
</CardTitle>
<CardDescription>{t("settings.cache.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
<p className="text-sm text-muted-foreground">{t("settings.cache.mode.description")}</p>
</div>
<CacheModeSwitch />
</div>
{/* Informations sur la taille du cache */}
<div className="rounded-md border bg-muted/50 backdrop-blur-md p-4 space-y-3">
<div className="flex items-center gap-2 font-medium">
<HardDrive className="h-4 w-4" />
{t("settings.cache.size.title")}
</div>
{isLoadingCacheSize ? (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.loading")}</div>
) : (
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<div className="text-sm font-medium">{t("settings.cache.size.server")}</div>
{serverCacheSize ? (
<div className="text-sm text-muted-foreground">
<div>{formatBytes(serverCacheSize.sizeInBytes)}</div>
<div className="text-xs">
{t("settings.cache.size.items", { count: serverCacheSize.itemCount })}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
<div className="space-y-1">
<div className="text-sm font-medium">{t("settings.cache.size.serviceWorker")}</div>
{swCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
) : (
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
<div className="space-y-1">
<div className="text-sm font-medium">{t("settings.cache.size.api")}</div>
{apiCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
) : (
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
</div>
)}
</div>
{/* Aperçu des entrées du cache serveur */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
onClick={toggleShowEntries}
className="w-full flex items-center justify-between"
>
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
{t("settings.cache.entries.serverTitle")}
</span>
{showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{showEntries && (
<div className="rounded-md border bg-muted/30 backdrop-blur-md">
{isLoadingEntries ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
{t("settings.cache.entries.loading")}
</div>
) : cacheEntries.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("settings.cache.entries.empty")}
</div>
) : (
<div className="max-h-96 overflow-y-auto">
<div className="divide-y">
{cacheEntries.map((entry, index) => (
<div
key={index}
className={`p-3 space-y-1 ${entry.isExpired ? "opacity-50" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate" title={entry.key}>
{entry.key}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium">
{getCacheType(entry.key)}
</span>
<span>{formatBytes(entry.size)}</span>
</div>
</div>
<div className="text-right text-xs">
<div
className={`font-medium ${entry.isExpired ? "text-destructive" : "text-muted-foreground"}`}
>
{getTimeRemaining(entry.expiry)}
</div>
<div
className="text-muted-foreground/70"
title={formatDate(entry.expiry)}
>
{new Date(entry.expiry).toLocaleDateString()}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Aperçu des entrées du cache service worker */}
<div className="space-y-3">
<Button
type="button"
variant="outline"
onClick={toggleShowSwEntries}
className="w-full flex items-center justify-between"
>
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
{t("settings.cache.entries.serviceWorkerTitle")}
</span>
{showSwEntries ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
{showSwEntries && (
<div className="rounded-md border bg-muted/30 backdrop-blur-md">
{isLoadingSwEntries ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
{t("settings.cache.entries.loading")}
</div>
) : swCacheEntries.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("settings.cache.entries.empty")}
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{(() => {
const grouped = groupEntriesByPath(swCacheEntries);
return (
<div className="divide-y">
{Object.entries(grouped).map(([pathGroup, entries]) => {
const isExpanded = expandedGroups[pathGroup];
return (
<div key={pathGroup} className="p-3 space-y-2">
<button
type="button"
onClick={() => toggleGroup(pathGroup)}
className="w-full flex items-center justify-between hover:bg-muted/50 rounded p-1 -m-1 transition-colors"
>
<div className="font-medium text-sm flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
<span className="inline-flex items-center rounded-full bg-blue-500/10 px-2 py-0.5 text-xs font-medium font-mono">
{pathGroup}
</span>
<span className="text-xs text-muted-foreground">
({entries.length} {entries.length > 1 ? "éléments" : "élément"})
</span>
</div>
<div className="text-xs text-muted-foreground font-medium">
{formatBytes(getTotalSizeByType(entries))}
</div>
</button>
{isExpanded && (
<div className="space-y-1 pl-2">
{(() => {
const versionGroups = groupVersions(entries);
return Object.entries(versionGroups).map(
([baseUrl, versions]) => {
const hasMultipleVersions = versions.length > 1;
const isVersionExpanded = expandedVersions[baseUrl];
const totalSize = versions.reduce(
(sum, v) => sum + v.size,
0
);
if (!hasMultipleVersions) {
const entry = versions[0];
return (
<div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs truncate text-muted-foreground"
title={entry.url}
>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div>
</div>
);
}
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div
className="font-mono text-xs truncate text-muted-foreground"
title={baseUrl}
>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div
key={vIdx}
className="py-0.5 flex items-start justify-between gap-2"
>
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs truncate text-muted-foreground/70"
title={version.url}
>
{new URL(version.url).search ||
"(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div>
);
}
);
})()}
</div>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
)}
</div>
{/* Formulaire TTL */}
<form onSubmit={handleSaveTTL} className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="defaultTTL" className="text-sm font-medium">
{t("settings.cache.ttl.default")}
</label>
<input
type="number"
id="defaultTTL"
name="defaultTTL"
min="1"
value={ttlConfig.defaultTTL}
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 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">
<label htmlFor="homeTTL" className="text-sm font-medium">
{t("settings.cache.ttl.home")}
</label>
<input
type="number"
id="homeTTL"
name="homeTTL"
min="1"
value={ttlConfig.homeTTL}
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 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">
<label htmlFor="librariesTTL" className="text-sm font-medium">
{t("settings.cache.ttl.libraries")}
</label>
<input
type="number"
id="librariesTTL"
name="librariesTTL"
min="1"
value={ttlConfig.librariesTTL}
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 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">
<label htmlFor="seriesTTL" className="text-sm font-medium">
{t("settings.cache.ttl.series")}
</label>
<input
type="number"
id="seriesTTL"
name="seriesTTL"
min="1"
value={ttlConfig.seriesTTL}
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 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">
<label htmlFor="booksTTL" className="text-sm font-medium">
{t("settings.cache.ttl.books")}
</label>
<input
type="number"
id="booksTTL"
name="booksTTL"
min="1"
value={ttlConfig.booksTTL}
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 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">
<label htmlFor="imagesTTL" className="text-sm font-medium">
{t("settings.cache.ttl.images")}
</label>
<input
type="number"
id="imagesTTL"
name="imagesTTL"
min="1"
value={ttlConfig.imagesTTL}
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 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
type="submit"
className="flex-1 inline-flex items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/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"
>
{t("settings.cache.buttons.saveTTL")}
</button>
<button
type="button"
onClick={handleClearCache}
disabled={isCacheClearing}
className="flex-1 inline-flex items-center justify-center rounded-md bg-destructive/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-destructive-foreground ring-offset-background transition-colors hover:bg-destructive/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"
>
{isCacheClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.cache.buttons.clearing")}
</>
) : (
t("settings.cache.buttons.clear")
)}
</button>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={handleClearServiceWorkerCache}
disabled={isServiceWorkerClearing}
className="flex-1 inline-flex items-center justify-center rounded-md bg-destructive/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-destructive-foreground ring-offset-background transition-colors hover:bg-destructive/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"
>
{isServiceWorkerClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.cache.buttons.clearingServiceWorker")}
</>
) : (
t("settings.cache.buttons.clearServiceWorker")
)}
</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

@@ -1,21 +1,19 @@
"use client"; "use client";
import type { KomgaConfig, TTLConfigData } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { DisplaySettings } from "./DisplaySettings"; import { DisplaySettings } from "./DisplaySettings";
import { KomgaSettings } from "./KomgaSettings"; import { KomgaSettings } from "./KomgaSettings";
import { CacheSettings } from "./CacheSettings";
import { BackgroundSettings } from "./BackgroundSettings"; import { BackgroundSettings } from "./BackgroundSettings";
import { AdvancedSettings } from "./AdvancedSettings"; import { AdvancedSettings } from "./AdvancedSettings";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Monitor, Network, HardDrive } from "lucide-react"; import { Monitor, Network } from "lucide-react";
interface ClientSettingsProps { interface ClientSettingsProps {
initialConfig: KomgaConfig | null; initialConfig: KomgaConfig | null;
initialTTLConfig: TTLConfigData | null;
} }
export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettingsProps) { export function ClientSettings({ initialConfig }: ClientSettingsProps) {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
@@ -23,7 +21,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
<h1 className="text-3xl font-bold">{t("settings.title")}</h1> <h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<Tabs defaultValue="display" className="w-full"> <Tabs defaultValue="display" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="display" className="flex items-center gap-2"> <TabsTrigger value="display" className="flex items-center gap-2">
<Monitor className="h-4 w-4" /> <Monitor className="h-4 w-4" />
{t("settings.tabs.display")} {t("settings.tabs.display")}
@@ -32,10 +30,6 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
{t("settings.tabs.connection")} {t("settings.tabs.connection")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="cache" className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
{t("settings.tabs.cache")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="display" className="mt-6 space-y-6"> <TabsContent value="display" className="mt-6 space-y-6">
@@ -47,10 +41,6 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
<KomgaSettings initialConfig={initialConfig} /> <KomgaSettings initialConfig={initialConfig} />
<AdvancedSettings /> <AdvancedSettings />
</TabsContent> </TabsContent>
<TabsContent value="cache" className="mt-6 space-y-6">
<CacheSettings initialTTLConfig={initialTTLConfig} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );

View File

@@ -4,7 +4,6 @@ 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 "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
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";
@@ -63,8 +62,7 @@ export function BookCover({
const { t } = useTranslate(); const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id); const { isAccessible } = useBookOfflineStatus(book.id);
const baseUrl = getImageUrl("book", book.id); const imageUrl = 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

@@ -4,7 +4,6 @@ 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 "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
import { useImageUrl } from "@/hooks/useImageUrl";
export function SeriesCover({ export function SeriesCover({
series, series,
@@ -12,8 +11,7 @@ export function SeriesCover({
className, className,
showProgressUi = true, showProgressUi = true,
}: SeriesCoverProps) { }: SeriesCoverProps) {
const baseUrl = getImageUrl("series", series.id); const imageUrl = 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

@@ -63,16 +63,6 @@ export const ERROR_CODES = {
UPDATE_ERROR: "PREFERENCES_UPDATE_ERROR", UPDATE_ERROR: "PREFERENCES_UPDATE_ERROR",
CONTEXT_ERROR: "PREFERENCES_CONTEXT_ERROR", CONTEXT_ERROR: "PREFERENCES_CONTEXT_ERROR",
}, },
CACHE: {
DELETE_ERROR: "CACHE_DELETE_ERROR",
SAVE_ERROR: "CACHE_SAVE_ERROR",
LOAD_ERROR: "CACHE_LOAD_ERROR",
CLEAR_ERROR: "CACHE_CLEAR_ERROR",
MODE_FETCH_ERROR: "CACHE_MODE_FETCH_ERROR",
MODE_UPDATE_ERROR: "CACHE_MODE_UPDATE_ERROR",
INVALID_MODE: "CACHE_INVALID_MODE",
SIZE_FETCH_ERROR: "CACHE_SIZE_FETCH_ERROR",
},
UI: { UI: {
TABS_TRIGGER_ERROR: "UI_TABS_TRIGGER_ERROR", TABS_TRIGGER_ERROR: "UI_TABS_TRIGGER_ERROR",
TABS_CONTENT_ERROR: "UI_TABS_CONTENT_ERROR", TABS_CONTENT_ERROR: "UI_TABS_CONTENT_ERROR",

View File

@@ -60,16 +60,6 @@ export const ERROR_MESSAGES: Record<string, string> = {
[ERROR_CODES.PREFERENCES.CONTEXT_ERROR]: [ERROR_CODES.PREFERENCES.CONTEXT_ERROR]:
"🔄 usePreferences must be used within a PreferencesProvider", "🔄 usePreferences must be used within a PreferencesProvider",
// Cache
[ERROR_CODES.CACHE.DELETE_ERROR]: "🗑️ Error deleting cache",
[ERROR_CODES.CACHE.SAVE_ERROR]: "💾 Error saving to cache",
[ERROR_CODES.CACHE.LOAD_ERROR]: "📂 Error loading from cache",
[ERROR_CODES.CACHE.CLEAR_ERROR]: "🧹 Error clearing cache completely",
[ERROR_CODES.CACHE.MODE_FETCH_ERROR]: "⚙️ Error fetching cache mode",
[ERROR_CODES.CACHE.MODE_UPDATE_ERROR]: "⚙️ Error updating cache mode",
[ERROR_CODES.CACHE.INVALID_MODE]: "⚠️ Invalid cache mode. Must be 'file' or 'memory'",
[ERROR_CODES.CACHE.SIZE_FETCH_ERROR]: "📊 Error fetching cache size",
// UI // UI
[ERROR_CODES.UI.TABS_TRIGGER_ERROR]: "🔄 TabsTrigger must be used within a Tabs component", [ERROR_CODES.UI.TABS_TRIGGER_ERROR]: "🔄 TabsTrigger must be used within a Tabs component",
[ERROR_CODES.UI.TABS_CONTENT_ERROR]: "🔄 TabsContent must be used within a Tabs component", [ERROR_CODES.UI.TABS_CONTENT_ERROR]: "🔄 TabsContent must be used within a Tabs component",

View File

@@ -1,58 +0,0 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import logger from "@/lib/logger";
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);
logger.info(`🗑️ 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;
}

View File

@@ -1,14 +0,0 @@
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

@@ -69,8 +69,7 @@
"title": "Preferences", "title": "Preferences",
"tabs": { "tabs": {
"display": "Display", "display": "Display",
"connection": "Connection", "connection": "Connection"
"cache": "Cache"
}, },
"display": { "display": {
"title": "Display Preferences", "title": "Display Preferences",
@@ -106,31 +105,9 @@
"title": "Advanced Settings", "title": "Advanced Settings",
"description": "Configure advanced performance and reliability settings.", "description": "Configure advanced performance and reliability settings.",
"save": "Save settings", "save": "Save settings",
"maxConcurrentRequests": {
"label": "Max Concurrent Requests",
"description": "Maximum number of simultaneous requests to Komga server (1-10)"
},
"prefetchCount": { "prefetchCount": {
"label": "Reader Prefetch Count", "label": "Reader Prefetch Count",
"description": "Number of pages to preload in the reader (0-20)" "description": "Number of pages to preload in the reader (0-20)"
},
"circuitBreaker": {
"title": "Circuit Breaker",
"description": "Automatic protection against server overload",
"threshold": {
"label": "Failure Threshold",
"description": "Number of consecutive failures before opening the circuit (1-20)"
},
"timeout": {
"label": "Request Timeout",
"description": "Maximum wait time for a request before considering it failed",
"unit": "milliseconds (1000ms = 1 second)"
},
"resetTimeout": {
"label": "Reset Timeout",
"description": "Time to wait before attempting to close the circuit",
"unit": "milliseconds (1000ms = 1 second)"
}
} }
}, },
"error": { "error": {
@@ -159,75 +136,6 @@
"title": "Error saving configuration", "title": "Error saving configuration",
"message": "An error occurred while saving the configuration" "message": "An error occurred while saving the configuration"
} }
},
"cache": {
"title": "Cache Configuration",
"description": "Manage data caching settings.",
"mode": {
"label": "Cache mode",
"description": "Memory cache is faster but doesn't persist between restarts"
},
"size": {
"title": "Cache size",
"server": "Server cache",
"serviceWorker": "SW cache (total)",
"api": "API cache (data)",
"items": "{count} item(s)",
"loading": "Loading...",
"error": "Error loading"
},
"ttl": {
"default": "Default TTL (minutes)",
"home": "Home page TTL",
"libraries": "Libraries TTL",
"series": "Series TTL",
"books": "Books 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...",
"flushImageCache": "Force reload images"
},
"messages": {
"ttlSaved": "TTL configuration saved successfully",
"cleared": "Server cache cleared successfully",
"serviceWorkerCleared": "Service worker cache cleared successfully",
"imageCacheFlushed": "Images will be reloaded - refresh the page"
},
"error": {
"title": "Error clearing cache",
"message": "An error occurred while clearing the cache",
"ttl": "Error saving TTL configuration",
"serviceWorkerMessage": "An error occurred while clearing the service worker cache"
},
"entries": {
"title": "Cache content preview",
"serverTitle": "Server cache preview",
"serviceWorkerTitle": "Service worker cache preview",
"loading": "Loading entries...",
"empty": "No entries in cache",
"expired": "Expired",
"daysRemaining": "{count} day(s) remaining",
"hoursRemaining": "{count} hour(s) remaining",
"minutesRemaining": "{count} minute(s) remaining",
"lessThanMinute": "Less than a minute"
}
} }
}, },
"library": { "library": {
@@ -463,14 +371,6 @@
"PREFERENCES_UPDATE_ERROR": "Error updating preferences", "PREFERENCES_UPDATE_ERROR": "Error updating preferences",
"PREFERENCES_CONTEXT_ERROR": "Preferences context error", "PREFERENCES_CONTEXT_ERROR": "Preferences context error",
"CACHE_DELETE_ERROR": "Error deleting cache",
"CACHE_SAVE_ERROR": "Error saving cache",
"CACHE_LOAD_ERROR": "Error loading cache",
"CACHE_CLEAR_ERROR": "Error clearing cache",
"CACHE_MODE_FETCH_ERROR": "Error fetching cache mode",
"CACHE_MODE_UPDATE_ERROR": "Error updating cache mode",
"CACHE_INVALID_MODE": "Invalid cache mode",
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs", "UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
"UI_TABS_CONTENT_ERROR": "Error loading tabs content", "UI_TABS_CONTENT_ERROR": "Error loading tabs content",

View File

@@ -69,8 +69,7 @@
"title": "Préférences", "title": "Préférences",
"tabs": { "tabs": {
"display": "Affichage", "display": "Affichage",
"connection": "Connexion", "connection": "Connexion"
"cache": "Cache"
}, },
"display": { "display": {
"title": "Préférences d'affichage", "title": "Préférences d'affichage",
@@ -106,31 +105,9 @@
"title": "Paramètres avancés", "title": "Paramètres avancés",
"description": "Configurez les paramètres avancés de performance et de fiabilité.", "description": "Configurez les paramètres avancés de performance et de fiabilité.",
"save": "Enregistrer les paramètres", "save": "Enregistrer les paramètres",
"maxConcurrentRequests": {
"label": "Requêtes simultanées max",
"description": "Nombre maximum de requêtes simultanées vers le serveur Komga (1-10)"
},
"prefetchCount": { "prefetchCount": {
"label": "Préchargement du lecteur", "label": "Préchargement du lecteur",
"description": "Nombre de pages à précharger dans le lecteur (0-20)" "description": "Nombre de pages à précharger dans le lecteur (0-20)"
},
"circuitBreaker": {
"title": "Disjoncteur",
"description": "Protection automatique contre la surcharge du serveur",
"threshold": {
"label": "Seuil d'échec",
"description": "Nombre d'échecs consécutifs avant ouverture du circuit (1-20)"
},
"timeout": {
"label": "Délai d'expiration",
"description": "Temps d'attente maximum pour une requête avant de la considérer comme échouée",
"unit": "millisecondes (1000ms = 1 seconde)"
},
"resetTimeout": {
"label": "Délai de réinitialisation",
"description": "Temps d'attente avant de tenter de fermer le circuit",
"unit": "millisecondes (1000ms = 1 seconde)"
}
} }
}, },
"error": { "error": {
@@ -159,75 +136,6 @@
"title": "Erreur lors de la sauvegarde de la configuration", "title": "Erreur lors de la sauvegarde de la configuration",
"message": "Une erreur est survenue lors de la sauvegarde de la configuration" "message": "Une erreur est survenue lors de la sauvegarde de la configuration"
} }
},
"cache": {
"title": "Configuration du Cache",
"description": "Gérez les paramètres de mise en cache des données.",
"mode": {
"label": "Mode de cache",
"description": "Le cache en mémoire est plus rapide mais ne persiste pas entre les redémarrages"
},
"size": {
"title": "Taille du cache",
"server": "Cache serveur",
"serviceWorker": "Cache SW (total)",
"api": "Cache API (données)",
"items": "{count} élément(s)",
"loading": "Chargement...",
"error": "Erreur lors du chargement"
},
"ttl": {
"default": "TTL par défaut (minutes)",
"home": "TTL page d'accueil",
"libraries": "TTL bibliothèques",
"series": "TTL séries",
"books": "TTL tomes",
"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...",
"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",
"imageCacheFlushed": "Les images seront rechargées - rafraîchissez la page"
},
"error": {
"title": "Erreur lors de la suppression du cache",
"message": "Une erreur est survenue lors de la suppression du cache",
"ttl": "Erreur lors de la sauvegarde de la configuration TTL",
"serviceWorkerMessage": "Une erreur est survenue lors de la suppression du cache du service worker"
},
"entries": {
"title": "Aperçu du contenu du cache",
"serverTitle": "Aperçu du cache serveur",
"serviceWorkerTitle": "Aperçu du cache service worker",
"loading": "Chargement des entrées...",
"empty": "Aucune entrée dans le cache",
"expired": "Expiré",
"daysRemaining": "{count} jour(s) restant(s)",
"hoursRemaining": "{count} heure(s) restante(s)",
"minutesRemaining": "{count} minute(s) restante(s)",
"lessThanMinute": "Moins d'une minute"
}
} }
}, },
"library": { "library": {
@@ -461,14 +369,6 @@
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences", "PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences", "PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
"CACHE_DELETE_ERROR": "Erreur lors de la suppression du cache",
"CACHE_SAVE_ERROR": "Erreur lors de la sauvegarde du cache",
"CACHE_LOAD_ERROR": "Erreur lors du chargement du cache",
"CACHE_CLEAR_ERROR": "Erreur lors de la suppression du cache",
"CACHE_MODE_FETCH_ERROR": "Erreur lors de la récupération du mode de cache",
"CACHE_MODE_UPDATE_ERROR": "Erreur lors de la mise à jour du mode de cache",
"CACHE_INVALID_MODE": "Mode de cache invalide",
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets", "UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets", "UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",

View File

@@ -1,19 +1,10 @@
import type { AuthConfig } from "@/types/auth"; import type { AuthConfig } from "@/types/auth";
import type { CacheType } from "@/types/cache";
import { getServerCacheService } from "./server-cache.service";
import { ConfigDBService } from "./config-db.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 type { KomgaConfig } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import type { ServerCacheService } from "./server-cache.service";
import { RequestMonitorService } from "./request-monitor.service";
import { RequestQueueService } from "./request-queue.service";
import { CircuitBreakerService } from "./circuit-breaker.service";
import { PreferencesService } from "./preferences.service";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export type { CacheType };
interface KomgaRequestInit extends RequestInit { interface KomgaRequestInit extends RequestInit {
isImage?: boolean; isImage?: boolean;
noJson?: boolean; noJson?: boolean;
@@ -25,68 +16,7 @@ interface KomgaUrlBuilder {
} }
export abstract class BaseApiService { export abstract class BaseApiService {
private static requestQueueInitialized = false;
private static circuitBreakerInitialized = false;
/**
* Initialise le RequestQueueService avec les préférences de l'utilisateur
*/
private static async initializeRequestQueue(): Promise<void> {
if (this.requestQueueInitialized) {
return;
}
try {
// Configurer le getter qui récupère dynamiquement la valeur depuis les préférences
RequestQueueService.setMaxConcurrentGetter(async () => {
try {
const preferences = await PreferencesService.getPreferences();
return preferences.komgaMaxConcurrentRequests;
} catch (error) {
logger.error({ err: error }, "Failed to get preferences for request queue");
return 5; // Valeur par défaut
}
});
this.requestQueueInitialized = true;
} catch (error) {
logger.error({ err: error }, "Failed to initialize request queue");
}
}
/**
* Initialise le CircuitBreakerService avec les préférences de l'utilisateur
*/
private static async initializeCircuitBreaker(): Promise<void> {
if (this.circuitBreakerInitialized) {
return;
}
try {
// Configurer le getter qui récupère dynamiquement la config depuis les préférences
CircuitBreakerService.setConfigGetter(async () => {
try {
const preferences = await PreferencesService.getPreferences();
return preferences.circuitBreakerConfig;
} catch (error) {
logger.error({ err: error }, "Failed to get preferences for circuit breaker");
return {
threshold: 5,
timeout: 30000,
resetTimeout: 60000,
};
}
});
this.circuitBreakerInitialized = true;
} catch (error) {
logger.error({ err: error }, "Failed to initialize circuit breaker");
}
}
protected static async getKomgaConfig(): Promise<AuthConfig> { protected static async getKomgaConfig(): Promise<AuthConfig> {
// Initialiser les services si ce n'est pas déjà fait
await Promise.all([this.initializeRequestQueue(), this.initializeCircuitBreaker()]);
try { try {
const config: KomgaConfig | null = await ConfigDBService.getConfig(); const config: KomgaConfig | null = await ConfigDBService.getConfig();
if (!config) { if (!config) {
@@ -117,22 +47,6 @@ export abstract class BaseApiService {
}); });
} }
protected static async fetchWithCache<T>(
key: string,
fetcher: () => Promise<T>,
type: CacheType = "DEFAULT"
): Promise<T> {
const cacheService: ServerCacheService = await getServerCacheService();
try {
const result = await cacheService.getOrSet(key, fetcher, type);
return result;
} catch (error) {
throw error;
}
}
protected static buildUrl( protected static buildUrl(
config: AuthConfig, config: AuthConfig,
path: string, path: string,
@@ -159,12 +73,6 @@ export abstract class BaseApiService {
return url.toString(); return url.toString();
} }
protected static async resolveWithFallback(url: string): Promise<string> {
// DNS resolution is only needed server-side and causes build issues
// The fetch API will handle DNS resolution automatically
return url;
}
protected static async fetchFromApi<T>( protected static async fetchFromApi<T>(
urlBuilder: KomgaUrlBuilder, urlBuilder: KomgaUrlBuilder,
headersOptions = {}, headersOptions = {},
@@ -172,7 +80,7 @@ export abstract class BaseApiService {
): Promise<T> { ): Promise<T> {
const config: AuthConfig = await this.getKomgaConfig(); const config: AuthConfig = await this.getKomgaConfig();
const { path, params } = urlBuilder; const { path, params } = urlBuilder;
const url = await this.resolveWithFallback(this.buildUrl(config, path, params)); const url = this.buildUrl(config, path, params);
const headers: Headers = this.getAuthHeaders(config); const headers: Headers = this.getAuthHeaders(config);
if (headersOptions) { if (headersOptions) {
@@ -185,10 +93,6 @@ export abstract class BaseApiService {
const startTime = isDebug ? Date.now() : 0; const startTime = isDebug ? Date.now() : 0;
if (isDebug) { if (isDebug) {
const queueStats = {
active: RequestQueueService.getActiveCount(),
queued: RequestQueueService.getQueueLength(),
};
logger.info( logger.info(
{ {
url, url,
@@ -196,28 +100,24 @@ export abstract class BaseApiService {
params, params,
isImage: options.isImage, isImage: options.isImage,
noJson: options.noJson, noJson: options.noJson,
queue: queueStats,
}, },
"🔵 Komga Request" "🔵 Komga Request"
); );
} }
// Timeout réduit à 15 secondes pour éviter les blocages longs // Timeout de 15 secondes pour éviter les blocages longs
const timeoutMs = 15000; const timeoutMs = 15000;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
// Utiliser le circuit breaker pour éviter de surcharger Komga let response: Response;
const response = await CircuitBreakerService.execute(async () => {
// Enqueue la requête pour limiter la concurrence
return await RequestQueueService.enqueue(async () => {
try { try {
return await fetch(url, { response = await fetch(url, {
headers, headers,
...options, ...options,
signal: controller.signal, signal: controller.signal,
// Configure undici connection timeouts
// @ts-ignore - undici-specific options not in standard fetch types // @ts-ignore - undici-specific options not in standard fetch types
connectTimeout: timeoutMs, connectTimeout: timeoutMs,
bodyTimeout: timeoutMs, bodyTimeout: timeoutMs,
@@ -226,12 +126,9 @@ export abstract class BaseApiService {
} catch (fetchError: any) { } catch (fetchError: any) {
// Gestion spécifique des erreurs DNS // Gestion spécifique des erreurs DNS
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") { if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
logger.error( logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
`DNS resolution failed for ${url}. Retrying with different DNS settings...`
);
// Retry avec des paramètres DNS différents response = await fetch(url, {
return await fetch(url, {
headers, headers,
...options, ...options,
signal: controller.signal, signal: controller.signal,
@@ -243,13 +140,11 @@ export abstract class BaseApiService {
// @ts-ignore // @ts-ignore
family: 4, family: 4,
}); });
} } else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
// Retry automatique sur timeout de connexion (cold start) // Retry automatique sur timeout de connexion (cold start)
if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`); logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
return await fetch(url, { response = await fetch(url, {
headers, headers,
...options, ...options,
signal: controller.signal, signal: controller.signal,
@@ -258,12 +153,11 @@ export abstract class BaseApiService {
bodyTimeout: timeoutMs, bodyTimeout: timeoutMs,
headersTimeout: timeoutMs, headersTimeout: timeoutMs,
}); });
} } else {
throw fetchError; throw fetchError;
} }
}); }
});
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (isDebug) { if (isDebug) {
@@ -320,7 +214,6 @@ export abstract class BaseApiService {
throw error; throw error;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
RequestMonitorService.decrementActive();
} }
} }
} }

View File

@@ -1,30 +1,18 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga"; import type { KomgaBook, KomgaBookWithPages } 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 { SeriesService } from "./series.service"; import { SeriesService } from "./series.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import logger from "@/lib/logger";
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
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) {
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
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>(
`book-${bookId}`,
async () => {
// Récupération parallèle des détails du tome et des pages // Récupération parallèle des détails du tome et des pages
const [book, pages] = await Promise.all([ const [book, pages] = await Promise.all([
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }), this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
@@ -35,13 +23,11 @@ export class BookService extends BaseApiService {
book, book,
pages: pages.map((page: any) => page.number), pages: pages.map((page: any) => page.number),
}; };
},
"BOOKS"
);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error); throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
} }
} }
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> { public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
try { try {
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant // Utiliser l'endpoint natif Komga pour obtenir le livre suivant
@@ -63,7 +49,6 @@ export class BookService extends BaseApiService {
static async getBookSeriesId(bookId: string): Promise<string> { static async getBookSeriesId(bookId: string): Promise<string> {
try { try {
// Récupérer le livre sans cache pour éviter les données obsolètes
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }); const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
return book.seriesId; return book.seriesId;
} catch (error) { } catch (error) {
@@ -136,12 +121,10 @@ 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=${maxAge}, immutable`, "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
}, },
}); });
} catch (error) { } catch (error) {
@@ -153,7 +136,6 @@ 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) {
@@ -161,7 +143,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=${maxAge}, immutable`, "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
}, },
}); });
} }
@@ -186,12 +168,11 @@ 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=${maxAge}, immutable`, "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -1,114 +0,0 @@
/**
* Circuit Breaker pour éviter de surcharger Komga quand il est défaillant
* Évite l'effet avalanche en coupant les requêtes vers un service défaillant
*/
import type { CircuitBreakerConfig } from "@/types/preferences";
import logger from "@/lib/logger";
interface CircuitBreakerState {
state: "CLOSED" | "OPEN" | "HALF_OPEN";
failureCount: number;
lastFailureTime: number;
nextAttemptTime: number;
}
class CircuitBreaker {
private state: CircuitBreakerState = {
state: "CLOSED",
failureCount: 0,
lastFailureTime: 0,
nextAttemptTime: 0,
};
private config = {
failureThreshold: 5, // Nombre d'échecs avant ouverture
recoveryTimeout: 30000, // 30s avant tentative de récupération
resetTimeout: 60000, // Délai de reset après échec
};
private getConfigFromPreferences: (() => Promise<CircuitBreakerConfig>) | null = null;
/**
* Configure une fonction pour récupérer dynamiquement la config depuis les préférences
*/
setConfigGetter(getter: () => Promise<CircuitBreakerConfig>): void {
this.getConfigFromPreferences = getter;
}
/**
* Récupère la config actuelle, soit depuis les préférences, soit depuis les valeurs par défaut
*/
private async getCurrentConfig(): Promise<typeof this.config> {
if (this.getConfigFromPreferences) {
try {
const prefConfig = await this.getConfigFromPreferences();
return {
failureThreshold: prefConfig.threshold ?? 5,
recoveryTimeout: prefConfig.timeout ?? 30000,
resetTimeout: prefConfig.resetTimeout ?? 60000,
};
} catch (error) {
logger.error({ err: error }, "Error getting circuit breaker config from preferences");
return this.config;
}
}
return this.config;
}
async execute<T>(operation: () => Promise<T>): Promise<T> {
const config = await this.getCurrentConfig();
if (this.state.state === "OPEN") {
if (Date.now() < this.state.nextAttemptTime) {
throw new Error("Circuit breaker is OPEN - Komga service unavailable");
}
this.state.state = "HALF_OPEN";
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
await this.onFailure(config);
throw error;
}
}
private onSuccess(): void {
if (this.state.state === "HALF_OPEN") {
this.state.failureCount = 0;
this.state.state = "CLOSED";
logger.info("[CIRCUIT-BREAKER] ✅ Circuit closed - Komga recovered");
}
}
private async onFailure(config: typeof this.config): Promise<void> {
this.state.failureCount++;
this.state.lastFailureTime = Date.now();
if (this.state.failureCount >= config.failureThreshold) {
this.state.state = "OPEN";
this.state.nextAttemptTime = Date.now() + config.resetTimeout;
logger.warn(
`[CIRCUIT-BREAKER] 🔴 Circuit OPEN - Komga failing (${this.state.failureCount} failures, reset in ${config.resetTimeout}ms)`
);
}
}
getState(): CircuitBreakerState {
return { ...this.state };
}
reset(): void {
this.state = {
state: "CLOSED",
failureCount: 0,
lastFailureTime: 0,
nextAttemptTime: 0,
};
logger.info("[CIRCUIT-BREAKER] 🔄 Circuit reset");
}
}
export const CircuitBreakerService = new CircuitBreaker();

View File

@@ -2,7 +2,7 @@ import prisma from "@/lib/prisma";
import { getCurrentUser } from "../auth-utils"; import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import type { User, KomgaConfigData, TTLConfigData, KomgaConfig, TTLConfig } from "@/types/komga"; import type { User, KomgaConfigData, KomgaConfig } from "@/types/komga";
export class ConfigDBService { export class ConfigDBService {
private static async getCurrentUser(): Promise<User> { private static async getCurrentUser(): Promise<User> {
@@ -62,58 +62,4 @@ export class ConfigDBService {
throw new AppError(ERROR_CODES.CONFIG.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.CONFIG.FETCH_ERROR, {}, error);
} }
} }
static async getTTLConfig(): Promise<TTLConfig | null> {
try {
const user: User | null = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const config = await prisma.tTLConfig.findUnique({
where: { userId },
});
return config as TTLConfig | null;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.CONFIG.TTL_FETCH_ERROR, {}, error);
}
}
static async saveTTLConfig(data: TTLConfigData): Promise<TTLConfig> {
try {
const user: User | null = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const config = await prisma.tTLConfig.upsert({
where: { userId },
update: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
imageCacheMaxAge: data.imageCacheMaxAge,
},
create: {
userId,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
imageCacheMaxAge: data.imageCacheMaxAge,
},
});
return config as TTLConfig;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.CONFIG.TTL_SAVE_ERROR, {}, error);
}
}
} }

View File

@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { HomeData } from "@/types/home"; import type { HomeData } from "@/types/home";
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,9 +11,6 @@ export class HomeService extends BaseApiService {
static async getHomeData(): Promise<HomeData> { static async getHomeData(): Promise<HomeData> {
try { try {
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([ const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
"home-ongoing",
async () =>
this.fetchFromApi<LibraryResponse<KomgaSeries>>({ this.fetchFromApi<LibraryResponse<KomgaSeries>>({
path: "series", path: "series",
params: { params: {
@@ -25,11 +21,6 @@ export class HomeService extends BaseApiService {
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME"
),
this.fetchWithCache<LibraryResponse<KomgaBook>>(
"home-ongoing-books",
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>({ this.fetchFromApi<LibraryResponse<KomgaBook>>({
path: "books", path: "books",
params: { params: {
@@ -40,11 +31,6 @@ export class HomeService extends BaseApiService {
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME"
),
this.fetchWithCache<LibraryResponse<KomgaBook>>(
"home-recently-read",
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>({ this.fetchFromApi<LibraryResponse<KomgaBook>>({
path: "books/latest", path: "books/latest",
params: { params: {
@@ -53,11 +39,6 @@ export class HomeService extends BaseApiService {
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME"
),
this.fetchWithCache<LibraryResponse<KomgaBook>>(
"home-on-deck",
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>({ this.fetchFromApi<LibraryResponse<KomgaBook>>({
path: "books/ondeck", path: "books/ondeck",
params: { params: {
@@ -66,11 +47,6 @@ export class HomeService extends BaseApiService {
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME"
),
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
"home-latest-series",
async () =>
this.fetchFromApi<LibraryResponse<KomgaSeries>>({ this.fetchFromApi<LibraryResponse<KomgaSeries>>({
path: "series/latest", path: "series/latest",
params: { params: {
@@ -79,13 +55,11 @@ export class HomeService extends BaseApiService {
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME"
),
]); ]);
return { return {
ongoing: ongoing.content || [], ongoing: ongoing.content || [],
ongoingBooks: ongoingBooks.content || [], // Nouveau champ ongoingBooks: ongoingBooks.content || [],
recentlyRead: recentlyRead.content || [], recentlyRead: recentlyRead.content || [],
onDeck: onDeck.content || [], onDeck: onDeck.content || [],
latestSeries: latestSeries.content || [], latestSeries: latestSeries.content || [],
@@ -97,17 +71,4 @@ export class HomeService extends BaseApiService {
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
} }
} }
static async invalidateHomeCache(): Promise<void> {
try {
const cacheService = await getServerCacheService();
await cacheService.delete("home-ongoing");
await cacheService.delete("home-ongoing-books"); // Nouvelle clé de cache
await cacheService.delete("home-recently-read");
await cacheService.delete("home-on-deck");
await cacheService.delete("home-latest-series");
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
} }

View File

@@ -1,7 +1,6 @@
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 { Series } from "@/types/series"; import type { Series } from "@/types/series";
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";
import type { KomgaLibrary } from "@/types/komga"; import type { KomgaLibrary } from "@/types/komga";
@@ -9,11 +8,7 @@ import type { KomgaLibrary } from "@/types/komga";
export class LibraryService extends BaseApiService { export class LibraryService extends BaseApiService {
static async getLibraries(): Promise<KomgaLibrary[]> { static async getLibraries(): Promise<KomgaLibrary[]> {
try { try {
return this.fetchWithCache<KomgaLibrary[]>( return this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" });
"libraries",
async () => this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" }),
"LIBRARIES"
);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
} }
@@ -87,14 +82,6 @@ export class LibraryService extends BaseApiService {
const searchBody = { condition }; const searchBody = { condition };
// Clé de cache incluant tous les paramètres
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${
search || ""
}`;
const response = await this.fetchWithCache<LibraryResponse<Series>>(
cacheKey,
async () => {
const params: Record<string, string | string[]> = { const params: Record<string, string | string[]> = {
page: String(page), page: String(page),
size: String(size), size: String(size),
@@ -106,7 +93,7 @@ export class LibraryService extends BaseApiService {
params.search = search; params.search = search;
} }
return this.fetchFromApi<LibraryResponse<Series>>( const response = await this.fetchFromApi<LibraryResponse<Series>>(
{ path: "series/list", params }, { path: "series/list", params },
headers, headers,
{ {
@@ -114,9 +101,6 @@ export class LibraryService extends BaseApiService {
body: JSON.stringify(searchBody), body: JSON.stringify(searchBody),
} }
); );
},
"SERIES"
);
// Filtrer uniquement les séries supprimées côté client (léger) // Filtrer uniquement les séries supprimées côté client (léger)
const filteredContent = response.content.filter((series) => !series.deleted); const filteredContent = response.content.filter((series) => !series.deleted);
@@ -131,17 +115,6 @@ export class LibraryService extends BaseApiService {
} }
} }
static async invalidateLibrarySeriesCache(libraryId: string): Promise<void> {
try {
const cacheService = await getServerCacheService();
// Invalider toutes les clés de cache pour cette bibliothèque
// Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search}
await cacheService.deleteAll(`library-${libraryId}-series-`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> { static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
try { try {
await this.fetchFromApi( await this.fetchFromApi(

View File

@@ -2,11 +2,7 @@ import prisma from "@/lib/prisma";
import { getCurrentUser } from "../auth-utils"; import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import type { import type { UserPreferences, BackgroundPreferences } from "@/types/preferences";
UserPreferences,
BackgroundPreferences,
CircuitBreakerConfig,
} from "@/types/preferences";
import { defaultPreferences } from "@/types/preferences"; import { defaultPreferences } from "@/types/preferences";
import type { User } from "@/types/komga"; import type { User } from "@/types/komga";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
@@ -37,7 +33,6 @@ export class PreferencesService {
return { return {
showThumbnails: preferences.showThumbnails, showThumbnails: preferences.showThumbnails,
cacheMode: preferences.cacheMode as "memory" | "file",
showOnlyUnread: preferences.showOnlyUnread, showOnlyUnread: preferences.showOnlyUnread,
displayMode: { displayMode: {
...defaultPreferences.displayMode, ...defaultPreferences.displayMode,
@@ -45,9 +40,7 @@ export class PreferencesService {
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode, viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
}, },
background: preferences.background as unknown as BackgroundPreferences, background: preferences.background as unknown as BackgroundPreferences,
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests,
readerPrefetchCount: preferences.readerPrefetchCount, readerPrefetchCount: preferences.readerPrefetchCount,
circuitBreakerConfig: preferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
}; };
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
@@ -65,17 +58,12 @@ export class PreferencesService {
const updateData: Record<string, any> = {}; const updateData: Record<string, any> = {};
if (preferences.showThumbnails !== undefined) if (preferences.showThumbnails !== undefined)
updateData.showThumbnails = preferences.showThumbnails; updateData.showThumbnails = preferences.showThumbnails;
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
if (preferences.showOnlyUnread !== undefined) if (preferences.showOnlyUnread !== undefined)
updateData.showOnlyUnread = preferences.showOnlyUnread; updateData.showOnlyUnread = preferences.showOnlyUnread;
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode; if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
if (preferences.background !== undefined) updateData.background = preferences.background; if (preferences.background !== undefined) updateData.background = preferences.background;
if (preferences.komgaMaxConcurrentRequests !== undefined)
updateData.komgaMaxConcurrentRequests = preferences.komgaMaxConcurrentRequests;
if (preferences.readerPrefetchCount !== undefined) if (preferences.readerPrefetchCount !== undefined)
updateData.readerPrefetchCount = preferences.readerPrefetchCount; updateData.readerPrefetchCount = preferences.readerPrefetchCount;
if (preferences.circuitBreakerConfig !== undefined)
updateData.circuitBreakerConfig = preferences.circuitBreakerConfig;
const updatedPreferences = await prisma.preferences.upsert({ const updatedPreferences = await prisma.preferences.upsert({
where: { userId }, where: { userId },
@@ -83,28 +71,20 @@ export class PreferencesService {
create: { create: {
userId, userId,
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails, showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread, showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
displayMode: preferences.displayMode ?? defaultPreferences.displayMode, displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
background: (preferences.background ?? background: (preferences.background ??
defaultPreferences.background) as unknown as Prisma.InputJsonValue, defaultPreferences.background) as unknown as Prisma.InputJsonValue,
circuitBreakerConfig: (preferences.circuitBreakerConfig ??
defaultPreferences.circuitBreakerConfig) as unknown as Prisma.InputJsonValue,
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests ?? 5,
readerPrefetchCount: preferences.readerPrefetchCount ?? 5, readerPrefetchCount: preferences.readerPrefetchCount ?? 5,
}, },
}); });
return { return {
showThumbnails: updatedPreferences.showThumbnails, showThumbnails: updatedPreferences.showThumbnails,
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
showOnlyUnread: updatedPreferences.showOnlyUnread, showOnlyUnread: updatedPreferences.showOnlyUnread,
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"], displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
background: updatedPreferences.background as unknown as BackgroundPreferences, background: updatedPreferences.background as unknown as BackgroundPreferences,
komgaMaxConcurrentRequests: updatedPreferences.komgaMaxConcurrentRequests,
readerPrefetchCount: updatedPreferences.readerPrefetchCount, readerPrefetchCount: updatedPreferences.readerPrefetchCount,
circuitBreakerConfig:
updatedPreferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
}; };
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -1,44 +0,0 @@
/**
* Service de monitoring des requêtes concurrentes vers Komga
* Permet de tracker le nombre de requêtes actives et d'alerter en cas de charge élevée
*/
import logger from "@/lib/logger";
class RequestMonitor {
private activeRequests = 0;
private readonly thresholds = {
warning: 10,
high: 20,
critical: 30,
};
incrementActive(): number {
this.activeRequests++;
this.checkThresholds();
return this.activeRequests;
}
decrementActive(): number {
this.activeRequests = Math.max(0, this.activeRequests - 1);
return this.activeRequests;
}
getActiveCount(): number {
return this.activeRequests;
}
private checkThresholds(): void {
const count = this.activeRequests;
if (count >= this.thresholds.critical) {
logger.warn(`[REQUEST-MONITOR] 🔴 CRITICAL concurrency: ${count} active requests`);
} else if (count >= this.thresholds.high) {
logger.warn(`[REQUEST-MONITOR] ⚠️ HIGH concurrency: ${count} active requests`);
} else if (count >= this.thresholds.warning) {
logger.info(`[REQUEST-MONITOR] ⚡ Warning concurrency: ${count} active requests`);
}
}
}
// Singleton instance
export const RequestMonitorService = new RequestMonitor();

View File

@@ -1,109 +0,0 @@
/**
* Service de gestion de queue pour limiter les requêtes concurrentes vers Komga
* Évite de surcharger Komga avec trop de requêtes simultanées
*/
import logger from "@/lib/logger";
interface QueuedRequest<T> {
execute: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: any) => void;
}
class RequestQueue {
private queue: QueuedRequest<any>[] = [];
private activeCount = 0;
private maxConcurrent: number;
private getMaxConcurrent: (() => Promise<number>) | null = null;
constructor(maxConcurrent?: number) {
// Valeur par défaut
this.maxConcurrent = maxConcurrent ?? 5;
}
/**
* Configure une fonction pour récupérer dynamiquement le max concurrent depuis les préférences
*/
setMaxConcurrentGetter(getter: () => Promise<number>): void {
this.getMaxConcurrent = getter;
}
/**
* Récupère la valeur de maxConcurrent, soit depuis les préférences, soit depuis la valeur fixe
*/
private async getCurrentMaxConcurrent(): Promise<number> {
if (this.getMaxConcurrent) {
try {
return await this.getMaxConcurrent();
} catch (error) {
logger.error({ err: error }, "Error getting maxConcurrent from preferences, using default");
return this.maxConcurrent;
}
}
return this.maxConcurrent;
}
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
// Limiter la taille de la queue pour éviter l'accumulation
if (this.queue.length >= 50) {
reject(new Error("Request queue is full - Komga may be overloaded"));
return;
}
this.queue.push({ execute, resolve, reject });
this.processQueue();
});
}
private async delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async processQueue(): Promise<void> {
const maxConcurrent = await this.getCurrentMaxConcurrent();
if (this.activeCount >= maxConcurrent || this.queue.length === 0) {
return;
}
this.activeCount++;
const request = this.queue.shift();
if (!request) {
this.activeCount--;
return;
}
try {
// Délai adaptatif : plus long si la queue est pleine
// Désactivé en mode debug pour ne pas ralentir les tests
const isDebug = process.env.KOMGA_DEBUG === "true";
if (!isDebug) {
const delayMs = this.queue.length > 10 ? 500 : 200;
await this.delay(delayMs);
}
const result = await request.execute();
request.resolve(result);
} catch (error) {
request.reject(error);
} finally {
this.activeCount--;
this.processQueue();
}
}
getActiveCount(): number {
return this.activeCount;
}
getQueueLength(): number {
return this.queue.length;
}
setMaxConcurrent(max: number): void {
this.maxConcurrent = max;
}
}
// Singleton instance - Par défaut limite à 5 requêtes simultanées
export const RequestQueueService = new RequestQueue(5);

View File

@@ -1,50 +1,27 @@
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, TTLConfig } from "@/types/komga"; import type { KomgaBook, KomgaSeries } 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 { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import type { ServerCacheService } from "./server-cache.service";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
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) {
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
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.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
`series-${seriesId}`,
async () => this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` }),
"SERIES"
);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
} }
} }
static async invalidateSeriesCache(seriesId: string): Promise<void> {
try {
const cacheService = await getServerCacheService();
await cacheService.delete(`series-${seriesId}`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async getSeriesBooks( static async getSeriesBooks(
seriesId: string, seriesId: string,
page: number = 0, page: number = 0,
@@ -96,19 +73,13 @@ export class SeriesService extends BaseApiService {
const searchBody = { condition }; const searchBody = { condition };
// Clé de cache incluant tous les paramètres
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
cacheKey,
async () => {
const params: Record<string, string | string[]> = { const params: Record<string, string | string[]> = {
page: String(page), page: String(page),
size: String(size), size: String(size),
sort: "number,asc", sort: "number,asc",
}; };
return this.fetchFromApi<LibraryResponse<KomgaBook>>( const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
{ path: "books/list", params }, { path: "books/list", params },
headers, headers,
{ {
@@ -116,9 +87,6 @@ export class SeriesService extends BaseApiService {
body: JSON.stringify(searchBody), body: JSON.stringify(searchBody),
} }
); );
},
"BOOKS"
);
// Filtrer uniquement les livres supprimés côté client (léger) // Filtrer uniquement les livres supprimés côté client (léger)
const filteredContent = response.content.filter((book: KomgaBook) => !book.deleted); const filteredContent = response.content.filter((book: KomgaBook) => !book.deleted);
@@ -133,25 +101,9 @@ export class SeriesService extends BaseApiService {
} }
} }
static async invalidateSeriesBooksCache(seriesId: string): Promise<void> {
try {
const cacheService: ServerCacheService = await getServerCacheService();
// Invalider toutes les clés de cache pour cette série
// Format: series-{id}-books-p{page}-s{size}-u{unread}
await cacheService.deleteAll(`series-${seriesId}-books-`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async getFirstBook(seriesId: string): Promise<string> { static async getFirstBook(seriesId: string): Promise<string> {
try { try {
return this.fetchWithCache<string>( const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
`series-first-book-${seriesId}`,
async () => {
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<
LibraryResponse<KomgaBook>
>({
path: `series/${seriesId}/books`, path: `series/${seriesId}/books`,
params: { page: "0", size: "1" }, params: { page: "0", size: "1" },
}); });
@@ -160,9 +112,6 @@ export class SeriesService extends BaseApiService {
} }
return data.content[0].id; return data.content[0].id;
},
"SERIES"
);
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération du premier livre"); logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
@@ -173,7 +122,6 @@ 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) {
@@ -181,7 +129,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=${maxAge}, immutable`, "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
}, },
}); });
} }

View File

@@ -1,730 +0,0 @@
import fs from "fs";
import path from "path";
import { PreferencesService } from "./preferences.service";
import { getCurrentUser } from "../auth-utils";
import logger from "@/lib/logger";
export type CacheMode = "file" | "memory";
interface CacheConfig {
mode: CacheMode;
}
class ServerCacheService {
private static instance: ServerCacheService;
private cacheDir: string;
private memoryCache: Map<string, { data: unknown; expiry: number }> = new Map();
private config: CacheConfig = {
mode: "memory",
};
// Configuration des temps de cache en millisecondes
private static readonly oneMinute = 1 * 60 * 1000;
private static readonly twoMinutes = 2 * 60 * 1000;
private static readonly fiveMinutes = 5 * 60 * 1000;
private static readonly tenMinutes = 10 * 60 * 1000;
private static readonly twentyFourHours = 24 * 60 * 60 * 1000;
private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000;
private static readonly noCache = 0;
// Configuration des temps de cache
// Optimisé pour la pagination native Komga :
// - Listes paginées (SERIES, BOOKS) : TTL court (2 min) car données fraîches + progression utilisateur
// - Données agrégées (HOME) : TTL moyen (10 min) car plusieurs sources
// - Données statiques (LIBRARIES) : TTL long (24h) car changent rarement
// - Images : TTL très long (7 jours) car immuables
private static readonly DEFAULT_TTL = {
DEFAULT: ServerCacheService.fiveMinutes,
HOME: ServerCacheService.tenMinutes,
LIBRARIES: ServerCacheService.twentyFourHours,
SERIES: ServerCacheService.twoMinutes, // Listes paginées avec progression
BOOKS: ServerCacheService.twoMinutes, // Listes paginées avec progression
IMAGES: ServerCacheService.oneWeek,
};
private constructor() {
this.cacheDir = path.join(process.cwd(), ".cache");
this.ensureCacheDirectory();
this.cleanExpiredCache();
this.initializeCacheMode();
}
private async initializeCacheMode(): Promise<void> {
try {
const user = await getCurrentUser();
if (!user) {
this.setCacheMode("memory");
return;
}
const preferences = await PreferencesService.getPreferences();
this.setCacheMode(preferences.cacheMode);
} catch (error) {
logger.error({ err: error }, "Error initializing cache mode from preferences");
// Keep default memory mode if preferences can't be loaded
}
}
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) {
logger.error(
{ err: error, path: itemPath },
`Could not remove directory ${itemPath}`
);
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) {
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
// Si le fichier est corrompu, on le supprime
try {
fs.unlinkSync(itemPath);
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
isEmpty = false;
}
}
} else {
isEmpty = false;
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
// En cas d'erreur sur le fichier/dossier, on continue
isEmpty = false;
continue;
}
}
return isEmpty;
};
cleanDirectory(this.cacheDir);
}
public static async getInstance(): Promise<ServerCacheService> {
if (!ServerCacheService.instance) {
ServerCacheService.instance = new ServerCacheService();
await ServerCacheService.instance.initializeCacheMode();
}
return ServerCacheService.instance;
}
/**
* Retourne le TTL pour un type de données spécifique
*/
public getTTL(type: keyof typeof ServerCacheService.DEFAULT_TTL): number {
// Utiliser directement la valeur par défaut
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;
}
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) {
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
// Ignore les fichiers corrompus
}
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
// 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) {
logger.error({ err: error, path: filePath }, `Could not write cache file ${filePath}`);
}
}
/**
* 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 {
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) {
logger.error({ err: error, path: filePath }, `Error writing cache file ${filePath}`);
}
}
}
/**
* Récupère des données du cache si elles sont valides
*/
get(key: string): any | null {
if (this.config.mode === "memory") {
const cached = this.memoryCache.get(key);
if (!cached) return null;
if (cached.expiry > Date.now()) {
return cached.data;
}
this.memoryCache.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) {
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
return null;
}
}
/**
* Récupère des données du cache même si elles sont expirées (stale)
* Retourne { data, isStale } ou null si pas de cache
*/
private getStale(key: string): { data: any; isStale: boolean } | null {
if (this.config.mode === "memory") {
const cached = this.memoryCache.get(key);
if (!cached) return null;
return {
data: cached.data,
isStale: cached.expiry <= Date.now(),
};
}
const filePath = this.getCacheFilePath(key);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, "utf-8");
const cached = JSON.parse(content);
return {
data: cached.data,
isStale: cached.expiry <= Date.now(),
};
} catch (error) {
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
return null;
}
}
/**
* Supprime une entrée du cache
*/
async delete(key: string): Promise<void> {
const user = await getCurrentUser();
if (!user) {
throw new Error("Utilisateur non authentifié");
}
const cacheKey = `${user.id}-${key}`;
if (this.config.mode === "memory") {
this.memoryCache.delete(cacheKey);
} else {
const filePath = this.getCacheFilePath(cacheKey);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
/**
* Supprime toutes les entrées du cache qui commencent par un préfixe
*/
async deleteAll(prefix: string): Promise<void> {
const user = await getCurrentUser();
if (!user) {
throw new Error("Utilisateur non authentifié");
}
const prefixKey = `${user.id}-${prefix}`;
if (this.config.mode === "memory") {
this.memoryCache.forEach((value, key) => {
if (key.startsWith(prefixKey)) {
this.memoryCache.delete(key);
}
});
} else {
// En mode fichier, parcourir récursivement tous les fichiers et supprimer ceux qui correspondent
if (!fs.existsSync(this.cacheDir)) return;
const deleteMatchingFiles = (dirPath: string): void => {
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()) {
deleteMatchingFiles(itemPath);
// Supprimer le répertoire s'il est vide après suppression des fichiers
try {
const remainingItems = fs.readdirSync(itemPath);
if (remainingItems.length === 0) {
fs.rmdirSync(itemPath);
}
} catch {
// Ignore les erreurs de suppression de répertoire
}
} else if (stats.isFile() && item.endsWith(".json")) {
// Extraire la clé du chemin relatif (sans l'extension .json)
const relativePath = path.relative(this.cacheDir, itemPath);
const key = relativePath.slice(0, -5).replace(/\\/g, "/"); // Remove .json and normalize slashes
if (key.startsWith(prefixKey)) {
fs.unlinkSync(itemPath);
if (process.env.CACHE_DEBUG === "true") {
logger.debug(`🗑️ [CACHE DELETE] ${key}`);
}
}
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not delete cache file ${itemPath}`);
}
}
};
deleteMatchingFiles(this.cacheDir);
}
}
/**
* Vide le cache
*/
clear(): void {
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) {
logger.error(
{ err: error, path: itemPath },
`Could not remove directory ${itemPath}`
);
}
} else {
try {
fs.unlinkSync(itemPath);
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
}
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Error accessing ${itemPath}`);
}
}
};
try {
removeDirectory(this.cacheDir);
} catch (error) {
logger.error({ err: error }, "Error clearing cache");
}
}
/**
* Récupère des données du cache ou exécute la fonction si nécessaire
* Stratégie stale-while-revalidate:
* - Cache valide → retourne immédiatement
* - Cache expiré → retourne le cache expiré ET revalide en background
* - Pas de cache → fetch normalement
*/
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
): Promise<T> {
const startTime = performance.now();
const user = await getCurrentUser();
if (!user) {
throw new Error("Utilisateur non authentifié");
}
const cacheKey = `${user.id}-${key}`;
const cachedResult = this.getStale(cacheKey);
if (cachedResult !== null) {
const { data, isStale } = cachedResult;
const endTime = performance.now();
// Debug logging
if (process.env.CACHE_DEBUG === "true") {
const icon = isStale ? "⚠️" : "✅";
const status = isStale ? "STALE" : "HIT";
logger.debug(
`${icon} [CACHE ${status}] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
);
}
// Si le cache est expiré, revalider en background sans bloquer la réponse
if (isStale) {
// Fire and forget - revalidate en background
this.revalidateInBackground(cacheKey, fetcher, type, key);
}
return data as T;
}
// Pas de cache du tout, fetch normalement
if (process.env.CACHE_DEBUG === "true") {
logger.debug(`❌ [CACHE MISS] ${key} | ${type}`);
}
try {
const data = await fetcher();
this.set(cacheKey, data, type);
const endTime = performance.now();
if (process.env.CACHE_DEBUG === "true") {
logger.debug(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
}
return data;
} catch (error) {
throw error;
}
}
/**
* Revalide le cache en background
*/
private async revalidateInBackground<T>(
cacheKey: string,
fetcher: () => Promise<T>,
type: keyof typeof ServerCacheService.DEFAULT_TTL,
debugKey: string
): Promise<void> {
try {
const startTime = performance.now();
const data = await fetcher();
this.set(cacheKey, data, type);
if (process.env.CACHE_DEBUG === "true") {
const endTime = performance.now();
logger.debug(
`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
);
}
} catch (error) {
logger.error({ err: error, key: debugKey }, `🔴 [CACHE REVALIDATE ERROR] ${debugKey}`);
// Ne pas relancer l'erreur car c'est en background
}
}
invalidate(key: string): void {
this.delete(key);
}
/**
* Calcule la taille approximative d'un objet en mémoire
*/
private calculateObjectSize(obj: unknown): number {
if (obj === null || obj === undefined) return 0;
// Si c'est un Buffer, utiliser sa taille réelle
if (Buffer.isBuffer(obj)) {
return obj.length;
}
// Si c'est un objet avec une propriété buffer (comme ImageResponse)
if (typeof obj === "object" && obj !== null) {
const objAny = obj as any;
if (objAny.buffer && Buffer.isBuffer(objAny.buffer)) {
// Taille du buffer + taille approximative des autres propriétés
let size = objAny.buffer.length;
// Ajouter la taille du contentType si présent
if (objAny.contentType && typeof objAny.contentType === "string") {
size += objAny.contentType.length * 2; // UTF-16
}
return size;
}
}
// Pour les autres types, utiliser JSON.stringify comme approximation
try {
return JSON.stringify(obj).length * 2; // x2 pour UTF-16
} catch {
// Si l'objet n'est pas sérialisable, retourner une estimation
return 1000; // 1KB par défaut
}
}
/**
* Calcule la taille du cache
*/
async getCacheSize(): Promise<{ sizeInBytes: number; itemCount: number }> {
if (this.config.mode === "memory") {
// Calculer la taille approximative en mémoire
let sizeInBytes = 0;
let itemCount = 0;
this.memoryCache.forEach((value) => {
if (value.expiry > Date.now()) {
itemCount++;
// Calculer la taille du data + expiry (8 bytes pour le timestamp)
sizeInBytes += this.calculateObjectSize(value.data) + 8;
}
});
return { sizeInBytes, itemCount };
}
// Calculer la taille du cache sur disque
let sizeInBytes = 0;
let itemCount = 0;
const calculateDirectorySize = (dirPath: string): void => {
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()) {
calculateDirectorySize(itemPath);
} else if (stats.isFile() && item.endsWith(".json")) {
sizeInBytes += stats.size;
itemCount++;
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
}
}
};
if (fs.existsSync(this.cacheDir)) {
calculateDirectorySize(this.cacheDir);
}
return { sizeInBytes, itemCount };
}
/**
* Liste les entrées du cache avec leurs détails
*/
async getCacheEntries(): Promise<
Array<{
key: string;
size: number;
expiry: number;
isExpired: boolean;
}>
> {
const entries: Array<{
key: string;
size: number;
expiry: number;
isExpired: boolean;
}> = [];
if (this.config.mode === "memory") {
this.memoryCache.forEach((value, key) => {
const size = this.calculateObjectSize(value.data) + 8;
entries.push({
key,
size,
expiry: value.expiry,
isExpired: value.expiry <= Date.now(),
});
});
} else {
const collectEntries = (dirPath: string): void => {
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()) {
collectEntries(itemPath);
} else if (stats.isFile() && item.endsWith(".json")) {
try {
const content = fs.readFileSync(itemPath, "utf-8");
const cached = JSON.parse(content);
const key = path.relative(this.cacheDir, itemPath).slice(0, -5);
entries.push({
key,
size: stats.size,
expiry: cached.expiry,
isExpired: cached.expiry <= Date.now(),
});
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
}
}
} catch (error) {
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
}
}
};
if (fs.existsSync(this.cacheDir)) {
collectEntries(this.cacheDir);
}
}
return entries.sort((a, b) => b.expiry - a.expiry);
}
}
// Créer une instance initialisée du service
let initializedInstance: Promise<ServerCacheService>;
export const getServerCacheService = async (): Promise<ServerCacheService> => {
if (!initializedInstance) {
initializedInstance = ServerCacheService.getInstance();
}
return initializedInstance;
};
// Exporter aussi la classe pour les tests
export { ServerCacheService };

View File

@@ -1,6 +1,5 @@
/** /**
* Génère l'URL de base pour une image (sans cache version) * Génère l'URL pour une image (thumbnail de série ou de livre)
* 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") {

View File

@@ -1 +0,0 @@
export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES";

View File

@@ -15,20 +15,6 @@ export interface KomgaConfig extends KomgaConfigData {
userId: number; userId: number;
} }
export interface TTLConfigData {
defaultTTL: number;
homeTTL: number;
librariesTTL: number;
seriesTTL: number;
booksTTL: number;
imagesTTL: number;
imageCacheMaxAge: number; // en secondes
}
export interface TTLConfig extends TTLConfigData {
userId: number;
}
// Types liés à l'API Komga // Types liés à l'API Komga
export interface KomgaUser { export interface KomgaUser {
id: string; id: string;

View File

@@ -9,15 +9,8 @@ export interface BackgroundPreferences {
komgaLibraries?: string[]; // IDs des bibliothèques Komga sélectionnées komgaLibraries?: string[]; // IDs des bibliothèques Komga sélectionnées
} }
export interface CircuitBreakerConfig {
threshold?: number;
timeout?: number;
resetTimeout?: number;
}
export interface UserPreferences { export interface UserPreferences {
showThumbnails: boolean; showThumbnails: boolean;
cacheMode: "memory" | "file";
showOnlyUnread: boolean; showOnlyUnread: boolean;
displayMode: { displayMode: {
compact: boolean; compact: boolean;
@@ -25,14 +18,11 @@ export interface UserPreferences {
viewMode: "grid" | "list"; viewMode: "grid" | "list";
}; };
background: BackgroundPreferences; background: BackgroundPreferences;
komgaMaxConcurrentRequests: number;
readerPrefetchCount: number; readerPrefetchCount: number;
circuitBreakerConfig: CircuitBreakerConfig;
} }
export const defaultPreferences: UserPreferences = { export const defaultPreferences: UserPreferences = {
showThumbnails: true, showThumbnails: true,
cacheMode: "memory",
showOnlyUnread: false, showOnlyUnread: false,
displayMode: { displayMode: {
compact: false, compact: false,
@@ -44,13 +34,7 @@ export const defaultPreferences: UserPreferences = {
opacity: 10, opacity: 10,
blur: 0, blur: 0,
}, },
komgaMaxConcurrentRequests: 5,
readerPrefetchCount: 5, readerPrefetchCount: 5,
circuitBreakerConfig: {
threshold: 5,
timeout: 30000,
resetTimeout: 60000,
},
}; };
// Dégradés prédéfinis // Dégradés prédéfinis