feat: add caching debug logs and configurable max concurrent requests for Komga API to enhance performance monitoring

This commit is contained in:
Julien Froidefond
2025-10-18 09:08:41 +02:00
parent ae4b766085
commit b7704207ec
42 changed files with 1141 additions and 1302 deletions

6
ENV.md
View File

@@ -17,6 +17,12 @@ NEXTAUTH_URL=https://ton-domaine.com
# Admin User (optional - default password for julienfroidefond@gmail.com)
ADMIN_DEFAULT_PASSWORD=Admin@2025
# Cache Debug (optional - logs cache operations)
# CACHE_DEBUG=true
# Komga Request Queue (optional - max concurrent requests to Komga, default: 2)
# KOMGA_MAX_CONCURRENT_REQUESTS=5
# Node Environment
NODE_ENV=production
```

View File

@@ -33,6 +33,8 @@ services:
- WATCHPACK_POLLING=true
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- CACHE_DEBUG=true
- KOMGA_MAX_CONCURRENT_REQUESTS=5
command: sh -c "pnpm config set store-dir /app/.pnpm-store && pnpm install --frozen-lockfile && pnpm prisma generate && pnpm dev"
mongodb:

View File

@@ -20,6 +20,7 @@ services:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- AUTH_TRUST_HOST=true
- KOMGA_MAX_CONCURRENT_REQUESTS=5
depends_on:
mongodb:
condition: service_healthy

317
docs/cache-debug.md Normal file
View File

@@ -0,0 +1,317 @@
# Debug du Cache
Guide pour debugger le système de caching de StripStream.
## Activation des logs de cache
### Variable d'environnement
Activez les logs détaillés du cache serveur avec :
```bash
CACHE_DEBUG=true
```
### Configuration
#### Développement (docker-compose.dev.yml)
```yaml
environment:
- CACHE_DEBUG=true
```
#### Production (.env)
```env
CACHE_DEBUG=true
```
## Format des logs
Les logs de cache apparaissent dans la console serveur avec le format suivant :
### Cache HIT (donnée valide)
```
[CACHE HIT] home-ongoing | HOME | 0.45ms
```
- ✅ Donnée trouvée en cache
- ✅ Donnée encore valide (pas expirée)
- ⚡ Retour immédiat (très rapide)
### Cache STALE (donnée expirée)
```
[CACHE STALE] home-ongoing | HOME | 0.52ms
```
- ✅ Donnée trouvée en cache
- ⚠️ Donnée expirée mais toujours retournée
- 🔄 Revalidation lancée en background
### Cache MISS (pas de donnée)
```
[CACHE MISS] home-ongoing | HOME
```
- ❌ Aucune donnée en cache
- 🌐 Fetch normal depuis Komga
- 💾 Mise en cache automatique
### Cache SET (mise en cache)
```
[CACHE SET] home-ongoing | HOME | 324.18ms
```
- 💾 Donnée mise en cache après fetch
- 📊 Temps total incluant le fetch Komga
- ✅ Prochaines requêtes seront rapides
### Cache REVALIDATE (revalidation background)
```
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
```
- 🔄 Revalidation en background (après STALE)
- 🌐 Nouvelle donnée fetched depuis Komga
- 💾 Cache mis à jour pour les prochaines requêtes
### Erreur de revalidation
```
[CACHE REVALIDATE ERROR] home-ongoing: Error: ...
```
- ❌ Échec de la revalidation background
- ⚠️ Cache ancien conservé
- 🔄 Retry au prochain STALE
## Types de cache
Les logs affichent le type de TTL utilisé :
| Type | TTL | Usage |
|------|-----|-------|
| `DEFAULT` | 5 min | Données génériques |
| `HOME` | 10 min | Page d'accueil |
| `LIBRARIES` | 24h | Bibliothèques |
| `SERIES` | 5 min | Séries |
| `BOOKS` | 5 min | Livres |
| `IMAGES` | 7 jours | Images |
## Exemple de session complète
```bash
# Première requête (cache vide)
[CACHE MISS] home-ongoing | HOME
[CACHE SET] home-ongoing | HOME | 324.18ms
# Requête suivante (cache valide)
[CACHE HIT] home-ongoing | HOME | 0.45ms
# 10 minutes plus tard (cache expiré)
[CACHE STALE] home-ongoing | HOME | 0.52ms
[CACHE REVALIDATE] home-ongoing | HOME | 287.45ms
# Requête suivante (cache frais)
[CACHE HIT] home-ongoing | HOME | 0.43ms
```
## Outils complémentaires
### 1. DevTools du navigateur
#### Network Tab
- Temps de réponse < 50ms = probablement du cache serveur
- Headers `X-Cache` si configurés
- Onglet "Timing" pour détails
#### Application → Cache Storage
Inspectez le cache du Service Worker :
- `stripstream-cache-v1` : Ressources statiques
- `stripstream-images-v1` : Images (covers + pages)
Actions disponibles :
- ✅ Voir le contenu de chaque cache
- 🔍 Chercher une URL spécifique
- 🗑️ Supprimer des entrées
- 🧹 Vider complètement un cache
#### Application → Service Workers
- État du Service Worker
- "Unregister" pour le désactiver
- "Update" pour forcer une mise à jour
- Console pour voir les logs SW
### 2. API de monitoring
#### Taille du cache
```bash
curl http://localhost:3000/api/komga/cache/size
```
Response :
```json
{
"sizeInBytes": 15728640,
"itemCount": 234
}
```
#### Mode actuel
```bash
curl http://localhost:3000/api/komga/cache/mode
```
Response :
```json
{
"mode": "memory"
}
```
#### Vider le cache
```bash
curl -X POST http://localhost:3000/api/komga/cache/clear
```
#### Changer de mode
```bash
curl -X POST http://localhost:3000/api/komga/cache/mode \
-H "Content-Type: application/json" \
-d '{"mode": "file"}'
```
### 3. Mode fichier : Inspection du disque
Si vous utilisez le mode `file`, le cache est stocké sur disque :
```bash
# Voir la structure du cache
ls -la .cache/
# Voir la taille totale
du -sh .cache/
# Compter les fichiers
find .cache/ -type f | wc -l
# Voir le contenu d'une entrée
cat .cache/user-id/home-ongoing.json | jq
```
Exemple de contenu :
```json
{
"data": {
"ongoing": [...],
"recentlyRead": [...],
"onDeck": [...]
},
"expiry": 1704067200000
}
```
## Patterns de debug courants
### Identifier un problème de cache
**Symptôme** : Les données ne se rafraîchissent pas
```bash
# 1. Vérifier si STALE + REVALIDATE se produisent
CACHE_DEBUG=true
# 2. Observer les logs
[CACHE STALE] series-123 | SERIES | 0.5ms
[CACHE REVALIDATE ERROR] series-123: Network error
# 3. Problème identifié : Komga inaccessible
```
**Solution** : Vérifier la connectivité avec Komga
### Optimiser les performances
**Objectif** : Identifier les requêtes lentes
```bash
# Activer les logs
CACHE_DEBUG=true
# Observer les temps
[CACHE MISS] library-456-all-series | SERIES
[CACHE SET] library-456-all-series | SERIES | 2847.32ms # ⚠️ Très lent !
```
**Solution** :
- Vérifier la taille des bibliothèques
- Augmenter le TTL pour ces données
- Considérer la pagination
### Vérifier le mode de cache
```bash
# Logs après redémarrage
[CACHE MISS] home-ongoing | HOME # Mode memory : normal
[CACHE HIT] home-ongoing | HOME # Mode file : cache persisté
```
En mode `memory` : tous les caches sont vides au démarrage
En mode `file` : les caches survivent au redémarrage
## Performance attendue
### Temps de réponse normaux
| Scénario | Temps attendu | Log |
|----------|---------------|-----|
| Cache HIT | < 1ms | `[CACHE HIT] ... \| 0.45ms` |
| Cache STALE | < 1ms | `[CACHE STALE] ... \| 0.52ms` |
| Cache MISS (petit) | 50-200ms | `[CACHE SET] ... \| 124.18ms` |
| Cache MISS (gros) | 200-1000ms | `[CACHE SET] ... \| 847.32ms` |
| Revalidate (background) | Variable | `[CACHE REVALIDATE] ... \| 287.45ms` |
### Signaux d'alerte
⚠️ **Cache HIT > 10ms**
- Problème : Disque lent (mode file)
- Solution : Vérifier les I/O, passer en mode memory
⚠️ **Cache MISS > 2000ms**
- Problème : Komga très lent ou données énormes
- Solution : Vérifier Komga, optimiser la requête
⚠️ **REVALIDATE ERROR fréquents**
- Problème : Komga instable ou réseau
- Solution : Augmenter les timeouts, vérifier la connectivité
⚠️ **Trop de MISS successifs**
- Problème : Cache pas conservé ou TTL trop court
- Solution : Vérifier le mode, augmenter les TTL
## Désactiver les logs
Pour désactiver les logs de cache en production :
```bash
# .env
CACHE_DEBUG=false
# ou simplement commenter/supprimer la ligne
# CACHE_DEBUG=true
```
Les logs sont **automatiquement désactivés** si la variable n'est pas définie.
## Logs et performance
**Impact sur les performances** :
- Overhead : < 0.1ms par opération
- Pas d'écriture disque (juste console)
- Pas d'accumulation en mémoire
- Safe pour la production
**Recommandations** :
- ✅ Activé en développement
- ✅ Activé temporairement en production pour diagnostics
- ❌ Pas nécessaire en production normale
## Conclusion
Le système de logs de cache est conçu pour être :
- 🎯 **Simple** : Format clair et concis
-**Rapide** : Impact négligeable sur les performances
- 🔧 **Utile** : Informations essentielles pour le debug
- 🔒 **Optionnel** : Désactivé par défaut
Pour la plupart des besoins de debug, les DevTools du navigateur suffisent.
Les logs serveur sont utiles pour comprendre le comportement du cache côté backend.

502
docs/caching.md Normal file
View File

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

View File

@@ -65,7 +65,6 @@ model Preferences {
showThumbnails Boolean @default(true)
cacheMode String @default("memory") // "memory" | "file"
showOnlyUnread Boolean @default(false)
debug Boolean @default(false)
displayMode Json @default("{\"compact\": false, \"itemsPerPage\": 20}")
background Json @default("{\"type\": \"default\"}")
createdAt DateTime @default(now())

View File

@@ -1,6 +1,5 @@
const CACHE_NAME = "stripstream-cache-v1";
const BOOKS_CACHE_NAME = "stripstream-books";
const COVERS_CACHE_NAME = "stripstream-covers";
const IMAGES_CACHE_NAME = "stripstream-images-v1";
const OFFLINE_PAGE = "/offline.html";
const STATIC_ASSETS = [
@@ -16,8 +15,7 @@ self.addEventListener("install", (event) => {
event.waitUntil(
Promise.all([
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
caches.open(BOOKS_CACHE_NAME),
caches.open(COVERS_CACHE_NAME),
caches.open(IMAGES_CACHE_NAME),
])
);
});
@@ -28,9 +26,7 @@ self.addEventListener("activate", (event) => {
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter(
(name) => name !== CACHE_NAME && name !== BOOKS_CACHE_NAME && name !== COVERS_CACHE_NAME
)
.filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME)
.map((name) => caches.delete(name))
);
})
@@ -53,48 +49,47 @@ const isNextStaticResource = (url) => {
return url.includes("/_next/static") && !isWebpackResource(url);
};
// Fonction pour vérifier si c'est une ressource de livre
const isBookResource = (url) => {
return url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail"));
};
// Fonction pour vérifier si c'est une image de couverture
const isCoverImage = (url) => {
const urlParams = new URLSearchParams(url.split("?")[1]);
const originalUrl = urlParams.get("url") || url;
// Fonction pour vérifier si c'est une image (couvertures ou pages de livres)
const isImageResource = (url) => {
return (
originalUrl.includes("/api/komga/images/") &&
(originalUrl.includes("/series/") || originalUrl.includes("/books/")) &&
originalUrl.includes("/thumbnail")
(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"))
);
};
// Stratégie de cache pour les images de couverture
const coverCacheStrategy = async (request) => {
const urlParams = new URLSearchParams(request.url.split("?")[1]);
const originalUrl = urlParams.get("url") || request.url;
const originalRequest = new Request(originalUrl, request);
const cache = await caches.open(COVERS_CACHE_NAME);
const cachedResponse = await cache.match(originalRequest);
// Stratégie Cache-First pour les images
const imageCacheStrategy = async (request) => {
const cache = await caches.open(IMAGES_CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(originalRequest);
const response = await fetch(request);
if (response.ok) {
await cache.put(originalRequest, response.clone());
await cache.put(request, response.clone());
return response;
}
throw new Error("Network response was not ok");
// Si 404, retourner une réponse vide sans throw (pas d'erreur console)
if (response.status === 404) {
return new Response("", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
},
});
}
// Pour les autres erreurs, throw
throw new Error(`Network response error: ${response.status}`);
} catch (error) {
console.error("Error fetching cover image", error);
// Erreurs réseau (offline, timeout, etc.)
console.warn("Image fetch failed:", error);
return new Response("", {
status: 404,
statusText: "Not Found",
status: 503,
statusText: "Service Unavailable",
headers: {
"Content-Type": "text/plain",
},
@@ -102,25 +97,6 @@ const coverCacheStrategy = async (request) => {
}
};
// Écouteur pour les messages du client
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "CACHE_COVER") {
const { url } = event.data;
if (url && isCoverImage(url)) {
fetch(url)
.then(async (response) => {
if (response.ok) {
const cache = await caches.open(COVERS_CACHE_NAME);
await cache.put(url, response.clone());
}
})
.catch(() => {
// Ignorer les erreurs de mise en cache
});
}
}
});
self.addEventListener("fetch", (event) => {
// Ignorer les requêtes non GET
if (event.request.method !== "GET") return;
@@ -128,54 +104,13 @@ self.addEventListener("fetch", (event) => {
// Ignorer les ressources webpack
if (isWebpackResource(event.request.url)) return;
// Gérer les requêtes d'images de couverture
if (event.request.url.includes("/api/v1/books/") && event.request.url.includes("/cover")) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request)
.then((response) => {
if (!response.ok) {
return new Response(null, { status: 404 });
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return new Response(null, { status: 404 });
});
})
);
}
// Pour les images de couverture
if (isCoverImage(event.request.url)) {
event.respondWith(coverCacheStrategy(event.request));
// Gérer les images avec Cache-First
if (isImageResource(event.request.url)) {
event.respondWith(imageCacheStrategy(event.request));
return;
}
// Pour les ressources de livre
if (isBookResource(event.request.url)) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request);
})
);
return;
}
// Pour les ressources statiques de Next.js et les autres requêtes
// Pour les ressources statiques de Next.js et les autres requêtes : Network-First
event.respondWith(
fetch(event.request)
.then((response) => {

View File

@@ -1,105 +0,0 @@
import type { NextRequest} from "next/server";
import { NextResponse } from "next/server";
import type { RequestTiming } from "@/lib/services/debug.service";
import { DebugService } from "@/lib/services/debug.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors";
export async function GET() {
try {
const logs: RequestTiming[] = await DebugService.getRequestLogs();
return NextResponse.json(logs);
} catch (error) {
console.error("Erreur lors de la récupération des logs:", error);
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Debug fetch error",
message: getErrorMessage(error.code),
} as AppError,
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.DEBUG.FETCH_ERROR,
name: "Debug fetch error",
message: getErrorMessage(ERROR_CODES.DEBUG.FETCH_ERROR),
} as AppError,
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const timing: RequestTiming = await request.json();
await DebugService.logRequest(timing);
return NextResponse.json({
message: "✅ Log enregistré avec succès",
});
} catch (error) {
console.error("Erreur lors de l'enregistrement du log:", error);
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Debug save error",
message: getErrorMessage(error.code),
} as AppError,
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.DEBUG.SAVE_ERROR,
name: "Debug save error",
message: getErrorMessage(ERROR_CODES.DEBUG.SAVE_ERROR),
} as AppError,
},
{ status: 500 }
);
}
}
export async function DELETE() {
try {
await DebugService.clearLogs();
return NextResponse.json({
message: "🧹 Logs supprimés avec succès",
});
} catch (error) {
console.error("Erreur lors de la suppression des logs:", error);
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Debug clear error",
message: getErrorMessage(error.code),
} as AppError,
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.DEBUG.CLEAR_ERROR,
name: "Debug clear error",
message: getErrorMessage(ERROR_CODES.DEBUG.CLEAR_ERROR),
} as AppError,
},
{ status: 500 }
);
}
}

View File

@@ -8,11 +8,7 @@ export const revalidate = 60;
export async function GET() {
try {
const data = await HomeService.getHomeData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120'
}
});
return NextResponse.json(data);
} catch (error) {
console.error("API Home - Erreur:", error);
if (error instanceof AppError) {

View File

@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import { findHttpStatus } from "@/utils/image-errors";
export const dynamic = "force-dynamic";
@@ -18,6 +19,26 @@ export async function GET(
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la page du livre:", error);
// Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error);
if (httpStatus === 404) {
const { bookId, pageNumber } = await params;
// eslint-disable-next-line no-console
console.log(`📷 Page ${pageNumber} not found for book: ${bookId}`);
return NextResponse.json(
{
error: {
code: ERROR_CODES.IMAGE.FETCH_ERROR,
name: "Image not found",
message: "Image not found",
},
},
{ status: 404 }
);
}
if (error instanceof AppError) {
return NextResponse.json(
{

View File

@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import { findHttpStatus } from "@/utils/image-errors";
export const dynamic = "force-dynamic";
@@ -32,6 +33,27 @@ export async function GET(
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la miniature de la page:", error);
// Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error);
if (httpStatus === 404) {
const { bookId, pageNumber: pageNumberParam } = await params;
const pageNumber: number = parseInt(pageNumberParam);
// eslint-disable-next-line no-console
console.log(`📷 Page ${pageNumber} thumbnail not found for book: ${bookId}`);
return NextResponse.json(
{
error: {
code: ERROR_CODES.IMAGE.FETCH_ERROR,
name: "Image not found",
message: "Image not found",
},
},
{ status: 404 }
);
}
if (error instanceof AppError) {
return NextResponse.json(
{

View File

@@ -4,6 +4,7 @@ import { BookService } from "@/lib/services/book.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import { findHttpStatus } from "@/utils/image-errors";
export async function GET(
request: NextRequest,
@@ -16,6 +17,26 @@ export async function GET(
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la miniature du livre:", error);
// Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error);
if (httpStatus === 404) {
const bookId: string = (await params).bookId;
// eslint-disable-next-line no-console
console.log(`📷 Thumbnail not found for book: ${bookId}`);
return NextResponse.json(
{
error: {
code: ERROR_CODES.IMAGE.FETCH_ERROR,
name: "Image not found",
message: "Image not found",
},
},
{ status: 404 }
);
}
if (error instanceof AppError) {
return NextResponse.json(
{

View File

@@ -4,6 +4,7 @@ import { SeriesService } from "@/lib/services/series.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import { findHttpStatus } from "@/utils/image-errors";
export const dynamic = "force-dynamic";
@@ -18,6 +19,26 @@ export async function GET(
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la couverture de la série:", error);
// Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error);
if (httpStatus === 404) {
const seriesId: string = (await params).seriesId;
// eslint-disable-next-line no-console
console.log(`📷 First page image not found for series: ${seriesId}`);
return NextResponse.json(
{
error: {
code: ERROR_CODES.IMAGE.FETCH_ERROR,
name: "Image not found",
message: "Image not found",
},
},
{ status: 404 }
);
}
if (error instanceof AppError) {
return NextResponse.json(
{

View File

@@ -4,6 +4,7 @@ import { SeriesService } from "@/lib/services/series.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import { findHttpStatus } from "@/utils/image-errors";
export async function GET(
request: NextRequest,
@@ -15,6 +16,26 @@ export async function GET(
return response;
} catch (error) {
console.error("Erreur lors de la récupération de la miniature de la série:", error);
// Chercher un status HTTP 404 dans la chaîne d'erreurs
const httpStatus = findHttpStatus(error);
if (httpStatus === 404) {
const seriesId: string = (await params).seriesId;
// eslint-disable-next-line no-console
console.log(`📷 Image not found for series: ${seriesId}`);
return NextResponse.json(
{
error: {
code: ERROR_CODES.IMAGE.FETCH_ERROR,
name: "Image not found",
message: "Image not found",
},
},
{ status: 404 }
);
}
if (error instanceof AppError) {
return NextResponse.json(
{

View File

@@ -1,9 +1,8 @@
import { Suspense } from "react";
import { ClientBookPage } from "@/components/reader/ClientBookPage";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
import { withPageTiming } from "@/lib/hoc/withPageTiming";
async function BookPage({ params }: { params: { bookId: string } }) {
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
const { bookId } = await params;
return (
@@ -12,5 +11,3 @@ async function BookPage({ params }: { params: { bookId: string } }) {
</Suspense>
);
}
export default withPageTiming("BookPage", BookPage);

View File

@@ -1,14 +1,11 @@
import { DownloadManager } from "@/components/downloads/DownloadManager";
import { withPageTiming } from "@/lib/hoc/withPageTiming";
export const dynamic = 'force-dynamic';
function DownloadsPage() {
export default function DownloadsPage() {
return (
<>
<DownloadManager />
</>
);
}
export default withPageTiming("DownloadsPage", DownloadsPage);

View File

@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { LoginContent } from "./LoginContent";
import { withPageTiming } from "@/lib/hoc/withPageTiming";
interface PageProps {
searchParams: Promise<{
@@ -13,7 +12,6 @@ export const metadata: Metadata = {
description: "Connectez-vous à votre compte StripStream",
};
async function LoginPage({ searchParams }: PageProps) {
export default async function LoginPage({ searchParams }: PageProps) {
return <LoginContent searchParams={await searchParams} />;
}
export default withPageTiming("LoginPage", LoginPage);

View File

@@ -1,281 +0,0 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
import {
X,
Database,
Minimize2,
Maximize2,
Clock,
CircleDot,
Layout,
RefreshCw,
Globe,
Filter,
Calendar,
} from "lucide-react";
import type { RequestTiming } from "@/types/debug";
import { useTranslation } from "react-i18next";
import { useDebug } from "@/contexts/DebugContext";
function formatTime(timestamp: string) {
const date = new Date(timestamp);
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDuration(duration: number) {
return Math.round(duration);
}
type FilterType = "all" | "current-page" | "api" | "cache" | "mongodb" | "page-render";
export function DebugInfo() {
const { logs, setLogs, clearLogs, isRefreshing, setIsRefreshing } = useDebug();
const [isMinimized, setIsMinimized] = useState(false);
const [filter, setFilter] = useState<FilterType>("all");
const [showFilters, setShowFilters] = useState(false);
const pathname = usePathname();
const { t } = useTranslation();
const fetchLogs = async () => {
try {
setIsRefreshing(true);
const response = await fetch("/api/debug");
if (response.ok) {
const data = await response.json();
setLogs(data);
}
} catch (error) {
console.error("Erreur lors de la récupération des logs:", error);
} finally {
setIsRefreshing(false);
}
};
// Fonction pour déterminer si une requête appartient à la page courante
const isCurrentPageRequest = (log: RequestTiming): boolean => {
if (log.pageRender) {
return log.pageRender.page === pathname;
}
// Pour les requêtes API, on considère qu'elles appartiennent à la page courante
// si elles ont été faites récemment (dans les 30 dernières secondes)
const logTime = new Date(log.timestamp).getTime();
const now = Date.now();
return now - logTime < 30000; // 30 secondes
};
// Filtrer les logs selon le filtre sélectionné
const filteredLogs = logs.filter((log) => {
switch (filter) {
case "current-page":
return isCurrentPageRequest(log);
case "api":
return !log.fromCache && !log.mongoAccess && !log.pageRender;
case "cache":
return log.fromCache;
case "mongodb":
return log.mongoAccess;
case "page-render":
return log.pageRender;
default:
return true;
}
});
const sortedLogs = [...filteredLogs].reverse();
return (
<div
className={`fixed bottom-4 right-4 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-lg p-4 text-zinc-100 z-50 ${
isMinimized ? "w-auto" : "w-[800px] max-h-[50vh] overflow-auto"
}`}
>
<div className="flex items-center justify-between mb-4 sticky top-0 bg-zinc-900/90 backdrop-blur-md pb-2">
<div className="flex items-center gap-2">
<h2 className="font-bold text-lg">{t("debug.title")}</h2>
{!isMinimized && (
<span className="text-xs text-zinc-400">
{sortedLogs.length} {t("debug.entries", { count: sortedLogs.length })}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isMinimized && (
<button
onClick={() => setShowFilters(!showFilters)}
className={`hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5 ${showFilters ? "bg-zinc-700/80 backdrop-blur-md" : ""}`}
aria-label="Filtres"
>
<Filter className="h-5 w-5" />
</button>
)}
<button
onClick={fetchLogs}
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
aria-label={t("debug.actions.refresh")}
disabled={isRefreshing}
>
<RefreshCw className={`h-5 w-5 ${isRefreshing ? "animate-spin" : ""}`} />
</button>
<button
onClick={() => setIsMinimized(!isMinimized)}
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
aria-label={t(isMinimized ? "debug.actions.maximize" : "debug.actions.minimize")}
>
{isMinimized ? <Maximize2 className="h-5 w-5" /> : <Minimize2 className="h-5 w-5" />}
</button>
<button
onClick={clearLogs}
className="hover:bg-zinc-700/80 hover:backdrop-blur-md rounded-full p-1.5"
aria-label={t("debug.actions.clear")}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{!isMinimized && showFilters && (
<div className="mb-4 p-3 bg-zinc-800/80 backdrop-blur-md rounded-lg">
<div className="flex flex-wrap gap-2">
{[
{ key: "all", label: "Toutes", icon: Calendar },
{ key: "current-page", label: "Page courante", icon: Layout },
{ key: "api", label: "API", icon: Globe },
{ key: "cache", label: "Cache", icon: Database },
{ key: "mongodb", label: "MongoDB", icon: CircleDot },
{ key: "page-render", label: "Rendu", icon: Layout },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setFilter(key as FilterType)}
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-xs transition-colors ${
filter === key
? "bg-blue-600 text-white"
: "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
}`}
>
<Icon className="h-3 w-3" />
{label}
</button>
))}
</div>
</div>
)}
{!isMinimized && (
<div className="space-y-3">
{sortedLogs.length === 0 ? (
<p className="text-sm opacity-75">{t("debug.noRequests")}</p>
) : (
sortedLogs.map((log, index) => {
const isCurrentPage = isCurrentPageRequest(log);
return (
<div
key={index}
className={`text-sm space-y-1.5 p-2 rounded border-l-2 ${
isCurrentPage
? "bg-blue-900/20 border-blue-500"
: "bg-zinc-800 border-zinc-700"
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 min-w-0 flex-1">
{log.fromCache && (
<div
title={t("debug.tooltips.cache", { type: log.cacheType || "DEFAULT" })}
className="flex-shrink-0"
>
<Database className="h-4 w-4" />
</div>
)}
{log.mongoAccess && (
<div
title={t("debug.tooltips.mongodb", {
operation: log.mongoAccess.operation,
})}
className="flex-shrink-0"
>
<CircleDot className="h-4 w-4 text-blue-400" />
</div>
)}
{log.pageRender && (
<div
title={t("debug.tooltips.pageRender", { page: log.pageRender.page })}
className="flex-shrink-0"
>
<Layout className="h-4 w-4 text-purple-400" />
</div>
)}
{!log.fromCache && !log.mongoAccess && !log.pageRender && (
<div title={t("debug.tooltips.apiCall")} className="flex-shrink-0">
<Globe className="h-4 w-4 text-rose-400" />
</div>
)}
<span className="font-medium truncate" title={log.url}>
{log.url}
</span>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div
className="flex items-center gap-1 text-zinc-400"
title={t("debug.tooltips.logTime")}
>
<Clock className="h-3 w-3" />
<span>{formatTime(log.timestamp)}</span>
</div>
<span
className={`${
log.pageRender
? "text-purple-400"
: log.mongoAccess
? "text-blue-400"
: log.fromCache
? "text-emerald-400"
: "text-rose-400"
}`}
>
{formatDuration(log.duration)}ms
</span>
{log.mongoAccess && (
<span className="text-blue-400" title={t("debug.tooltips.mongoAccess")}>
+{formatDuration(log.mongoAccess.duration)}ms
</span>
)}
</div>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full ${
log.pageRender
? "bg-purple-500"
: log.fromCache
? "bg-emerald-500"
: "bg-rose-500"
}`}
style={{
width: `${Math.min((log.duration / 1000) * 100, 100)}%`,
}}
/>
{log.mongoAccess && (
<div
className="h-full bg-blue-500"
style={{
width: `${Math.min((log.mongoAccess.duration / 1000) * 100, 100)}%`,
marginTop: "-6px",
}}
/>
)}
</div>
</div>
);
})
)}
</div>
)}
</div>
);
}

