From b7704207ec9d5d6673704366d03f130682fc1053 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sat, 18 Oct 2025 09:08:41 +0200 Subject: [PATCH] feat: add caching debug logs and configurable max concurrent requests for Komga API to enhance performance monitoring --- ENV.md | 6 + docker-compose.dev.yml | 2 + docker-compose.yml | 1 + docs/cache-debug.md | 317 +++++++++++ docs/caching.md | 502 ++++++++++++++++++ prisma/schema.prisma | 1 - public/sw.js | 131 ++--- src/app/api/debug/route.ts | 105 ---- src/app/api/komga/home/route.ts | 6 +- .../[bookId]/pages/[pageNumber]/route.ts | 21 + .../pages/[pageNumber]/thumbnail/route.ts | 22 + .../images/books/[bookId]/thumbnail/route.ts | 21 + .../series/[seriesId]/first-page/route.ts | 21 + .../series/[seriesId]/thumbnail/route.ts | 21 + src/app/books/[bookId]/page.tsx | 5 +- src/app/downloads/page.tsx | 5 +- src/app/login/page.tsx | 4 +- src/components/debug/DebugInfo.tsx | 281 ---------- src/components/debug/DebugWrapper.tsx | 13 - src/components/layout/ClientLayout.tsx | 37 +- src/components/settings/DisplaySettings.tsx | 28 - src/constants/errorCodes.ts | 5 - src/constants/errorMessages.ts | 5 - src/contexts/DebugContext.tsx | 106 ---- src/i18n/messages/en/common.json | 28 - src/i18n/messages/fr/common.json | 28 - src/lib/hoc/withPageTiming.tsx | 25 - src/lib/services/base-api.service.ts | 39 +- src/lib/services/book.service.ts | 2 +- src/lib/services/config-db.service.ts | 61 +-- src/lib/services/debug.service.ts | 326 ------------ src/lib/services/favorite.service.ts | 93 ++-- src/lib/services/library.service.ts | 8 +- src/lib/services/preferences.service.ts | 4 - src/lib/services/request-queue.service.ts | 14 +- src/lib/services/series.service.ts | 3 + src/lib/services/server-cache.service.ts | 43 +- src/lib/utils/fetch-with-cache-detection.ts | 57 -- src/types/debug.ts | 20 - src/types/komga.ts | 1 + src/types/preferences.ts | 2 - src/utils/image-errors.ts | 23 + 42 files changed, 1141 insertions(+), 1302 deletions(-) create mode 100644 docs/cache-debug.md create mode 100644 docs/caching.md delete mode 100644 src/app/api/debug/route.ts delete mode 100644 src/components/debug/DebugInfo.tsx delete mode 100644 src/components/debug/DebugWrapper.tsx delete mode 100644 src/contexts/DebugContext.tsx delete mode 100644 src/lib/hoc/withPageTiming.tsx delete mode 100644 src/lib/services/debug.service.ts delete mode 100644 src/lib/utils/fetch-with-cache-detection.ts delete mode 100644 src/types/debug.ts create mode 100644 src/utils/image-errors.ts diff --git a/ENV.md b/ENV.md index b0b9dda..d2709e3 100644 --- a/ENV.md +++ b/ENV.md @@ -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 ``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5e8db1a..a763c93 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index b7e0809..506d225 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/cache-debug.md b/docs/cache-debug.md new file mode 100644 index 0000000..1a54195 --- /dev/null +++ b/docs/cache-debug.md @@ -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. + diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..92a14de --- /dev/null +++ b/docs/caching.md @@ -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( + key: string, + fetcher: () => Promise, + type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT" +): Promise { + 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( + "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. + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c959b75..bdb6c0c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/public/sw.js b/public/sw.js index 17c8f78..55e8f47 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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) => { diff --git a/src/app/api/debug/route.ts b/src/app/api/debug/route.ts deleted file mode 100644 index feba702..0000000 --- a/src/app/api/debug/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/komga/home/route.ts b/src/app/api/komga/home/route.ts index fc8b7f6..5822904 100644 --- a/src/app/api/komga/home/route.ts +++ b/src/app/api/komga/home/route.ts @@ -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) { diff --git a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts index e7eda97..ff319cd 100644 --- a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts +++ b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/route.ts @@ -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( { diff --git a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts index a65a9bf..05189e1 100644 --- a/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts +++ b/src/app/api/komga/images/books/[bookId]/pages/[pageNumber]/thumbnail/route.ts @@ -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( { diff --git a/src/app/api/komga/images/books/[bookId]/thumbnail/route.ts b/src/app/api/komga/images/books/[bookId]/thumbnail/route.ts index daae108..0e49368 100644 --- a/src/app/api/komga/images/books/[bookId]/thumbnail/route.ts +++ b/src/app/api/komga/images/books/[bookId]/thumbnail/route.ts @@ -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( { diff --git a/src/app/api/komga/images/series/[seriesId]/first-page/route.ts b/src/app/api/komga/images/series/[seriesId]/first-page/route.ts index a66235e..7ac5deb 100644 --- a/src/app/api/komga/images/series/[seriesId]/first-page/route.ts +++ b/src/app/api/komga/images/series/[seriesId]/first-page/route.ts @@ -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( { diff --git a/src/app/api/komga/images/series/[seriesId]/thumbnail/route.ts b/src/app/api/komga/images/series/[seriesId]/thumbnail/route.ts index 0aec5c9..b0196fd 100644 --- a/src/app/api/komga/images/series/[seriesId]/thumbnail/route.ts +++ b/src/app/api/komga/images/series/[seriesId]/thumbnail/route.ts @@ -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( { diff --git a/src/app/books/[bookId]/page.tsx b/src/app/books/[bookId]/page.tsx index 3a25b6a..46d9202 100644 --- a/src/app/books/[bookId]/page.tsx +++ b/src/app/books/[bookId]/page.tsx @@ -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 } }) { ); } - -export default withPageTiming("BookPage", BookPage); diff --git a/src/app/downloads/page.tsx b/src/app/downloads/page.tsx index 4c0c133..c1e98cf 100644 --- a/src/app/downloads/page.tsx +++ b/src/app/downloads/page.tsx @@ -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 ( <> ); } - -export default withPageTiming("DownloadsPage", DownloadsPage); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5bea05f..cce6131 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 ; } -export default withPageTiming("LoginPage", LoginPage); diff --git a/src/components/debug/DebugInfo.tsx b/src/components/debug/DebugInfo.tsx deleted file mode 100644 index 1d26621..0000000 --- a/src/components/debug/DebugInfo.tsx +++ /dev/null @@ -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("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 ( -
-
-
-

{t("debug.title")}

- {!isMinimized && ( - - {sortedLogs.length} {t("debug.entries", { count: sortedLogs.length })} - - )} -
-
- {!isMinimized && ( - - )} - - - -
-
- - {!isMinimized && showFilters && ( -
-
- {[ - { 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 }) => ( - - ))} -
-
- )} - - {!isMinimized && ( -
- {sortedLogs.length === 0 ? ( -

{t("debug.noRequests")}

- ) : ( - sortedLogs.map((log, index) => { - const isCurrentPage = isCurrentPageRequest(log); - return ( -
-
-
- {log.fromCache && ( -
- -
- )} - {log.mongoAccess && ( -
- -
- )} - {log.pageRender && ( -
- -
- )} - {!log.fromCache && !log.mongoAccess && !log.pageRender && ( -
- -
- )} - - {log.url} - -
-
-
- - {formatTime(log.timestamp)} -
- - {formatDuration(log.duration)}ms - - {log.mongoAccess && ( - - +{formatDuration(log.mongoAccess.duration)}ms - - )} -
-
-
-
- {log.mongoAccess && ( -
- )} -
-
- ); - }) - )} -
- )} -
- ); -} diff --git a/src/components/debug/DebugWrapper.tsx b/src/components/debug/DebugWrapper.tsx deleted file mode 100644 index 09df146..0000000 --- a/src/components/debug/DebugWrapper.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 377cff4..1ad779f 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -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 ( - -
- {!isPublicRoute &&
} - {!isPublicRoute && ( - - )} -
{children}
- - - - -
-
+
+ {!isPublicRoute &&
} + {!isPublicRoute && ( + + )} +
{children}
+ + + +
); } diff --git a/src/components/settings/DisplaySettings.tsx b/src/components/settings/DisplaySettings.tsx index 0322190..bd5c438 100644 --- a/src/components/settings/DisplaySettings.tsx +++ b/src/components/settings/DisplaySettings.tsx @@ -75,34 +75,6 @@ export function DisplaySettings() { }} />
-
-
- -

- {t("settings.display.debugMode.description")} -

-
- { - 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"), - }); - } - }} - /> -
); diff --git a/src/constants/errorCodes.ts b/src/constants/errorCodes.ts index f4d5d5d..0c8d5ce 100644 --- a/src/constants/errorCodes.ts +++ b/src/constants/errorCodes.ts @@ -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", diff --git a/src/constants/errorMessages.ts b/src/constants/errorMessages.ts index b87f1d6..3e0b03a 100644 --- a/src/constants/errorMessages.ts +++ b/src/constants/errorMessages.ts @@ -85,11 +85,6 @@ export const ERROR_MESSAGES: Record = { [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", diff --git a/src/contexts/DebugContext.tsx b/src/contexts/DebugContext.tsx deleted file mode 100644 index 003095d..0000000 --- a/src/contexts/DebugContext.tsx +++ /dev/null @@ -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(undefined); - -interface DebugProviderProps { - children: ReactNode; -} - -export function DebugProvider({ children }: DebugProviderProps) { - const [logs, setLogs] = useState([]); - 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 ( - - {children} - - ); -} - -export function useDebug() { - const context = useContext(DebugContext); - if (context === undefined) { - throw new Error("useDebug must be used within a DebugProvider"); - } - return context; -} diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 3f95242..c5e4280 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -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" - } } } diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index b25508f..d573010 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -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" - } } } diff --git a/src/lib/hoc/withPageTiming.tsx b/src/lib/hoc/withPageTiming.tsx deleted file mode 100644 index 7f5cf07..0000000 --- a/src/lib/hoc/withPageTiming.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ReactElement } from "react"; -import { DebugService } from "@/lib/services/debug.service"; - -type PageComponent = (props: any) => Promise | 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; - }; -} diff --git a/src/lib/services/base-api.service.ts b/src/lib/services/base-api.service.ts index 91eb7b3..6a6c8e1 100644 --- a/src/lib/services/base-api.service.ts +++ b/src/lib/services/base-api.service.ts @@ -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); diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 8aefc9d..0afee56 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -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: { diff --git a/src/lib/services/config-db.service.ts b/src/lib/services/config-db.service.ts index 087093c..e8715c2 100644 --- a/src/lib/services/config-db.service.ts +++ b/src/lib/services/config-db.service.ts @@ -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; diff --git a/src/lib/services/debug.service.ts b/src/lib/services/debug.service.ts deleted file mode 100644 index f2e9888..0000000 --- a/src/lib/services/debug.service.ts +++ /dev/null @@ -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>(); - - private static async getCurrentUserId(): Promise { - 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 { - 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 { - const user = await getCurrentUser(); - if (!user) { - return false; - } - const preferences = await PreferencesService.getPreferences(); - return preferences.debug === true; - } - - private static async readLogs(filePath: string): Promise { - 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 { - // 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 { - 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 { - // 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 { - 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 { - return { - url, - startTime, - endTime, - duration: endTime - startTime, - timestamp: new Date().toISOString(), - fromCache, - ...additionalData, - }; - } - - static async logRequest(timing: Omit) { - 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 { - 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 { - 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 { - 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 { - 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(operation: string, func: () => Promise): Promise { - 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; - } - } -} diff --git a/src/lib/services/favorite.service.ts b/src/lib/services/favorite.service.ts index 21d35f5..9ce9dd6 100644 --- a/src/lib/services/favorite.service.ts +++ b/src/lib/services/favorite.service.ts @@ -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 { 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 { 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; } } diff --git a/src/lib/services/library.service.ts b/src/lib/services/library.service.ts index ee80868..55cfd7f 100644 --- a/src/lib/services/library.service.ts +++ b/src/lib/services/library.service.ts @@ -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 diff --git a/src/lib/services/preferences.service.ts b/src/lib/services/preferences.service.ts index 29e5671..f210bfc 100644 --- a/src/lib/services/preferences.service.ts +++ b/src/lib/services/preferences.service.ts @@ -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, }; diff --git a/src/lib/services/request-queue.service.ts b/src/lib/services/request-queue.service.ts index acb975d..e81583b 100644 --- a/src/lib/services/request-queue.service.ts +++ b/src/lib/services/request-queue.service.ts @@ -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(execute: () => Promise): Promise { @@ -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 +); diff --git a/src/lib/services/series.service.ts b/src/lib/services/series.service.ts index 68f2b18..c9a1c7c 100644 --- a/src/lib/services/series.service.ts +++ b/src/lib/services/series.service.ts @@ -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 diff --git a/src/lib/services/server-cache.service.ts b/src/lib/services/server-cache.service.ts index fae6fbb..f6105c1 100644 --- a/src/lib/services/server-cache.service.ts +++ b/src/lib/services/server-cache.service.ts @@ -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 } } diff --git a/src/lib/utils/fetch-with-cache-detection.ts b/src/lib/utils/fetch-with-cache-detection.ts deleted file mode 100644 index 3929c02..0000000 --- a/src/lib/utils/fetch-with-cache-detection.ts +++ /dev/null @@ -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; - } -} diff --git a/src/types/debug.ts b/src/types/debug.ts deleted file mode 100644 index 538e2ce..0000000 --- a/src/types/debug.ts +++ /dev/null @@ -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; - }; -} - diff --git a/src/types/komga.ts b/src/types/komga.ts index f64fd26..98e05c3 100644 --- a/src/types/komga.ts +++ b/src/types/komga.ts @@ -121,6 +121,7 @@ export interface KomgaBook { media: BookMedia; metadata: BookMetadata; readProgress: ReadProgress | null; + deleted: boolean; } export interface BookMedia { diff --git a/src/types/preferences.ts b/src/types/preferences.ts index 265a4eb..72ea553 100644 --- a/src/types/preferences.ts +++ b/src/types/preferences.ts @@ -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, diff --git a/src/utils/image-errors.ts b/src/utils/image-errors.ts new file mode 100644 index 0000000..38b6e5b --- /dev/null +++ b/src/utils/image-errors.ts @@ -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; +} +