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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m22s
This commit is contained in:
22
docs/api.md
22
docs/api.md
@@ -27,22 +27,6 @@
|
||||
- **Body** : `{ url: string, username: string, password: string }`
|
||||
- **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
|
||||
|
||||
### GET /api/komga/libraries
|
||||
@@ -123,13 +107,13 @@
|
||||
### GET /api/preferences
|
||||
|
||||
- **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
|
||||
|
||||
- **Description** : Mise à jour des préférences utilisateur
|
||||
- **Body** : `{ showThumbnails?: boolean, cacheMode?: "memory" | "file", showOnlyUnread?: boolean, debug?: boolean }`
|
||||
- **Réponse** : `{ showThumbnails: boolean, cacheMode: "memory" | "file", showOnlyUnread: boolean, debug: boolean }`
|
||||
- **Body** : `{ showThumbnails?: boolean, showOnlyUnread?: boolean, displayMode?: object, background?: object, readerPrefetchCount?: number }`
|
||||
- **Réponse** : `{ showThumbnails: boolean, showOnlyUnread: boolean, displayMode: object, background: object, readerPrefetchCount: number }`
|
||||
|
||||
## 🧪 Test
|
||||
|
||||
|
||||
549
docs/caching.md
549
docs/caching.md
@@ -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.
|
||||
@@ -7,10 +7,12 @@ Service de gestion de l'authentification
|
||||
### Méthodes
|
||||
|
||||
- `loginUser(email: string, password: string): Promise<UserData>`
|
||||
|
||||
- Authentifie un utilisateur
|
||||
- Retourne les données utilisateur
|
||||
|
||||
- `createUser(email: string, password: string): Promise<UserData>`
|
||||
|
||||
- Crée un nouvel utilisateur
|
||||
- Retourne les données utilisateur
|
||||
|
||||
@@ -24,10 +26,11 @@ Service de gestion des bibliothèques
|
||||
### Méthodes
|
||||
|
||||
- `getLibraries(): Promise<Library[]>`
|
||||
|
||||
- Récupère la liste des bibliothèques
|
||||
- Met en cache les résultats
|
||||
|
||||
- `getLibrary(libraryId: string): Promise<Library>`
|
||||
|
||||
- Récupère une bibliothèque spécifique
|
||||
- Lance une erreur si non trouvée
|
||||
|
||||
@@ -47,9 +50,11 @@ Service de gestion des séries
|
||||
### Méthodes
|
||||
|
||||
- `getSeries(seriesId: string): Promise<Series>`
|
||||
|
||||
- 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>>`
|
||||
|
||||
- Récupère les livres d'une série
|
||||
- Supporte la pagination et le filtrage
|
||||
|
||||
@@ -63,15 +68,19 @@ Service de gestion des livres
|
||||
### Méthodes
|
||||
|
||||
- `getBook(bookId: string): Promise<{ book: KomgaBook; pages: number[] }>`
|
||||
|
||||
- Récupère les détails d'un livre et ses pages
|
||||
|
||||
- `updateReadProgress(bookId: string, page: number, completed: boolean = false): Promise<void>`
|
||||
|
||||
- Met à jour la progression de lecture
|
||||
|
||||
- `getPage(bookId: string, pageNumber: number): Promise<Response>`
|
||||
|
||||
- Récupère une page spécifique d'un livre
|
||||
|
||||
- `getCover(bookId: string): Promise<Response>`
|
||||
|
||||
- Récupère la couverture d'un livre
|
||||
|
||||
- `getPageThumbnail(bookId: string, pageNumber: number): Promise<Response>`
|
||||
@@ -84,13 +93,15 @@ Service de gestion des images
|
||||
### Méthodes
|
||||
|
||||
- `getImage(path: string): Promise<ImageResponse>`
|
||||
|
||||
- Récupère une image depuis le serveur
|
||||
- Gère le cache des images
|
||||
|
||||
- `getSeriesThumbnailUrl(seriesId: string): string`
|
||||
|
||||
- Génère l'URL de la miniature d'une série
|
||||
|
||||
- `getBookThumbnailUrl(bookId: string): string`
|
||||
|
||||
- Génère l'URL de la miniature d'un livre
|
||||
|
||||
- `getBookPageUrl(bookId: string, pageNumber: number): string`
|
||||
@@ -103,29 +114,20 @@ Service de gestion de la configuration
|
||||
### Méthodes
|
||||
|
||||
- `getConfig(): Promise<Config>`
|
||||
|
||||
- Récupère la configuration Komga
|
||||
|
||||
- `saveConfig(config: Config): Promise<Config>`
|
||||
|
||||
- Sauvegarde la configuration Komga
|
||||
|
||||
- `getTTLConfig(): Promise<TTLConfig>`
|
||||
|
||||
- Récupère la configuration TTL
|
||||
|
||||
- `saveTTLConfig(config: TTLConfig): Promise<TTLConfig>`
|
||||
- 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
|
||||
|
||||
Service de gestion des favoris
|
||||
@@ -142,6 +144,7 @@ Service de gestion des préférences
|
||||
### Méthodes
|
||||
|
||||
- `getPreferences(): Promise<Preferences>`
|
||||
|
||||
- Récupère les préférences utilisateur
|
||||
|
||||
- `savePreferences(preferences: Preferences): Promise<void>`
|
||||
@@ -164,13 +167,12 @@ Service de base pour les appels API
|
||||
### Méthodes
|
||||
|
||||
- `buildUrl(config: Config, path: string, params?: Record<string, string>): string`
|
||||
|
||||
- Construit une URL d'API
|
||||
|
||||
- `getAuthHeaders(config: Config): Headers`
|
||||
|
||||
- Génère les en-têtes d'authentification
|
||||
|
||||
- `fetchFromApi<T>(url: string, headers: Headers, raw?: boolean): Promise<T>`
|
||||
- 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
|
||||
|
||||
@@ -21,7 +21,6 @@ model User {
|
||||
|
||||
// Relations
|
||||
config KomgaConfig?
|
||||
ttlConfig TTLConfig?
|
||||
preferences Preferences?
|
||||
favorites Favorite[]
|
||||
|
||||
@@ -42,34 +41,13 @@ model KomgaConfig {
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
showThumbnails Boolean @default(true)
|
||||
cacheMode String @default("memory") // "memory" | "file"
|
||||
showOnlyUnread Boolean @default(false)
|
||||
displayMode Json
|
||||
background Json
|
||||
komgaMaxConcurrentRequests Int @default(5)
|
||||
circuitBreakerConfig Json
|
||||
readerPrefetchCount Int @default(5)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -92,4 +70,3 @@ model Favorite {
|
||||
@@index([userId])
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
|
||||
45
public/sw.js
45
public/sw.js
@@ -1,11 +1,8 @@
|
||||
// StripStream Service Worker - Version 1
|
||||
// Architecture: Cache-as-you-go for images and static resources only
|
||||
// API data caching is handled by ServerCacheService on the server
|
||||
// Architecture: Cache-as-you-go for static resources only
|
||||
|
||||
const VERSION = "v1";
|
||||
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 BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||
|
||||
@@ -20,29 +17,18 @@ function isNextStaticResource(url) {
|
||||
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) {
|
||||
const url = new URL(request.url);
|
||||
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-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 = {}) {
|
||||
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
|
||||
* Used for: API data, RSC payloads
|
||||
* Used for: RSC payloads
|
||||
*/
|
||||
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
@@ -202,39 +188,24 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 1: Images → Cache-First with ignoreSearch
|
||||
if (isImageRequest(url.href)) {
|
||||
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
|
||||
// Route 1: Next.js RSC payloads → Stale-While-Revalidate
|
||||
if (isNextRSCRequest(request)) {
|
||||
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 3: API data → Network only (no SW caching)
|
||||
// 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
|
||||
// Route 2: Next.js static resources → Cache-First with ignoreSearch
|
||||
if (isNextStaticResource(url.href)) {
|
||||
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 5: Navigation → Network-First with SPA fallback
|
||||
// Route 3: Navigation → Network-First with SPA fallback
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(navigationStrategy(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Route 6: Everything else → Network only (no caching)
|
||||
// This includes: API auth, preferences, and other dynamic content
|
||||
// Route 4: Everything else → Network only (no caching)
|
||||
// This includes: API calls, images, and other dynamic content
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { BookService } from "@/lib/services/book.service";
|
||||
import { SeriesService } from "@/lib/services/series.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { AppError } from "@/utils/errors";
|
||||
@@ -30,16 +29,6 @@ export async function PATCH(
|
||||
|
||||
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" });
|
||||
} catch (error) {
|
||||
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);
|
||||
|
||||
// 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" });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la suppression de la progression:");
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/app/api/komga/cache/clear/route.ts
vendored
34
src/app/api/komga/cache/clear/route.ts
vendored
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/app/api/komga/cache/entries/route.ts
vendored
27
src/app/api/komga/cache/entries/route.ts
vendored
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/app/api/komga/cache/mode/route.ts
vendored
60
src/app/api/komga/cache/mode/route.ts
vendored
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/komga/cache/size/route.ts
vendored
31
src/app/api/komga/cache/size/route.ts
vendored
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,7 @@ export function ClientLibraryPage({
|
||||
params.append("search", search);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||
cache: "default", // Utilise le cache HTTP du navigateur
|
||||
});
|
||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
@@ -84,16 +82,6 @@ export function ClientLibraryPage({
|
||||
|
||||
const handleRefresh = async (libraryId: string) => {
|
||||
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({
|
||||
page: String(currentPage - 1),
|
||||
size: String(effectivePageSize),
|
||||
@@ -105,7 +93,7 @@ export function ClientLibraryPage({
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||
cache: "reload", // Force un nouveau fetch après invalidation
|
||||
cache: "reload",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -139,7 +127,7 @@ export function ClientLibraryPage({
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||
cache: "reload", // Force un nouveau fetch lors du retry
|
||||
cache: "reload",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -49,9 +49,7 @@ export function ClientSeriesPage({
|
||||
unread: String(unreadOnly),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||
cache: "default", // Utilise le cache HTTP du navigateur
|
||||
});
|
||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
@@ -74,16 +72,6 @@ export function ClientSeriesPage({
|
||||
|
||||
const handleRefresh = async (seriesId: string) => {
|
||||
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({
|
||||
page: String(currentPage - 1),
|
||||
size: String(effectivePageSize),
|
||||
@@ -91,7 +79,7 @@ export function ClientSeriesPage({
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||
cache: "reload", // Force un nouveau fetch après invalidation
|
||||
cache: "reload",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -121,7 +109,7 @@ export function ClientSeriesPage({
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||
cache: "reload", // Force un nouveau fetch lors du retry
|
||||
cache: "reload",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||
import { ClientSettings } from "@/components/settings/ClientSettings";
|
||||
import type { Metadata } from "next";
|
||||
import type { KomgaConfig, TTLConfig } from "@/types/komga";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,7 +13,6 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function SettingsPage() {
|
||||
let config: KomgaConfig | null = null;
|
||||
let ttlConfig: TTLConfig | null = null;
|
||||
|
||||
try {
|
||||
// Récupérer la configuration Komga
|
||||
@@ -27,13 +26,10 @@ export default async function SettingsPage() {
|
||||
password: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Récupérer la configuration TTL
|
||||
ttlConfig = await ConfigDBService.getTTLConfig();
|
||||
} catch (error) {
|
||||
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
|
||||
}
|
||||
|
||||
return <ClientSettings initialConfig={config} initialTTLConfig={ttlConfig} />;
|
||||
return <ClientSettings initialConfig={config} />;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ export function ClientHomePage() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/komga/home", {
|
||||
cache: "default", // Utilise le cache HTTP du navigateur
|
||||
});
|
||||
const response = await fetch("/api/komga/home");
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
@@ -56,18 +54,8 @@ export function ClientHomePage() {
|
||||
|
||||
const handleRefresh = async () => {
|
||||
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", {
|
||||
cache: "reload", // Force un nouveau fetch après invalidation
|
||||
cache: "reload",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { usePathname } from "next/navigation";
|
||||
import { registerServiceWorker } from "@/lib/registerSW";
|
||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { ImageCacheProvider } from "@/contexts/ImageCacheContext";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
@@ -152,7 +151,6 @@ export default function ClientLayout({
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ImageCacheProvider>
|
||||
{/* Background fixe pour les images et gradients */}
|
||||
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
|
||||
<div
|
||||
@@ -184,7 +182,6 @@ export default function ClientLayout({
|
||||
<Toaster />
|
||||
<NetworkStatus />
|
||||
</div>
|
||||
</ImageCacheProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,8 +75,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
}, []);
|
||||
|
||||
// Prefetch current and next pages
|
||||
// Deduplication in useImageLoader prevents redundant requests
|
||||
// Server queue (RequestQueueService) handles concurrency limits
|
||||
useEffect(() => {
|
||||
// Prefetch pages starting from current page
|
||||
prefetchPages(currentPage, prefetchCount);
|
||||
|
||||
@@ -99,8 +99,6 @@ export function useImageLoader({
|
||||
);
|
||||
|
||||
// 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(
|
||||
async (startPage: number, count: number = prefetchCount) => {
|
||||
const pagesToPrefetch = [];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
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 logger from "@/lib/logger";
|
||||
|
||||
@@ -11,25 +11,6 @@ export function AdvancedSettings() {
|
||||
const { toast } = useToast();
|
||||
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) => {
|
||||
try {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Performance Settings */}
|
||||
@@ -85,18 +44,6 @@ export function AdvancedSettings() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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
|
||||
label={t("settings.advanced.prefetchCount.label")}
|
||||
value={preferences.readerPrefetchCount}
|
||||
@@ -108,54 +55,6 @@ export function AdvancedSettings() {
|
||||
/>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { KomgaConfig, TTLConfigData } from "@/types/komga";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { DisplaySettings } from "./DisplaySettings";
|
||||
import { KomgaSettings } from "./KomgaSettings";
|
||||
import { CacheSettings } from "./CacheSettings";
|
||||
import { BackgroundSettings } from "./BackgroundSettings";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Monitor, Network, HardDrive } from "lucide-react";
|
||||
import { Monitor, Network } from "lucide-react";
|
||||
|
||||
interface ClientSettingsProps {
|
||||
initialConfig: KomgaConfig | null;
|
||||
initialTTLConfig: TTLConfigData | null;
|
||||
}
|
||||
|
||||
export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettingsProps) {
|
||||
export function ClientSettings({ initialConfig }: ClientSettingsProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
@@ -23,7 +21,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||
|
||||
<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">
|
||||
<Monitor className="h-4 w-4" />
|
||||
{t("settings.tabs.display")}
|
||||
@@ -32,10 +30,6 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
||||
<Network className="h-4 w-4" />
|
||||
{t("settings.tabs.connection")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cache" className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
{t("settings.tabs.cache")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="display" className="mt-6 space-y-6">
|
||||
@@ -47,10 +41,6 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
|
||||
<KomgaSettings initialConfig={initialConfig} />
|
||||
<AdvancedSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cache" className="mt-6 space-y-6">
|
||||
<CacheSettings initialTTLConfig={initialTTLConfig} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import type { BookCoverProps } from "./cover-utils";
|
||||
import { getImageUrl } from "@/lib/utils/image-url";
|
||||
import { useImageUrl } from "@/hooks/useImageUrl";
|
||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||
import { MarkAsReadButton } from "./mark-as-read-button";
|
||||
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
||||
@@ -63,8 +62,7 @@ export function BookCover({
|
||||
const { t } = useTranslate();
|
||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||
|
||||
const baseUrl = getImageUrl("book", book.id);
|
||||
const imageUrl = useImageUrl(baseUrl);
|
||||
const imageUrl = getImageUrl("book", book.id);
|
||||
const isCompleted = book.readProgress?.completed || false;
|
||||
|
||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import type { SeriesCoverProps } from "./cover-utils";
|
||||
import { getImageUrl } from "@/lib/utils/image-url";
|
||||
import { useImageUrl } from "@/hooks/useImageUrl";
|
||||
|
||||
export function SeriesCover({
|
||||
series,
|
||||
@@ -12,8 +11,7 @@ export function SeriesCover({
|
||||
className,
|
||||
showProgressUi = true,
|
||||
}: SeriesCoverProps) {
|
||||
const baseUrl = getImageUrl("series", series.id);
|
||||
const imageUrl = useImageUrl(baseUrl);
|
||||
const imageUrl = getImageUrl("series", series.id);
|
||||
const isCompleted = series.booksCount === series.booksReadCount;
|
||||
|
||||
const readBooks = series.booksReadCount;
|
||||
|
||||
@@ -63,16 +63,6 @@ export const ERROR_CODES = {
|
||||
UPDATE_ERROR: "PREFERENCES_UPDATE_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: {
|
||||
TABS_TRIGGER_ERROR: "UI_TABS_TRIGGER_ERROR",
|
||||
TABS_CONTENT_ERROR: "UI_TABS_CONTENT_ERROR",
|
||||
|
||||
@@ -60,16 +60,6 @@ export const ERROR_MESSAGES: Record<string, string> = {
|
||||
[ERROR_CODES.PREFERENCES.CONTEXT_ERROR]:
|
||||
"🔄 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
|
||||
[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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -69,8 +69,7 @@
|
||||
"title": "Preferences",
|
||||
"tabs": {
|
||||
"display": "Display",
|
||||
"connection": "Connection",
|
||||
"cache": "Cache"
|
||||
"connection": "Connection"
|
||||
},
|
||||
"display": {
|
||||
"title": "Display Preferences",
|
||||
@@ -106,31 +105,9 @@
|
||||
"title": "Advanced Settings",
|
||||
"description": "Configure advanced performance and reliability settings.",
|
||||
"save": "Save settings",
|
||||
"maxConcurrentRequests": {
|
||||
"label": "Max Concurrent Requests",
|
||||
"description": "Maximum number of simultaneous requests to Komga server (1-10)"
|
||||
},
|
||||
"prefetchCount": {
|
||||
"label": "Reader Prefetch Count",
|
||||
"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": {
|
||||
@@ -159,75 +136,6 @@
|
||||
"title": "Error saving 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": {
|
||||
@@ -463,14 +371,6 @@
|
||||
"PREFERENCES_UPDATE_ERROR": "Error updating preferences",
|
||||
"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_CONTENT_ERROR": "Error loading tabs content",
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@
|
||||
"title": "Préférences",
|
||||
"tabs": {
|
||||
"display": "Affichage",
|
||||
"connection": "Connexion",
|
||||
"cache": "Cache"
|
||||
"connection": "Connexion"
|
||||
},
|
||||
"display": {
|
||||
"title": "Préférences d'affichage",
|
||||
@@ -106,31 +105,9 @@
|
||||
"title": "Paramètres avancés",
|
||||
"description": "Configurez les paramètres avancés de performance et de fiabilité.",
|
||||
"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": {
|
||||
"label": "Préchargement du lecteur",
|
||||
"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": {
|
||||
@@ -159,75 +136,6 @@
|
||||
"title": "Erreur 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": {
|
||||
@@ -461,14 +369,6 @@
|
||||
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour 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_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
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 { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
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";
|
||||
|
||||
export type { CacheType };
|
||||
|
||||
interface KomgaRequestInit extends RequestInit {
|
||||
isImage?: boolean;
|
||||
noJson?: boolean;
|
||||
@@ -25,68 +16,7 @@ interface KomgaUrlBuilder {
|
||||
}
|
||||
|
||||
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> {
|
||||
// Initialiser les services si ce n'est pas déjà fait
|
||||
await Promise.all([this.initializeRequestQueue(), this.initializeCircuitBreaker()]);
|
||||
try {
|
||||
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||
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(
|
||||
config: AuthConfig,
|
||||
path: string,
|
||||
@@ -159,12 +73,6 @@ export abstract class BaseApiService {
|
||||
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>(
|
||||
urlBuilder: KomgaUrlBuilder,
|
||||
headersOptions = {},
|
||||
@@ -172,7 +80,7 @@ export abstract class BaseApiService {
|
||||
): Promise<T> {
|
||||
const config: AuthConfig = await this.getKomgaConfig();
|
||||
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);
|
||||
if (headersOptions) {
|
||||
@@ -185,10 +93,6 @@ export abstract class BaseApiService {
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
const queueStats = {
|
||||
active: RequestQueueService.getActiveCount(),
|
||||
queued: RequestQueueService.getQueueLength(),
|
||||
};
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
@@ -196,28 +100,24 @@ export abstract class BaseApiService {
|
||||
params,
|
||||
isImage: options.isImage,
|
||||
noJson: options.noJson,
|
||||
queue: queueStats,
|
||||
},
|
||||
"🔵 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 controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
// Utiliser le circuit breaker pour éviter de surcharger Komga
|
||||
const response = await CircuitBreakerService.execute(async () => {
|
||||
// Enqueue la requête pour limiter la concurrence
|
||||
return await RequestQueueService.enqueue(async () => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// Configure undici connection timeouts
|
||||
// @ts-ignore - undici-specific options not in standard fetch types
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
@@ -226,12 +126,9 @@ export abstract class BaseApiService {
|
||||
} catch (fetchError: any) {
|
||||
// Gestion spécifique des erreurs DNS
|
||||
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
|
||||
logger.error(
|
||||
`DNS resolution failed for ${url}. Retrying with different DNS settings...`
|
||||
);
|
||||
logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
|
||||
|
||||
// Retry avec des paramètres DNS différents
|
||||
return await fetch(url, {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
@@ -243,13 +140,11 @@ export abstract class BaseApiService {
|
||||
// @ts-ignore
|
||||
family: 4,
|
||||
});
|
||||
}
|
||||
|
||||
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
// 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)...`);
|
||||
|
||||
return await fetch(url, {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
@@ -258,12 +153,11 @@ export abstract class BaseApiService {
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
@@ -320,7 +214,6 @@ export abstract class BaseApiService {
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
RequestMonitorService.decrementActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
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 { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { SeriesService } from "./series.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
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 {
|
||||
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> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaBookWithPages>(
|
||||
`book-${bookId}`,
|
||||
async () => {
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
||||
@@ -35,13 +23,11 @@ export class BookService extends BaseApiService {
|
||||
book,
|
||||
pages: pages.map((page: any) => page.number),
|
||||
};
|
||||
},
|
||||
"BOOKS"
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
||||
try {
|
||||
// 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> {
|
||||
try {
|
||||
// Récupérer le livre sans cache pour éviter les données obsolètes
|
||||
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
|
||||
return book.seriesId;
|
||||
} catch (error) {
|
||||
@@ -136,12 +121,10 @@ export class BookService extends BaseApiService {
|
||||
response.buffer.byteOffset + response.buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
return new Response(arrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -153,7 +136,6 @@ export class BookService extends BaseApiService {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
||||
if (preferences.showThumbnails) {
|
||||
@@ -161,7 +143,7 @@ export class BookService extends BaseApiService {
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${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(
|
||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
||||
);
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -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();
|
||||
@@ -2,7 +2,7 @@ import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
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 {
|
||||
private static async getCurrentUser(): Promise<User> {
|
||||
@@ -62,58 +62,4 @@ export class ConfigDBService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
@@ -12,9 +11,6 @@ export class HomeService extends BaseApiService {
|
||||
static async getHomeData(): Promise<HomeData> {
|
||||
try {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
||||
"home-ongoing",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series",
|
||||
params: {
|
||||
@@ -25,11 +21,6 @@ export class HomeService extends BaseApiService {
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-ongoing-books",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books",
|
||||
params: {
|
||||
@@ -40,11 +31,6 @@ export class HomeService extends BaseApiService {
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-recently-read",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/latest",
|
||||
params: {
|
||||
@@ -53,11 +39,6 @@ export class HomeService extends BaseApiService {
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-on-deck",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
@@ -66,11 +47,6 @@ export class HomeService extends BaseApiService {
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
||||
"home-latest-series",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series/latest",
|
||||
params: {
|
||||
@@ -79,13 +55,11 @@ export class HomeService extends BaseApiService {
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
ongoing: ongoing.content || [],
|
||||
ongoingBooks: ongoingBooks.content || [], // Nouveau champ
|
||||
ongoingBooks: ongoingBooks.content || [],
|
||||
recentlyRead: recentlyRead.content || [],
|
||||
onDeck: onDeck.content || [],
|
||||
latestSeries: latestSeries.content || [],
|
||||
@@ -97,17 +71,4 @@ export class HomeService extends BaseApiService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { Series } from "@/types/series";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
@@ -9,11 +8,7 @@ import type { KomgaLibrary } from "@/types/komga";
|
||||
export class LibraryService extends BaseApiService {
|
||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaLibrary[]>(
|
||||
"libraries",
|
||||
async () => this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" }),
|
||||
"LIBRARIES"
|
||||
);
|
||||
return this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" });
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
@@ -87,14 +82,6 @@ export class LibraryService extends BaseApiService {
|
||||
|
||||
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[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
@@ -106,7 +93,7 @@ export class LibraryService extends BaseApiService {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
return this.fetchFromApi<LibraryResponse<Series>>(
|
||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
@@ -114,9 +101,6 @@ export class LibraryService extends BaseApiService {
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
},
|
||||
"SERIES"
|
||||
);
|
||||
|
||||
// Filtrer uniquement les séries supprimées côté client (léger)
|
||||
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> {
|
||||
try {
|
||||
await this.fetchFromApi(
|
||||
|
||||
@@ -2,11 +2,7 @@ import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type {
|
||||
UserPreferences,
|
||||
BackgroundPreferences,
|
||||
CircuitBreakerConfig,
|
||||
} from "@/types/preferences";
|
||||
import type { UserPreferences, BackgroundPreferences } from "@/types/preferences";
|
||||
import { defaultPreferences } from "@/types/preferences";
|
||||
import type { User } from "@/types/komga";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
@@ -37,7 +33,6 @@ export class PreferencesService {
|
||||
|
||||
return {
|
||||
showThumbnails: preferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: preferences.showOnlyUnread,
|
||||
displayMode: {
|
||||
...defaultPreferences.displayMode,
|
||||
@@ -45,9 +40,7 @@ export class PreferencesService {
|
||||
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
||||
},
|
||||
background: preferences.background as unknown as BackgroundPreferences,
|
||||
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests,
|
||||
readerPrefetchCount: preferences.readerPrefetchCount,
|
||||
circuitBreakerConfig: preferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -65,17 +58,12 @@ export class PreferencesService {
|
||||
const updateData: Record<string, any> = {};
|
||||
if (preferences.showThumbnails !== undefined)
|
||||
updateData.showThumbnails = preferences.showThumbnails;
|
||||
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
|
||||
if (preferences.showOnlyUnread !== undefined)
|
||||
updateData.showOnlyUnread = preferences.showOnlyUnread;
|
||||
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
|
||||
if (preferences.background !== undefined) updateData.background = preferences.background;
|
||||
if (preferences.komgaMaxConcurrentRequests !== undefined)
|
||||
updateData.komgaMaxConcurrentRequests = preferences.komgaMaxConcurrentRequests;
|
||||
if (preferences.readerPrefetchCount !== undefined)
|
||||
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
||||
if (preferences.circuitBreakerConfig !== undefined)
|
||||
updateData.circuitBreakerConfig = preferences.circuitBreakerConfig;
|
||||
|
||||
const updatedPreferences = await prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
@@ -83,28 +71,20 @@ export class PreferencesService {
|
||||
create: {
|
||||
userId,
|
||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
|
||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||
background: (preferences.background ??
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
showThumbnails: updatedPreferences.showThumbnails,
|
||||
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||
background: updatedPreferences.background as unknown as BackgroundPreferences,
|
||||
komgaMaxConcurrentRequests: updatedPreferences.komgaMaxConcurrentRequests,
|
||||
readerPrefetchCount: updatedPreferences.readerPrefetchCount,
|
||||
circuitBreakerConfig:
|
||||
updatedPreferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -1,50 +1,27 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
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 type { ImageResponse } from "./image.service";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import type { ServerCacheService } from "./server-cache.service";
|
||||
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 {
|
||||
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> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaSeries>(
|
||||
`series-${seriesId}`,
|
||||
async () => this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` }),
|
||||
"SERIES"
|
||||
);
|
||||
return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
|
||||
} catch (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(
|
||||
seriesId: string,
|
||||
page: number = 0,
|
||||
@@ -96,19 +73,13 @@ export class SeriesService extends BaseApiService {
|
||||
|
||||
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[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "number,asc",
|
||||
};
|
||||
|
||||
return this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{ path: "books/list", params },
|
||||
headers,
|
||||
{
|
||||
@@ -116,9 +87,6 @@ export class SeriesService extends BaseApiService {
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
},
|
||||
"BOOKS"
|
||||
);
|
||||
|
||||
// Filtrer uniquement les livres supprimés côté client (léger)
|
||||
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> {
|
||||
try {
|
||||
return this.fetchWithCache<string>(
|
||||
`series-first-book-${seriesId}`,
|
||||
async () => {
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<
|
||||
LibraryResponse<KomgaBook>
|
||||
>({
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: `series/${seriesId}/books`,
|
||||
params: { page: "0", size: "1" },
|
||||
});
|
||||
@@ -160,9 +112,6 @@ export class SeriesService extends BaseApiService {
|
||||
}
|
||||
|
||||
return data.content[0].id;
|
||||
},
|
||||
"SERIES"
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
@@ -173,7 +122,6 @@ export class SeriesService extends BaseApiService {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
||||
if (preferences.showThumbnails) {
|
||||
@@ -181,7 +129,7 @@ export class SeriesService extends BaseApiService {
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Génère l'URL de base pour une image (sans cache version)
|
||||
* Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting
|
||||
* Génère l'URL pour une image (thumbnail de série ou de livre)
|
||||
*/
|
||||
export function getImageUrl(type: "series" | "book", id: string) {
|
||||
if (type === "series") {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type CacheType = "DEFAULT" | "HOME" | "LIBRARIES" | "SERIES" | "BOOKS" | "IMAGES";
|
||||
@@ -15,20 +15,6 @@ export interface KomgaConfig extends KomgaConfigData {
|
||||
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
|
||||
export interface KomgaUser {
|
||||
id: string;
|
||||
|
||||
@@ -9,15 +9,8 @@ export interface BackgroundPreferences {
|
||||
komgaLibraries?: string[]; // IDs des bibliothèques Komga sélectionnées
|
||||
}
|
||||
|
||||
export interface CircuitBreakerConfig {
|
||||
threshold?: number;
|
||||
timeout?: number;
|
||||
resetTimeout?: number;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
showThumbnails: boolean;
|
||||
cacheMode: "memory" | "file";
|
||||
showOnlyUnread: boolean;
|
||||
displayMode: {
|
||||
compact: boolean;
|
||||
@@ -25,14 +18,11 @@ export interface UserPreferences {
|
||||
viewMode: "grid" | "list";
|
||||
};
|
||||
background: BackgroundPreferences;
|
||||
komgaMaxConcurrentRequests: number;
|
||||
readerPrefetchCount: number;
|
||||
circuitBreakerConfig: CircuitBreakerConfig;
|
||||
}
|
||||
|
||||
export const defaultPreferences: UserPreferences = {
|
||||
showThumbnails: true,
|
||||
cacheMode: "memory",
|
||||
showOnlyUnread: false,
|
||||
displayMode: {
|
||||
compact: false,
|
||||
@@ -44,13 +34,7 @@ export const defaultPreferences: UserPreferences = {
|
||||
opacity: 10,
|
||||
blur: 0,
|
||||
},
|
||||
komgaMaxConcurrentRequests: 5,
|
||||
readerPrefetchCount: 5,
|
||||
circuitBreakerConfig: {
|
||||
threshold: 5,
|
||||
timeout: 30000,
|
||||
resetTimeout: 60000,
|
||||
},
|
||||
};
|
||||
|
||||
// Dégradés prédéfinis
|
||||
|
||||
Reference in New Issue
Block a user