View File

@@ -1,13 +0,0 @@
"use client";
import { usePreferences } from "@/contexts/PreferencesContext";
import { DebugInfo } from "./DebugInfo";
export function DebugWrapper() {
const { preferences } = usePreferences();
if (!preferences.debug) {
return null;
}
return <DebugInfo />;
}

View File

@@ -9,8 +9,6 @@ import { Toaster } from "@/components/ui/toaster";
import { usePathname } from "next/navigation";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus";
import { DebugWrapper } from "@/components/debug/DebugWrapper";
import { DebugProvider } from "@/contexts/DebugContext";
import { usePreferences } from "@/contexts/PreferencesContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
@@ -94,25 +92,22 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<DebugProvider>
<div className="relative min-h-screen bg-background" style={backgroundStyle}>
{!isPublicRoute && <Header onToggleSidebar={handleToggleSidebar} />}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={`${!isPublicRoute ? "container pt-safe" : ""}`}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
<DebugWrapper />
</div>
</DebugProvider>
<div className="relative min-h-screen bg-background" style={backgroundStyle}>
{!isPublicRoute && <Header onToggleSidebar={handleToggleSidebar} />}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>
)}
<main className={`${!isPublicRoute ? "container pt-safe" : ""}`}>{children}</main>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
</ThemeProvider>
);
}

View File

@@ -75,34 +75,6 @@ export function DisplaySettings() {
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="debug-mode">{t("settings.display.debugMode.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.display.debugMode.description")}
</p>
</div>
<Switch
id="debug-mode"
checked={preferences.debug}
onCheckedChange={async (checked) => {
try {
await updatePreferences({ debug: checked });
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
console.error("Erreur détaillée:", error);
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
}}
/>
</div>
</CardContent>
</Card>
);

View File

@@ -87,11 +87,6 @@ export const ERROR_CODES = {
INVALID_TOKEN: "MIDDLEWARE_INVALID_TOKEN",
INVALID_SESSION: "MIDDLEWARE_INVALID_SESSION",
},
DEBUG: {
FETCH_ERROR: "DEBUG_FETCH_ERROR",
SAVE_ERROR: "DEBUG_SAVE_ERROR",
CLEAR_ERROR: "DEBUG_CLEAR_ERROR",
},
CLIENT: {
FETCH_ERROR: "CLIENT_FETCH_ERROR",
NETWORK_ERROR: "CLIENT_NETWORK_ERROR",

View File

@@ -85,11 +85,6 @@ export const ERROR_MESSAGES: Record<string, string> = {
[ERROR_CODES.CONFIG.TTL_SAVE_ERROR]: "⏱️ Error saving TTL configuration",
[ERROR_CODES.CONFIG.TTL_FETCH_ERROR]: "⏱️ Error fetching TTL configuration",
// Debug
[ERROR_CODES.DEBUG.FETCH_ERROR]: "🔍 Error fetching logs",
[ERROR_CODES.DEBUG.SAVE_ERROR]: "💾 Error saving log",
[ERROR_CODES.DEBUG.CLEAR_ERROR]: "🧹 Error clearing logs",
// Client
[ERROR_CODES.CLIENT.FETCH_ERROR]: "🌐 Error during request",
[ERROR_CODES.CLIENT.NETWORK_ERROR]: "📡 Network connection error",

View File

@@ -1,106 +0,0 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import type { RequestTiming } from "@/types/debug";
import { usePreferences } from "./PreferencesContext";
interface DebugContextType {
logs: RequestTiming[];
setLogs: (logs: RequestTiming[]) => void;
addLog: (log: RequestTiming) => void;
clearLogs: () => void;
isRefreshing: boolean;
setIsRefreshing: (refreshing: boolean) => void;
}
const DebugContext = createContext<DebugContextType | undefined>(undefined);
interface DebugProviderProps {
children: ReactNode;
}
export function DebugProvider({ children }: DebugProviderProps) {
const [logs, setLogs] = useState<RequestTiming[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const { preferences } = usePreferences();
const addLog = (log: RequestTiming) => {
setLogs(prevLogs => {
// Éviter les doublons basés sur l'URL et le timestamp
const exists = prevLogs.some(existingLog =>
existingLog.url === log.url && existingLog.timestamp === log.timestamp
);
if (exists) return prevLogs;
return [...prevLogs, log].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
});
};
const clearLogs = async () => {
try {
// Vider le fichier côté serveur
await fetch("/api/debug", { method: "DELETE" });
// Vider le state côté client
setLogs([]);
} catch (error) {
console.error("Erreur lors de la suppression des logs:", error);
// Même en cas d'erreur, vider le state côté client
setLogs([]);
}
};
// Charger les logs au montage du provider et les rafraîchir périodiquement
useEffect(() => {
const fetchLogs = async () => {
try {
// Ne pas faire de requête si le debug n'est pas activé
if (!preferences.debug) {
return;
}
setIsRefreshing(true);
const debugResponse = await fetch("/api/debug");
if (debugResponse.ok) {
const serverLogs = await debugResponse.json();
setLogs(serverLogs);
}
} catch (error) {
console.error("Erreur lors de la récupération des logs:", error);
} finally {
setIsRefreshing(false);
}
};
fetchLogs();
// Rafraîchir toutes les 10 secondes (moins fréquent pour éviter les conflits)
const interval = setInterval(fetchLogs, 10000);
return () => clearInterval(interval);
}, [preferences.debug]);
return (
<DebugContext.Provider
value={{
logs,
setLogs,
addLog,
clearLogs,
isRefreshing,
setIsRefreshing
}}
>
{children}
</DebugContext.Provider>
);
}
export function useDebug() {
const context = useContext(DebugContext);
if (context === undefined) {
throw new Error("useDebug must be used within a DebugProvider");
}
return context;
}

View File

@@ -77,10 +77,6 @@
"unreadFilter": {
"label": "Default Unread Filter",
"description": "Show only unread series by default"
},
"debugMode": {
"label": "Debug mode",
"description": "Show debug information in the interface"
}
},
"background": {
@@ -388,10 +384,6 @@
"MIDDLEWARE_INVALID_TOKEN": "Invalid authentication token",
"MIDDLEWARE_INVALID_SESSION": "Invalid session",
"DEBUG_FETCH_ERROR": "Error fetching debug logs",
"DEBUG_SAVE_ERROR": "Error saving debug logs",
"DEBUG_CLEAR_ERROR": "Error clearing debug logs",
"CLIENT_FETCH_ERROR": "Error fetching data",
"CLIENT_NETWORK_ERROR": "Network error",
"CLIENT_REQUEST_FAILED": "Request failed",
@@ -434,25 +426,5 @@
},
"common": {
"retry": "Retry"
},
"debug": {
"title": "DEBUG",
"entries": "entry",
"entries_plural": "entries",
"noRequests": "No recorded requests",
"actions": {
"refresh": "Refresh logs",
"maximize": "Maximize",
"minimize": "Minimize",
"clear": "Clear logs"
},
"tooltips": {
"cache": "Cache: {{type}}",
"mongodb": "MongoDB: {{operation}}",
"pageRender": "Page Render: {{page}}",
"apiCall": "API Call",
"logTime": "Log time",
"mongoAccess": "MongoDB access time"
}
}
}

View File

@@ -77,10 +77,6 @@
"unreadFilter": {
"label": "Filtre \"À lire\" par défaut",
"description": "Afficher uniquement les séries non lues par défaut"
},
"debugMode": {
"label": "Mode debug",
"description": "Afficher les informations de debug dans l'interface"
}
},
"background": {
@@ -386,10 +382,6 @@
"MIDDLEWARE_INVALID_TOKEN": "Jeton d'authentification invalide",
"MIDDLEWARE_INVALID_SESSION": "Session invalide",
"DEBUG_FETCH_ERROR": "Erreur lors de la récupération des logs de debug",
"DEBUG_SAVE_ERROR": "Erreur lors de la sauvegarde des logs de debug",
"DEBUG_CLEAR_ERROR": "Erreur lors de la suppression des logs de debug",
"CLIENT_FETCH_ERROR": "Erreur lors de la récupération des données",
"CLIENT_NETWORK_ERROR": "Erreur réseau",
"CLIENT_REQUEST_FAILED": "La requête a échoué",
@@ -436,25 +428,5 @@
},
"common": {
"retry": "Réessayer"
},
"debug": {
"title": "DEBUG",
"entries": "entrée",
"entries_plural": "entrées",
"noRequests": "Aucune requête enregistrée",
"actions": {
"refresh": "Rafraîchir les logs",
"maximize": "Agrandir",
"minimize": "Minimiser",
"clear": "Effacer les logs"
},
"tooltips": {
"cache": "Cache: {{type}}",
"mongodb": "MongoDB: {{operation}}",
"pageRender": "Page Render: {{page}}",
"apiCall": "API Call",
"logTime": "Heure du log",
"mongoAccess": "Temps d'accès MongoDB"
}
}
}

View File

@@ -1,25 +0,0 @@
import { ReactElement } from "react";
import { DebugService } from "@/lib/services/debug.service";
type PageComponent = (props: any) => Promise<ReactElement> | ReactElement;
export function withPageTiming(pageName: string, Component: PageComponent) {
return async function PageWithTiming(props: any) {
const start = performance.now();
const result = await Promise.resolve(Component(props));
const duration = performance.now() - start;
// Ensure params is awaited before using it
const params = props.params ? await Promise.resolve(props.params) : {};
// Only log if debug is enabled and user is authenticated
try {
await DebugService.logPageRender(pageName + JSON.stringify(params), duration);
} catch {
// Silently fail if user is not authenticated or debug is disabled
// This prevents errors on public pages like /login
}
return result;
};
}

View File

@@ -6,7 +6,6 @@ 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 { DebugService } from "./debug.service";
import { RequestMonitorService } from "./request-monitor.service";
import { RequestQueueService } from "./request-queue.service";
@@ -109,8 +108,6 @@ export abstract class BaseApiService {
}
}
const startTime = performance.now();
// Timeout de 60 secondes au lieu de 10 par défaut
const timeoutMs = 60000;
const controller = new AbortController();
@@ -149,19 +146,27 @@ export abstract class BaseApiService {
family: 4,
});
}
// Retry automatique sur timeout de connexion (cold start)
if (fetchError?.cause?.code === 'UND_ERR_CONNECT_TIMEOUT') {
// eslint-disable-next-line no-console
console.log(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
return await fetch(url, {
headers,
...options,
signal: controller.signal,
// @ts-ignore - undici-specific options
connectTimeout: timeoutMs,
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
});
}
throw fetchError;
}
});
clearTimeout(timeoutId);
const endTime = performance.now();
// Logger la requête côté serveur
await DebugService.logRequest({
url: url,
startTime,
endTime,
fromCache: false, // Côté serveur, on ne peut pas détecter le cache navigateur
});
if (!response.ok) {
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
@@ -172,16 +177,6 @@ export abstract class BaseApiService {
return options.isImage ? (response as T) : response.json();
} catch (error) {
const endTime = performance.now();
// Logger l'erreur côté serveur
await DebugService.logRequest({
url: url,
startTime,
endTime,
fromCache: false,
});
throw error;
} finally {
clearTimeout(timeoutId);

View File

@@ -99,7 +99,7 @@ export class BookService extends BaseApiService {
const arrayBuffer = response.buffer.buffer.slice(
response.buffer.byteOffset,
response.buffer.byteOffset + response.buffer.byteLength
);
) as ArrayBuffer;
return new Response(arrayBuffer, {
headers: {

View File

@@ -1,5 +1,4 @@
import prisma from "@/lib/prisma";
import { DebugService } from "./debug.service";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
@@ -50,12 +49,10 @@ export class ConfigDBService {
try {
const user: User | null = await this.getCurrentUser();
return DebugService.measureMongoOperation("getConfig", async () => {
const config = await prisma.komgaConfig.findUnique({
where: { userId: user.id },
});
return config as KomgaConfig | null;
const config = await prisma.komgaConfig.findUnique({
where: { userId: user.id },
});
return config as KomgaConfig | null;
} catch (error) {
if (error instanceof AppError) {
throw error;
@@ -68,12 +65,10 @@ export class ConfigDBService {
try {
const user: User | null = await this.getCurrentUser();
return DebugService.measureMongoOperation("getTTLConfig", async () => {
const config = await prisma.tTLConfig.findUnique({
where: { userId: user.id },
});
return config as TTLConfig | null;
const config = await prisma.tTLConfig.findUnique({
where: { userId: user.id },
});
return config as TTLConfig | null;
} catch (error) {
if (error instanceof AppError) {
throw error;
@@ -86,30 +81,28 @@ export class ConfigDBService {
try {
const user: User | null = await this.getCurrentUser();
return DebugService.measureMongoOperation("saveTTLConfig", async () => {
const config = await prisma.tTLConfig.upsert({
where: { userId: user.id },
update: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
create: {
userId: user.id,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
});
return config as TTLConfig;
const config = await prisma.tTLConfig.upsert({
where: { userId: user.id },
update: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
create: {
userId: user.id,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
});
return config as TTLConfig;
} catch (error) {
if (error instanceof AppError) {
throw error;

View File

@@ -1,326 +0,0 @@
import fs from "fs/promises";
import path from "path";
import type { RequestTiming } from "@/types/debug";
import { PreferencesService } from "./preferences.service";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
export type { RequestTiming };
export class DebugService {
private static writeQueues = new Map<string, Promise<void>>();
private static async getCurrentUserId(): Promise<string> {
const user = await getCurrentUser();
if (!user) {
throw new AppError(ERROR_CODES.AUTH.UNAUTHENTICATED);
}
return user.id;
}
private static getLogFilePath(userId: string): string {
return path.join(process.cwd(), "debug-logs", `${userId}.json`);
}
private static async ensureDebugDir(): Promise<void> {
const debugDir = path.join(process.cwd(), "debug-logs");
try {
await fs.access(debugDir);
} catch {
await fs.mkdir(debugDir, { recursive: true });
}
}
private static async isDebugEnabled(): Promise<boolean> {
const user = await getCurrentUser();
if (!user) {
return false;
}
const preferences = await PreferencesService.getPreferences();
return preferences.debug === true;
}
private static async readLogs(filePath: string): Promise<RequestTiming[]> {
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content);
} catch {
// Essayer de lire un fichier de sauvegarde
try {
const backupPath = filePath + '.backup';
const backupContent = await fs.readFile(backupPath, "utf-8");
return JSON.parse(backupContent);
} catch {
return [];
}
}
}
private static async writeLogs(filePath: string, logs: RequestTiming[]): Promise<void> {
// Obtenir la queue existante ou créer une nouvelle
const existingQueue = this.writeQueues.get(filePath);
// Créer une nouvelle promesse qui attend la queue précédente
const newQueue = existingQueue
? existingQueue.then(() => this.performAppend(filePath, logs))
: this.performAppend(filePath, logs);
// Mettre à jour la queue
this.writeQueues.set(filePath, newQueue);
try {
await newQueue;
} finally {
// Nettoyer la queue si c'est la dernière opération
if (this.writeQueues.get(filePath) === newQueue) {
this.writeQueues.delete(filePath);
}
}
}
private static async performAppend(filePath: string, logs: RequestTiming[]): Promise<void> {
try {
// Lire le fichier existant
const existingLogs = await this.readLogs(filePath);
// Fusionner avec les nouveaux logs
const allLogs = [...existingLogs, ...logs];
// Garder seulement les 1000 derniers logs
const trimmedLogs = allLogs.slice(-1000);
// Créer une sauvegarde avant d'écrire
try {
await fs.copyFile(filePath, filePath + '.backup');
} catch {
// Ignorer si le fichier n'existe pas encore
}
// Écrire le fichier complet (c'est nécessaire pour maintenir l'ordre chronologique)
await fs.writeFile(filePath, JSON.stringify(trimmedLogs, null, 2), { flag: 'w' });
} catch (error) {
console.error(`Erreur lors de l'écriture des logs pour ${filePath}:`, error);
// Ne pas relancer l'erreur pour éviter de casser l'application
}
}
private static async appendLog(filePath: string, log: RequestTiming): Promise<void> {
// Obtenir la queue existante ou créer une nouvelle
const existingQueue = this.writeQueues.get(filePath);
// Créer une nouvelle promesse qui attend la queue précédente
const newQueue = existingQueue
? existingQueue.then(() => this.performSingleAppend(filePath, log))
: this.performSingleAppend(filePath, log);
// Mettre à jour la queue
this.writeQueues.set(filePath, newQueue);
try {
await newQueue;
} finally {
// Nettoyer la queue si c'est la dernière opération
if (this.writeQueues.get(filePath) === newQueue) {
this.writeQueues.delete(filePath);
}
}
}
private static async performSingleAppend(filePath: string, log: RequestTiming): Promise<void> {
try {
// Lire le fichier existant
const existingLogs = await this.readLogs(filePath);
// Vérifier les doublons avec des tolérances différentes selon le type
const isPageRender = log.pageRender !== undefined;
const timeTolerance = isPageRender ? 500 : 50; // 500ms pour les rendus, 50ms pour les requêtes
const exists = existingLogs.some(existingLog =>
existingLog.url === log.url &&
Math.abs(existingLog.duration - log.duration) < 10 && // Durée similaire (10ms de tolérance)
Math.abs(new Date(existingLog.timestamp).getTime() - new Date(log.timestamp).getTime()) < timeTolerance
);
if (!exists) {
// Ajouter le nouveau log
const allLogs = [...existingLogs, log];
// Garder seulement les 1000 derniers logs
const trimmedLogs = allLogs.slice(-1000);
// Écrire le fichier complet avec gestion d'erreur
await fs.writeFile(filePath, JSON.stringify(trimmedLogs, null, 2), { flag: 'w' });
}
} catch (error) {
console.error(`Erreur lors de l'écriture du log pour ${filePath}:`, error);
// Ne pas relancer l'erreur pour éviter de casser l'application
}
}
private static createTiming(
url: string,
startTime: number,
endTime: number,
fromCache: boolean,
additionalData?: Partial<RequestTiming>
): RequestTiming {
return {
url,
startTime,
endTime,
duration: endTime - startTime,
timestamp: new Date().toISOString(),
fromCache,
...additionalData,
};
}
static async logRequest(timing: Omit<RequestTiming, "duration" | "timestamp">) {
try {
if (!(await this.isDebugEnabled())) return;
const userId = await this.getCurrentUserId();
await this.ensureDebugDir();
const filePath = this.getLogFilePath(userId);
const newTiming = this.createTiming(
timing.url,
timing.startTime,
timing.endTime,
timing.fromCache,
{
cacheType: timing.cacheType,
mongoAccess: timing.mongoAccess,
pageRender: timing.pageRender,
}
);
// Utiliser un système d'append atomique
await this.appendLog(filePath, newTiming);
} catch (error) {
console.error("Erreur lors de l'enregistrement du log:", error);
}
}
static async getRequestLogs(): Promise<RequestTiming[]> {
try {
const userId = await this.getCurrentUserId();
const filePath = this.getLogFilePath(userId);
return await this.readLogs(filePath);
} catch (error) {
if (error instanceof AppError) throw error;
return [];
}
}
static async clearLogs(): Promise<void> {
try {
const userId = await this.getCurrentUserId();
const filePath = this.getLogFilePath(userId);
await this.clearFile(filePath);
} catch (error) {
if (error instanceof AppError) throw error;
}
}
private static async clearFile(filePath: string): Promise<void> {
try {
// Obtenir la queue existante ou créer une nouvelle
const existingQueue = this.writeQueues.get(filePath);
// Créer une nouvelle promesse qui attend la queue précédente
const newQueue = existingQueue
? existingQueue.then(() => this.performClear(filePath))
: this.performClear(filePath);
// Mettre à jour la queue
this.writeQueues.set(filePath, newQueue);
try {
await newQueue;
} finally {
// Nettoyer la queue si c'est la dernière opération
if (this.writeQueues.get(filePath) === newQueue) {
this.writeQueues.delete(filePath);
}
}
} catch (error) {
console.error(`Erreur lors du vidage du fichier ${filePath}:`, error);
}
}
private static async performClear(filePath: string): Promise<void> {
try {
// Créer une sauvegarde avant de vider
try {
await fs.copyFile(filePath, filePath + '.backup');
} catch {
// Ignorer si le fichier n'existe pas encore
}
// Écrire un tableau vide pour vider le fichier
await fs.writeFile(filePath, JSON.stringify([], null, 2), { flag: 'w' });
} catch (error) {
console.error(`Erreur lors du vidage du fichier ${filePath}:`, error);
}
}
static async logPageRender(page: string, duration: number) {
try {
if (!(await this.isDebugEnabled())) return;
const userId = await this.getCurrentUserId();
await this.ensureDebugDir();
const filePath = this.getLogFilePath(userId);
const now = performance.now();
const newTiming = this.createTiming(`Page Render: ${page}`, now - duration, now, false, {
pageRender: { page, duration },
});
// Utiliser le même système d'append atomique
await this.appendLog(filePath, newTiming);
} catch (error) {
console.error("Erreur lors de l'enregistrement du log de rendu:", error);
}
}
static async measureMongoOperation<T>(operation: string, func: () => Promise<T>): Promise<T> {
const startTime = performance.now();
try {
if (!(await this.isDebugEnabled())) {
return func();
}
const result = await func();
const endTime = performance.now();
await this.logRequest({
url: `MongoDB: ${operation}`,
startTime,
endTime,
fromCache: false,
mongoAccess: {
operation,
duration: endTime - startTime,
},
});
return result;
} catch (error) {
const endTime = performance.now();
await this.logRequest({
url: `MongoDB Error: ${operation}`,
startTime,
endTime,
fromCache: false,
mongoAccess: {
operation,
duration: endTime - startTime,
},
});
throw error;
}
}
}

View File

@@ -1,5 +1,4 @@
import prisma from "@/lib/prisma";
import { DebugService } from "./debug.service";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
@@ -30,15 +29,13 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
return DebugService.measureMongoOperation("isFavorite", async () => {
const favorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
seriesId: seriesId,
},
});
return !!favorite;
const favorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
seriesId: seriesId,
},
});
return !!favorite;
} catch (error) {
console.error("Erreur lors de la vérification du favori:", error);
return false;
@@ -52,20 +49,18 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
await DebugService.measureMongoOperation("addToFavorites", async () => {
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
});
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
this.dispatchFavoritesChanged();
@@ -81,13 +76,11 @@ export class FavoriteService {
try {
const user = await this.getCurrentUser();
await DebugService.measureMongoOperation("removeFromFavorites", async () => {
await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
this.dispatchFavoritesChanged();
@@ -102,47 +95,41 @@ export class FavoriteService {
static async getAllFavoriteIds(): Promise<string[]> {
const user = await this.getCurrentUser();
return DebugService.measureMongoOperation("getAllFavoriteIds", async () => {
const favorites = await prisma.favorite.findMany({
where: { userId: user.id },
select: { seriesId: true },
});
return favorites.map((favorite) => favorite.seriesId);
const favorites = await prisma.favorite.findMany({
where: { userId: user.id },
select: { seriesId: true },
});
return favorites.map((favorite) => favorite.seriesId);
}
static async addFavorite(seriesId: string) {
const user = await this.getCurrentUser();
return DebugService.measureMongoOperation("addFavorite", async () => {
const favorite = await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
const favorite = await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
});
return favorite;
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
return favorite;
}
static async removeFavorite(seriesId: string): Promise<boolean> {
const user = await this.getCurrentUser();
return DebugService.measureMongoOperation("removeFavorite", async () => {
const result = await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
return result.count > 0;
const result = await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
return result.count > 0;
}
}

View File

@@ -84,9 +84,13 @@ export class LibraryService extends BaseApiService {
try {
// Récupérer toutes les séries depuis le cache
const allSeries = await this.getAllLibrarySeries(libraryId);
// Filtrer les séries
let filteredSeries = allSeries;
// Filtrer les séries supprimées (fichiers manquants sur le filesystem)
filteredSeries = filteredSeries.filter((series) => !series.deleted);
if (unreadOnly) {
filteredSeries = filteredSeries.filter(
(series) => series.booksReadCount < series.booksCount
@@ -96,7 +100,8 @@ export class LibraryService extends BaseApiService {
if (search) {
const searchLower = search.toLowerCase();
filteredSeries = filteredSeries.filter((series) =>
series.metadata.title.toLowerCase().includes(searchLower)
series.metadata.title.toLowerCase().includes(searchLower) ||
series.id.toLowerCase().includes(searchLower)
);
}
@@ -108,6 +113,7 @@ export class LibraryService extends BaseApiService {
const totalPages = Math.ceil(totalElements / size);
const startIndex = page * size;
const endIndex = Math.min(startIndex + size, totalElements);
const paginatedSeries = filteredSeries.slice(startIndex, endIndex);
// Construire la réponse

View File

@@ -31,7 +31,6 @@ export class PreferencesService {
showThumbnails: preferences.showThumbnails,
cacheMode: preferences.cacheMode as "memory" | "file",
showOnlyUnread: preferences.showOnlyUnread,
debug: preferences.debug,
displayMode: preferences.displayMode as UserPreferences["displayMode"],
background: preferences.background as unknown as BackgroundPreferences,
};
@@ -51,7 +50,6 @@ export class PreferencesService {
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.debug !== undefined) updateData.debug = preferences.debug;
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
if (preferences.background !== undefined) updateData.background = preferences.background;
@@ -63,7 +61,6 @@ export class PreferencesService {
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
debug: preferences.debug ?? defaultPreferences.debug,
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
background: (preferences.background ?? defaultPreferences.background) as unknown as Prisma.InputJsonValue,
},
@@ -73,7 +70,6 @@ export class PreferencesService {
showThumbnails: updatedPreferences.showThumbnails,
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
showOnlyUnread: updatedPreferences.showOnlyUnread,
debug: updatedPreferences.debug,
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
background: updatedPreferences.background as unknown as BackgroundPreferences,
};

View File

@@ -14,8 +14,10 @@ class RequestQueue {
private activeCount = 0;
private maxConcurrent: number;
constructor(maxConcurrent: number = 5) {
this.maxConcurrent = maxConcurrent;
constructor(maxConcurrent?: number) {
// Lire depuis env ou utiliser la valeur par défaut
const envValue = process.env.KOMGA_MAX_CONCURRENT_REQUESTS;
this.maxConcurrent = maxConcurrent ?? (envValue ? parseInt(envValue, 10) : 5);
}
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
@@ -68,6 +70,10 @@ class RequestQueue {
}
}
// Singleton instance - Limite à 2 requêtes simultanées vers Komga (réduit pour CPU)
export const RequestQueueService = new RequestQueue(2);
// Singleton instance - Par défaut limite à 2 requêtes simultanées (configurable via KOMGA_MAX_CONCURRENT_REQUESTS)
export const RequestQueueService = new RequestQueue(
process.env.KOMGA_MAX_CONCURRENT_REQUESTS
? parseInt(process.env.KOMGA_MAX_CONCURRENT_REQUESTS, 10)
: 2
);

View File

@@ -92,6 +92,9 @@ export class SeriesService extends BaseApiService {
// Filtrer les livres
let filteredBooks = allBooks;
// Filtrer les livres supprimés (fichiers manquants sur le filesystem)
filteredBooks = filteredBooks.filter((book: KomgaBook) => !book.deleted);
if (unreadOnly) {
filteredBooks = filteredBooks.filter(
(book: KomgaBook) => !book.readProgress || !book.readProgress.completed

View File

@@ -1,7 +1,6 @@
import fs from "fs";
import path from "path";
import { PreferencesService } from "./preferences.service";
import { DebugService } from "./debug.service";
import { getCurrentUser } from "../auth-utils";
export type CacheMode = "file" | "memory";
@@ -440,14 +439,13 @@ class ServerCacheService {
const { data, isStale } = cachedResult;
const endTime = performance.now();
// Log la requête avec l'indication du cache
await DebugService.logRequest({
url: `[CACHE${isStale ? '-STALE' : ''}] ${key}`,
startTime,
endTime,
fromCache: true,
cacheType: type,
});
// Debug logging
if (process.env.CACHE_DEBUG === 'true') {
const icon = isStale ? '⚠️' : '';
const status = isStale ? 'STALE' : 'HIT';
// eslint-disable-next-line no-console
console.log(`${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) {
@@ -459,9 +457,21 @@ class ServerCacheService {
}
// Pas de cache du tout, fetch normalement
if (process.env.CACHE_DEBUG === 'true') {
// eslint-disable-next-line no-console
console.log(`❌ [CACHE MISS] ${key} | ${type}`);
}
try {
const data = await fetcher();
this.set(cacheKey, data, type);
const endTime = performance.now();
if (process.env.CACHE_DEBUG === 'true') {
// eslint-disable-next-line no-console
console.log(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
}
return data;
} catch (error) {
throw error;
@@ -482,16 +492,13 @@ class ServerCacheService {
const data = await fetcher();
this.set(cacheKey, data, type);
const endTime = performance.now();
await DebugService.logRequest({
url: `[REVALIDATE] ${debugKey}`,
startTime,
endTime,
fromCache: false,
cacheType: type,
});
if (process.env.CACHE_DEBUG === 'true') {
const endTime = performance.now();
// eslint-disable-next-line no-console
console.log(`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
}
} catch (error) {
console.error(`Background revalidation failed for ${debugKey}:`, error);
console.error(`🔴 [CACHE REVALIDATE ERROR] ${debugKey}:`, error);
// Ne pas relancer l'erreur car c'est en background
}
}

View File

@@ -1,57 +0,0 @@
// Wrapper pour détecter le cache du navigateur
export async function fetchWithCacheDetection(url: string, options: RequestInit = {}) {
const startTime = performance.now();
try {
const response = await fetch(url, options);
const endTime = performance.now();
// Détecter si la réponse vient du cache du navigateur
const fromBrowserCache = response.headers.get('x-cache') === 'HIT' ||
response.headers.get('cf-cache-status') === 'HIT' ||
(endTime - startTime) < 5; // Si très rapide, probablement du cache
// Logger la requête seulement si ce n'est pas une requête de debug
// Note: La vérification du mode debug se fait côté serveur dans DebugService
if (!url.includes('/api/debug')) {
try {
await fetch("/api/debug", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url,
startTime,
endTime,
fromCache: fromBrowserCache,
}),
});
} catch {
// Ignorer les erreurs de logging
}
}
return response;
} catch (error) {
const endTime = performance.now();
// Logger aussi les erreurs seulement si ce n'est pas une requête de debug
if (!url.includes('/api/debug')) {
try {
await fetch("/api/debug", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url,
startTime,
endTime,
fromCache: false,
}),
});
} catch {
// Ignorer les erreurs de logging
}
}
throw error;
}
}

View File

@@ -1,20 +0,0 @@
import type { CacheType } from "./cache";
export interface RequestTiming {
url: string;
startTime: number;
endTime: number;
duration: number;
timestamp: string;
fromCache: boolean;
cacheType?: CacheType;
mongoAccess?: {
operation: string;
duration: number;
};
pageRender?: {
page: string;
duration: number;
};
}

View File

@@ -121,6 +121,7 @@ export interface KomgaBook {
media: BookMedia;
metadata: BookMetadata;
readProgress: ReadProgress | null;
deleted: boolean;
}
export interface BookMedia {

View File

@@ -10,7 +10,6 @@ export interface UserPreferences {
showThumbnails: boolean;
cacheMode: "memory" | "file";
showOnlyUnread: boolean;
debug: boolean;
displayMode: {
compact: boolean;
itemsPerPage: number;
@@ -22,7 +21,6 @@ export const defaultPreferences: UserPreferences = {
showThumbnails: true,
cacheMode: "memory",
showOnlyUnread: false,
debug: false,
displayMode: {
compact: false,
itemsPerPage: 20,

23
src/utils/image-errors.ts Normal file
View File

@@ -0,0 +1,23 @@
import { AppError } from "./errors";
import { ERROR_CODES } from "@/constants/errorCodes";
/**
* Helper pour trouver le status HTTP dans la chaîne d'erreurs imbriquées
* Parcourt récursivement les originalError pour trouver une erreur KOMGA.HTTP_ERROR
*/
export function findHttpStatus(error: unknown): number | null {
if (!(error instanceof AppError)) return null;
// Si c'est une erreur HTTP, récupérer le status
if (error.code === ERROR_CODES.KOMGA.HTTP_ERROR) {
return (error.params as any)?.status || null;
}
// Sinon, chercher récursivement dans originalError
if (error.originalError) {
return findHttpStatus(error.originalError);
}
return null;
}