Compare commits

...

24 Commits

Author SHA1 Message Date
Julien Froidefond
034aa69f8d feat: update service worker to version 2.5 and enhance caching strategies for network requests, including cache bypass for refresh actions in LibraryClientWrapper, SeriesClientWrapper, and HomeClientWrapper components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m3s
2026-01-04 11:44:50 +01:00
Julien Froidefond
060dfb3099 fix: adjust thumbnail size and optimize image loading in BookDownloadCard component
Some checks are pending
Deploy with Docker Compose / deploy (push) Has started running
2026-01-04 11:41:13 +01:00
Julien Froidefond
ad11bce308 revert: restore page-by-page download method (old method works better) 2026-01-04 11:39:55 +01:00
Julien Froidefond
1ffe99285d feat: add fflate library for file decompression and implement file download functionality in BookOfflineButton component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m18s
2026-01-04 11:32:48 +01:00
Julien Froidefond
0d33462349 feat: update service worker to version 2.4, enhance caching strategies for pages, and add service worker reinstallation functionality in CacheSettings component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2026-01-04 07:39:07 +01:00
Julien Froidefond
b8a0b85c54 refactor: rename Image import to ImageIcon for clarity in CacheSettings component and remove unused React import in collapsible component 2026-01-04 07:18:22 +01:00
Julien Froidefond
2c8c0b5eb0 feat: enhance service worker functionality with improved caching strategies, client communication, and service worker registration options
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m42s
2026-01-04 06:48:17 +01:00
Julien Froidefond
b497746cfa feat: enhance home and library pages by integrating new data fetching methods, improving error handling, and refactoring components for better structure
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m17s
2026-01-04 06:19:45 +01:00
Julien Froidefond
489e570348 feat: enrich library data by fetching book counts from the API and handling errors gracefully 2026-01-04 05:57:22 +01:00
Julien Froidefond
117ad2d0ce fix: enhance error handling in read progress update by validating request body and returning appropriate error responses
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10m22s
2026-01-03 22:06:28 +01:00
Julien Froidefond
0d7d27ef82 refactor: streamline image handling by implementing direct streaming in BookService and ImageService, and update .gitignore to include temp directory 2026-01-03 22:03:35 +01:00
Julien Froidefond
e903b55a46 refactor: implement abort controller for fetch requests in multiple components to prevent memory leaks and improve error handling 2026-01-03 21:51:07 +01:00
Julien Froidefond
512e9a480f refactor: remove caching-related API endpoints and configurations, update preferences structure, and clean up unused services
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m22s
2026-01-03 18:55:12 +01:00
Julien Froidefond
acd26ea427 chore: optimize Dockerfile by removing PNPM_HOME environment variable and using cache mount for pnpm store during dependency installation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m36s
2025-12-13 12:15:40 +01:00
Julien Froidefond
4fac95a1d8 fix: update next
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m11s
2025-12-13 07:24:45 +01:00
Julien Froidefond
8f0e343e8e chore: fix DATABASE_URL in docker-compose.yml to use the correct absolute path for SQLite database
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2025-12-11 11:07:33 +01:00
Julien Froidefond
853518e1fd chore: update DATABASE_URL in docker-compose.yml to use the correct path for SQLite database
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
2025-12-11 11:06:38 +01:00
Julien Froidefond
b8e7c5a005 chore: update docker-compose.yml to change default path for Prisma data volume to a relative path
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2025-12-11 11:05:49 +01:00
Julien Froidefond
03c74d96c4 chore: update docker-compose.yml to correct the default path for Prisma data volume
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2025-12-11 11:02:37 +01:00
Julien Froidefond
5da6f9f991 chore: update docker-compose.yml to specify absolute path for Prisma data volume
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2025-12-11 11:01:09 +01:00
Julien Froidefond
de505cc8f6 chore: add PRISMA_DATA_PATH environment variable to deployment workflow for improved Prisma data management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12s
2025-12-11 10:59:03 +01:00
Julien Froidefond
c49c3d7fc4 chore: update docker-compose configuration to rename app service, adjust volume path for Prisma data, and set DATABASE_URL for SQLite
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s
2025-12-11 10:52:06 +01:00
Julien Froidefond
f9a4e596d4 chore: update deployment workflow to enable Docker BuildKit and set environment variables for production
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2025-12-11 08:54:54 +01:00
Julien Froidefond
0c1b8287d1 refactor: enhance ServerCacheService to support dynamic cache key generation for improved cache management
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-11 08:33:32 +01:00
81 changed files with 4652 additions and 8786 deletions

View File

@@ -0,0 +1,26 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main # adapte la branche que tu veux déployer
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
NODE_ENV: production
run: |
docker compose up -d --build

4
.gitignore vendored
View File

@@ -53,4 +53,6 @@ mongo-keyfile
prisma/data/ prisma/data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
temp/

View File

@@ -4,9 +4,6 @@ FROM node:20-alpine AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Configure pnpm store location
ENV PNPM_HOME="/app/.pnpm-store"
# Install dependencies for node-gyp # Install dependencies for node-gyp
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
@@ -23,8 +20,8 @@ COPY prisma ./prisma
COPY tsconfig.json .eslintrc.json ./ COPY tsconfig.json .eslintrc.json ./
COPY tailwind.config.ts postcss.config.js ./ COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with pnpm # Install dependencies with pnpm using cache mount for store
RUN pnpm config set store-dir /app/.pnpm-store && \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
# Generate Prisma Client # Generate Prisma Client

4
ENV.md
View File

@@ -6,6 +6,10 @@
# Database Configuration (SQLite) # Database Configuration (SQLite)
DATABASE_URL=file:./data/stripstream.db DATABASE_URL=file:./data/stripstream.db
# Prisma Data Path (optional - default: ./prisma/data)
# Chemin sur l'hôte où seront stockées les données Prisma (base de données SQLite)
# PRISMA_DATA_PATH=./prisma/data
# NextAuth Configuration # NextAuth Configuration
NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32
# Si derrière un reverse proxy HTTPS, utiliser l'URL HTTPS publique : # Si derrière un reverse proxy HTTPS, utiliser l'URL HTTPS publique :

View File

@@ -1,7 +1,7 @@
version: "3.8" version: "3.8"
services: services:
app: stripstream-app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -14,15 +14,16 @@ services:
- "3020:3000" - "3020:3000"
volumes: volumes:
- stripstream_cache:/app/.cache - stripstream_cache:/app/.cache
- ./prisma/data:/app/data - ${PRISMA_DATA_PATH:-./prisma/data}:/app/prisma/data
environment: environment:
- NODE_ENV=${NODE_ENV} - NODE_ENV=${NODE_ENV}
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=file:/app/prisma/data/stripstream.db
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_URL=${NEXTAUTH_URL}
- AUTH_TRUST_HOST=true - AUTH_TRUST_HOST=true
- KOMGA_MAX_CONCURRENT_REQUESTS=5 - KOMGA_MAX_CONCURRENT_REQUESTS=5
- ADMIN_DEFAULT_PASSWORD=${ADMIN_DEFAULT_PASSWORD} - ADMIN_DEFAULT_PASSWORD=${ADMIN_DEFAULT_PASSWORD}
- PRISMA_DATA_PATH=${PRISMA_DATA_PATH:-./prisma/data}
networks: networks:
- stripstream-network - stripstream-network
deploy: deploy:

View File

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

View File

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

View File

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

View File

@@ -18,10 +18,12 @@
"@prisma/client": "^6.17.1", "@prisma/client": "^6.17.1",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.3", "@radix-ui/react-slot": "1.2.3",
@@ -35,7 +37,7 @@
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"mongodb": "^6.20.0", "mongodb": "^6.20.0",
"next": "^15.4.7", "next": "^15.5.9",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "0.2.1", "next-themes": "0.2.1",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",

6033
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,17 +1,20 @@
// StripStream Service Worker - Version 1 // StripStream Service Worker - Version 2
// Architecture: Cache-as-you-go for images and static resources only // Architecture: SWR (Stale-While-Revalidate) for all resources
// API data caching is handled by ServerCacheService on the server
const VERSION = "v1"; const VERSION = "v2.5";
const STATIC_CACHE = `stripstream-static-${VERSION}`; const STATIC_CACHE = `stripstream-static-${VERSION}`;
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
const API_CACHE = `stripstream-api-${VERSION}`;
const IMAGES_CACHE = `stripstream-images-${VERSION}`; const IMAGES_CACHE = `stripstream-images-${VERSION}`;
const DATA_CACHE = `stripstream-data-${VERSION}`;
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
const OFFLINE_PAGE = "/offline.html"; const OFFLINE_PAGE = "/offline.html";
const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"]; const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"];
// Cache size limits
const IMAGES_CACHE_MAX_SIZE = 100 * 1024 * 1024; // 100MB
const IMAGES_CACHE_MAX_ENTRIES = 500;
// ============================================================================ // ============================================================================
// Utility Functions - Request Detection // Utility Functions - Request Detection
// ============================================================================ // ============================================================================
@@ -20,21 +23,76 @@ function isNextStaticResource(url) {
return url.includes("/_next/static/"); return url.includes("/_next/static/");
} }
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
function isApiDataRequest(url) {
return url.includes("/api/komga/") && !isImageRequest(url);
}
function isNextRSCRequest(request) { function isNextRSCRequest(request) {
const url = new URL(request.url); const url = new URL(request.url);
return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1";
} }
// Removed: shouldCacheApiData - API data is no longer cached by SW function isApiRequest(url) {
// API data caching is handled by ServerCacheService on the server return url.includes("/api/komga/") && !url.includes("/api/komga/images/");
}
function isImageRequest(url) {
return url.includes("/api/komga/images/");
}
function isBookPageRequest(url) {
// Book pages: /api/komga/images/books/{id}/pages/{num} or /api/komga/books/{id}/pages/{num}
// These are handled by manual download (DownloadManager) - don't cache via SWR
return (
(url.includes("/api/komga/images/books/") || url.includes("/api/komga/books/")) &&
url.includes("/pages/")
);
}
function isBooksManualCache(url) {
// Check if this is a request that should be handled by the books manual cache
return url.includes("/api/komga/images/books/") && url.includes("/pages");
}
// ============================================================================
// Client Communication
// ============================================================================
async function notifyClients(message) {
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((client) => {
client.postMessage(message);
});
}
// ============================================================================
// Cache Management
// ============================================================================
async function getCacheSize(cacheName) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
let totalSize = 0;
for (const request of keys) {
const response = await cache.match(request);
if (response) {
const blob = await response.clone().blob();
totalSize += blob.size;
}
}
return totalSize;
}
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// Remove oldest entries (FIFO)
const toDelete = keys.slice(0, keys.length - maxEntries);
await Promise.all(toDelete.map((key) => cache.delete(key)));
// eslint-disable-next-line no-console
console.log(`[SW] Trimmed ${toDelete.length} entries from ${cacheName}`);
}
}
// ============================================================================ // ============================================================================
// Cache Strategies // Cache Strategies
@@ -42,7 +100,7 @@ function isNextRSCRequest(request) {
/** /**
* Cache-First: Serve from cache, fallback to network * Cache-First: Serve from cache, fallback to network
* Used for: Images, Next.js static resources * Used for: Next.js static resources (immutable)
*/ */
async function cacheFirstStrategy(request, cacheName, options = {}) { async function cacheFirstStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
@@ -70,21 +128,70 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
/** /**
* Stale-While-Revalidate: Serve from cache immediately, update in background * Stale-While-Revalidate: Serve from cache immediately, update in background
* Used for: API data, RSC payloads * Used for: API calls, images
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
*/ */
async function staleWhileRevalidateStrategy(request, cacheName) { async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
const cache = await caches.open(cacheName); const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
// 1. Check Cache-Control header
const cacheControl = request.headers.get("Cache-Control");
const noCacheHeader =
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
// 2. Check request.cache mode (used by Next.js router.refresh())
const noCacheMode =
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
const noCache = noCacheHeader || noCacheMode;
// If no-cache, skip cached response and go network-first
const cached = noCache ? null : await cache.match(request);
// Start network request (don't await) // Start network request (don't await)
const fetchPromise = fetch(request) const fetchPromise = fetch(request)
.then((response) => { .then(async (response) => {
if (response.ok) { if (response.ok) {
cache.put(request, response.clone()); // Clone response for cache
const responseToCache = response.clone();
// Check if content changed (for notification)
if (cached && options.notifyOnChange) {
try {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// For JSON APIs, compare content
if (options.isJson) {
const oldText = await cachedResponse.text();
const newText = await response.clone().text();
if (oldText !== newText) {
notifyClients({
type: "CACHE_UPDATED",
url: request.url,
timestamp: Date.now(),
});
}
}
}
} catch {
// Ignore comparison errors
}
}
// Update cache
await cache.put(request, responseToCache);
// Trim cache if needed (for images)
if (options.maxEntries) {
trimCache(cacheName, options.maxEntries);
}
} }
return response; return response;
}) })
.catch(() => null); .catch((error) => {
// eslint-disable-next-line no-console
console.log("[SW] Network request failed:", request.url, error.message);
return null;
});
// Return cached version immediately if available // Return cached version immediately if available
if (cached) { if (cached) {
@@ -101,40 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName) {
} }
/** /**
* Navigation Strategy: Network-First with SPA fallback * Navigation SWR: Serve from cache immediately, update in background
* Falls back to offline page if nothing cached
* Used for: Page navigations * Used for: Page navigations
*/ */
async function navigationStrategy(request) { async function navigationSWRStrategy(request, cacheName) {
const cache = await caches.open(STATIC_CACHE); const cache = await caches.open(cacheName);
const cached = await cache.match(request);
try { // Start network request in background
// Try network first const fetchPromise = fetch(request)
const response = await fetch(request); .then(async (response) => {
if (response.ok) { if (response.ok) {
cache.put(request, response.clone()); await cache.put(request, response.clone());
} }
return response; return response;
} catch (error) { })
// Network failed - try cache .catch(() => null);
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Try to serve root page for SPA client-side routing // Return cached version immediately if available
const rootPage = await cache.match("/"); if (cached) {
if (rootPage) { return cached;
return rootPage;
}
// Last resort: offline page
const offlinePage = await cache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw error;
} }
// No cache - wait for network
const response = await fetchPromise;
if (response) {
return response;
}
// Network failed and no cache - try fallbacks
// Try to serve root page for SPA client-side routing
const rootPage = await cache.match("/");
if (rootPage) {
return rootPage;
}
// Last resort: offline page (in static cache)
const staticCache = await caches.open(STATIC_CACHE);
const offlinePage = await staticCache.match(OFFLINE_PAGE);
if (offlinePage) {
return offlinePage;
}
throw new Error("Offline and no cached page available");
} }
// ============================================================================ // ============================================================================
@@ -169,9 +286,10 @@ self.addEventListener("activate", (event) => {
(async () => { (async () => {
// Clean up old caches, but preserve BOOKS_CACHE // Clean up old caches, but preserve BOOKS_CACHE
const cacheNames = await caches.keys(); const cacheNames = await caches.keys();
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
const cachesToDelete = cacheNames.filter( const cachesToDelete = cacheNames.filter(
(name) => (name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
name.startsWith("stripstream-") && name !== BOOKS_CACHE && !name.endsWith(`-${VERSION}`)
); );
await Promise.all(cachesToDelete.map((name) => caches.delete(name))); await Promise.all(cachesToDelete.map((name) => caches.delete(name)));
@@ -184,10 +302,179 @@ self.addEventListener("activate", (event) => {
await self.clients.claim(); await self.clients.claim();
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("[SW] Activated and claimed clients"); console.log("[SW] Activated and claimed clients");
// Notify clients that SW is ready
notifyClients({ type: "SW_ACTIVATED", version: VERSION });
})() })()
); );
}); });
// ============================================================================
// Message Handler - Client Communication
// ============================================================================
self.addEventListener("message", async (event) => {
const { type, payload } = event.data || {};
switch (type) {
case "GET_CACHE_STATS": {
try {
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
getCacheSize(STATIC_CACHE),
getCacheSize(PAGES_CACHE),
getCacheSize(API_CACHE),
getCacheSize(IMAGES_CACHE),
getCacheSize(BOOKS_CACHE),
]);
const staticCache = await caches.open(STATIC_CACHE);
const pagesCache = await caches.open(PAGES_CACHE);
const apiCache = await caches.open(API_CACHE);
const imagesCache = await caches.open(IMAGES_CACHE);
const booksCache = await caches.open(BOOKS_CACHE);
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
staticCache.keys(),
pagesCache.keys(),
apiCache.keys(),
imagesCache.keys(),
booksCache.keys(),
]);
event.source.postMessage({
type: "CACHE_STATS",
payload: {
static: { size: staticSize, entries: staticKeys.length },
pages: { size: pagesSize, entries: pagesKeys.length },
api: { size: apiSize, entries: apiKeys.length },
images: { size: imagesSize, entries: imagesKeys.length },
books: { size: booksSize, entries: booksKeys.length },
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
},
});
} catch (error) {
event.source.postMessage({
type: "CACHE_STATS_ERROR",
payload: { error: error.message },
});
}
break;
}
case "CLEAR_CACHE": {
try {
const cacheType = payload?.cacheType || "all";
const cachesToClear = [];
if (cacheType === "all" || cacheType === "static") {
cachesToClear.push(STATIC_CACHE);
}
if (cacheType === "all" || cacheType === "pages") {
cachesToClear.push(PAGES_CACHE);
}
if (cacheType === "all" || cacheType === "api") {
cachesToClear.push(API_CACHE);
}
if (cacheType === "all" || cacheType === "images") {
cachesToClear.push(IMAGES_CACHE);
}
// Note: BOOKS_CACHE is not cleared by default, only explicitly
await Promise.all(
cachesToClear.map(async (cacheName) => {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
await Promise.all(keys.map((key) => cache.delete(key)));
})
);
event.source.postMessage({
type: "CACHE_CLEARED",
payload: { caches: cachesToClear },
});
} catch (error) {
event.source.postMessage({
type: "CACHE_CLEAR_ERROR",
payload: { error: error.message },
});
}
break;
}
case "SKIP_WAITING": {
self.skipWaiting();
break;
}
case "GET_VERSION": {
event.source.postMessage({
type: "SW_VERSION",
payload: { version: VERSION },
});
break;
}
case "GET_CACHE_ENTRIES": {
try {
const cacheType = payload?.cacheType;
let cacheName;
switch (cacheType) {
case "static":
cacheName = STATIC_CACHE;
break;
case "pages":
cacheName = PAGES_CACHE;
break;
case "api":
cacheName = API_CACHE;
break;
case "images":
cacheName = IMAGES_CACHE;
break;
case "books":
cacheName = BOOKS_CACHE;
break;
default:
throw new Error("Invalid cache type");
}
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const entries = await Promise.all(
keys.map(async (request) => {
const response = await cache.match(request);
let size = 0;
if (response) {
const blob = await response.clone().blob();
size = blob.size;
}
return {
url: request.url,
size,
};
})
);
// Sort by size descending
entries.sort((a, b) => b.size - a.size);
event.source.postMessage({
type: "CACHE_ENTRIES",
payload: { cacheType, entries },
});
} catch (error) {
event.source.postMessage({
type: "CACHE_ENTRIES_ERROR",
payload: { error: error.message },
});
}
break;
}
}
});
// ============================================================================ // ============================================================================
// Fetch Handler - Request Routing // Fetch Handler - Request Routing
// ============================================================================ // ============================================================================
@@ -202,39 +489,68 @@ self.addEventListener("fetch", (event) => {
return; return;
} }
// Route 1: Images → Cache-First with ignoreSearch // Route 1: Book pages (handled by DownloadManager) - Check manual cache only, no SWR
if (isImageRequest(url.href)) { if (isBookPageRequest(url.href)) {
event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true })); event.respondWith(
(async () => {
// Check the manual books cache
const booksCache = await caches.open(BOOKS_CACHE);
const cached = await booksCache.match(request);
if (cached) {
return cached;
}
// Not in cache - fetch from network without caching
// Book pages are large and should only be cached via manual download
return fetch(request);
})()
);
return; return;
} }
// Route 2: Next.js RSC payloads → Stale-While-Revalidate // Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
if (isNextRSCRequest(request)) { if (isNextRSCRequest(request)) {
event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE)); event.respondWith(
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
notifyOnChange: false,
})
);
return; return;
} }
// Route 3: API data → Network only (no SW caching) // Route 3: Next.js static resources → Cache-First with ignoreSearch
// API data caching is handled by ServerCacheService on the server
// This avoids double caching and simplifies cache invalidation
if (isApiDataRequest(url.href)) {
// Let the request pass through to the network
// ServerCacheService will handle caching server-side
return;
}
// Route 4: Next.js static resources → Cache-First with ignoreSearch
if (isNextStaticResource(url.href)) { if (isNextStaticResource(url.href)) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true }));
return; return;
} }
// Route 5: Navigation → Network-First with SPA fallback // Route 4: API requests (JSON) → SWR with notification
if (request.mode === "navigate") { if (isApiRequest(url.href)) {
event.respondWith(navigationStrategy(request)); event.respondWith(
staleWhileRevalidateStrategy(request, API_CACHE, {
notifyOnChange: true,
isJson: true,
})
);
return; return;
} }
// Route 6: Everything else → Network only (no caching) // Route 5: Image requests (thumbnails, covers) → SWR with cache size management
// This includes: API auth, preferences, and other dynamic content // Note: Book pages are excluded (Route 1) and only use manual download cache
if (isImageRequest(url.href)) {
event.respondWith(
staleWhileRevalidateStrategy(request, IMAGES_CACHE, {
maxEntries: IMAGES_CACHE_MAX_ENTRIES,
})
);
return;
}
// Route 6: Navigation → SWR (cache first, revalidate in background)
if (request.mode === "navigate") {
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
return;
}
// Route 7: Everything else → Network only (no caching)
}); });

View File

@@ -1,7 +1,6 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { BookService } from "@/lib/services/book.service"; import { BookService } from "@/lib/services/book.service";
import { SeriesService } from "@/lib/services/series.service";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -12,9 +11,40 @@ export async function PATCH(
{ params }: { params: Promise<{ bookId: string }> } { params }: { params: Promise<{ bookId: string }> }
) { ) {
try { try {
const { page, completed } = await request.json();
const bookId: string = (await params).bookId; const bookId: string = (await params).bookId;
// Handle empty or invalid body (can happen when request is aborted during navigation)
let body: { page?: unknown; completed?: boolean };
try {
const text = await request.text();
if (!text) {
return NextResponse.json(
{
error: {
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
name: "Progress update error",
message: "Empty request body",
},
},
{ status: 400 }
);
}
body = JSON.parse(text);
} catch {
return NextResponse.json(
{
error: {
code: ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR,
name: "Progress update error",
message: "Invalid JSON body",
},
},
{ status: 400 }
);
}
const { page, completed } = body;
if (typeof page !== "number") { if (typeof page !== "number") {
return NextResponse.json( return NextResponse.json(
{ {
@@ -30,16 +60,6 @@ export async function PATCH(
await BookService.updateReadProgress(bookId, page, completed); await BookService.updateReadProgress(bookId, page, completed);
// Invalider le cache de la série après avoir mis à jour la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "📖 Progression mise à jour avec succès" }); return NextResponse.json({ message: "📖 Progression mise à jour avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:"); logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:");
@@ -77,16 +97,6 @@ export async function DELETE(
await BookService.deleteReadProgress(bookId); await BookService.deleteReadProgress(bookId);
// Invalider le cache de la série après avoir supprimé la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" }); return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression de la progression:"); logger.error({ err: error }, "Erreur lors de la suppression de la progression:");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,14 +71,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const cookieStore = await cookies(); const cookieStore = await cookies();
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr"; const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
// Les libraries et favorites sont chargés côté client par la Sidebar
let preferences: UserPreferences = defaultPreferences; let preferences: UserPreferences = defaultPreferences;
let userIsAdmin = false; let userIsAdmin = false;
let libraries: any[] = [];
let favorites: any[] = [];
try { try {
const [preferencesData, isAdminCheck] = await Promise.allSettled([ const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(), PreferencesService.getPreferences(),
import("@/lib/auth-utils").then((m) => m.isAdmin()), import("@/lib/auth-utils").then((m) => m.isAdmin()),
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
import("@/lib/services/favorites.service").then((m) => m.FavoritesService.getFavorites()),
]); ]);
if (preferencesData.status === "fulfilled") { if (preferencesData.status === "fulfilled") {
@@ -88,8 +91,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
if (isAdminCheck.status === "fulfilled") { if (isAdminCheck.status === "fulfilled") {
userIsAdmin = isAdminCheck.value; userIsAdmin = isAdminCheck.value;
} }
if (librariesData.status === "fulfilled") {
libraries = librariesData.value;
}
if (favoritesData.status === "fulfilled") {
favorites = favoritesData.value;
}
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors du chargement des préférences:"); logger.error({ err: error }, "Erreur lors du chargement des données initiales:");
} }
return ( return (
@@ -155,7 +166,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider> <AuthProvider>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}> <PreferencesProvider initialPreferences={preferences}>
<ClientLayout initialLibraries={[]} initialFavorites={[]} userIsAdmin={userIsAdmin}> <ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
{children} {children}
</ClientLayout> </ClientLayout>
</PreferencesProvider> </PreferencesProvider>

View File

@@ -1,269 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { RefreshButton } from "@/components/library/RefreshButton";
import { LibraryHeader } from "@/components/library/LibraryHeader";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { useTranslate } from "@/hooks/useTranslate";
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import type { LibraryResponse } from "@/types/library";
import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
import { Container } from "@/components/ui/container";
import { Section } from "@/components/ui/section";
import logger from "@/lib/logger";
interface ClientLibraryPageProps {
currentPage: number;
libraryId: string;
preferences: UserPreferences;
unreadOnly: boolean;
search?: string;
pageSize?: number;
}
const DEFAULT_PAGE_SIZE = 20;
export function ClientLibraryPage({
currentPage,
libraryId,
preferences,
unreadOnly,
search,
pageSize,
}: ClientLibraryPageProps) {
const { t } = useTranslate();
const [library, setLibrary] = useState<KomgaLibrary | null>(null);
const [series, setSeries] = useState<LibraryResponse<KomgaSeries> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
} catch (err) {
logger.error({ err }, "Error fetching library series");
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
} finally {
setLoading(false);
}
};
fetchData();
}, [libraryId, currentPage, unreadOnly, search, effectivePageSize]);
const handleRefresh = async (libraryId: string) => {
try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/libraries/${libraryId}/series`, {
method: "DELETE",
});
if (!cacheResponse.ok) {
throw new Error("Error invalidating cache");
}
// Recharger les données
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload", // Force un nouveau fetch après invalidation
});
if (!response.ok) {
throw new Error("Error refreshing library");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Error during refresh:");
return { success: false, error: "Error refreshing library" };
}
};
const handleRetry = async () => {
setError(null);
setLoading(true);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
if (search) {
params.append("search", search);
}
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "reload", // Force un nouveau fetch lors du retry
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || "SERIES_FETCH_ERROR");
}
const data = await response.json();
setLibrary(data.library);
setSeries(data.series);
} catch (err) {
logger.error({ err }, "Error fetching library series");
setError(err instanceof Error ? err.message : "SERIES_FETCH_ERROR");
} finally {
setLoading(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh(libraryId);
},
enabled: !loading && !error && !!library && !!series,
});
if (loading) {
return (
<>
{/* Header skeleton */}
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden mb-8">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-background" />
<div className="relative container mx-auto px-4 py-8 h-full">
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
<OptimizedSkeleton className="w-[120px] h-[120px] rounded-lg" />
<div className="flex-1 space-y-3">
<OptimizedSkeleton className="h-10 w-64" />
<div className="flex gap-4">
<OptimizedSkeleton className="h-8 w-32" />
<OptimizedSkeleton className="h-8 w-32" />
<OptimizedSkeleton className="h-10 w-10 rounded-full" />
</div>
</div>
</div>
</div>
</div>
<Container>
{/* Filters */}
<div className="flex flex-col gap-4 mb-8">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="w-full">
<OptimizedSkeleton className="h-10 w-full" />
</div>
<div className="flex items-center justify-end gap-2">
<OptimizedSkeleton className="h-10 w-24" />
<OptimizedSkeleton className="h-10 w-10 rounded" />
<OptimizedSkeleton className="h-10 w-10 rounded" />
</div>
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{Array.from({ length: effectivePageSize }).map((_, i) => (
<OptimizedSkeleton key={i} className="aspect-[2/3] w-full rounded-lg" />
))}
</div>
{/* Pagination */}
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<OptimizedSkeleton className="h-5 w-32 order-2 sm:order-1" />
<OptimizedSkeleton className="h-10 w-64 order-1 sm:order-2" />
</div>
</Container>
</>
);
}
if (error) {
return (
<Container>
<Section
title={library?.name || t("series.empty")}
actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
/>
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</Container>
);
}
if (!library || !series) {
return (
<Container>
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
</Container>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<LibraryHeader
library={library}
seriesCount={series.totalElements}
series={series.content || []}
refreshLibrary={handleRefresh}
/>
<Container>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
pageSize={effectivePageSize}
/>
</Container>
</>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import type { UserPreferences } from "@/types/preferences";
interface LibraryClientWrapperProps {
children: ReactNode;
libraryId: string;
currentPage: number;
unreadOnly: boolean;
search?: string;
pageSize: number;
preferences: UserPreferences;
}
export function LibraryClientWrapper({
children,
libraryId,
currentPage,
unreadOnly,
search,
pageSize,
}: LibraryClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
...(search && { search }),
});
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh library");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshLibrary={handleRefresh}>{children}</RefreshProvider>
</>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { LibraryHeader } from "@/components/library/LibraryHeader";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { KomgaLibrary } from "@/types/komga";
import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series";
import type { UserPreferences } from "@/types/preferences";
interface LibraryContentProps {
library: KomgaLibrary;
series: LibraryResponse<Series>;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
search?: string;
pageSize: number;
}
export function LibraryContent({
library,
series,
currentPage,
preferences,
unreadOnly,
pageSize,
}: LibraryContentProps) {
const { refreshLibrary } = useRefresh();
return (
<>
<LibraryHeader
library={library}
seriesCount={series.totalElements}
series={series.content || []}
refreshLibrary={refreshLibrary || (async () => ({ success: false }))}
/>
<Container>
<PaginatedSeriesGrid
series={series.content || []}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
pageSize={pageSize}
/>
</Container>
</>
);
}

View File

@@ -1,5 +1,10 @@
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { ClientLibraryPage } from "./ClientLibraryPage"; import { LibraryService } from "@/lib/services/library.service";
import { LibraryClientWrapper } from "./LibraryClientWrapper";
import { LibraryContent } from "./LibraryContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
interface PageProps { interface PageProps {
@@ -7,6 +12,8 @@ interface PageProps {
searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>; searchParams: Promise<{ page?: string; unread?: string; search?: string; size?: string }>;
} }
const DEFAULT_PAGE_SIZE = 20;
export default async function LibraryPage({ params, searchParams }: PageProps) { export default async function LibraryPage({ params, searchParams }: PageProps) {
const libraryId = (await params).libraryId; const libraryId = (await params).libraryId;
const unread = (await searchParams).unread; const unread = (await searchParams).unread;
@@ -19,15 +26,49 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur // Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread; const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size
? parseInt(size)
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
return ( try {
<ClientLibraryPage const [series, library] = await Promise.all([
currentPage={currentPage} LibraryService.getLibrarySeries(
libraryId={libraryId} libraryId,
preferences={preferences} currentPage - 1,
unreadOnly={unreadOnly} effectivePageSize,
search={search} unreadOnly,
pageSize={size ? parseInt(size) : undefined} search
/> ),
); LibraryService.getLibrary(libraryId),
]);
return (
<LibraryClientWrapper
libraryId={libraryId}
currentPage={currentPage}
unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize}
preferences={preferences}
>
<LibraryContent
library={library}
series={series}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize}
/>
</LibraryClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
} }

View File

@@ -1,5 +1,33 @@
import { ClientHomePage } from "@/components/home/ClientHomePage"; import { HomeService } from "@/lib/services/home.service";
import { HomeContent } from "@/components/home/HomeContent";
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { redirect } from "next/navigation";
export default function HomePage() { export default async function HomePage() {
return <ClientHomePage />; try {
const data = await HomeService.getHomeData();
return (
<HomeClientWrapper>
<HomeContent data={data} />
</HomeClientWrapper>
);
} catch (error) {
// Si la config Komga est manquante, rediriger vers les settings
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
redirect("/settings");
}
// Afficher une erreur pour les autres cas
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
} }

View File

@@ -1,207 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
interface ClientSeriesPageProps {
seriesId: string;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
pageSize?: number;
}
const DEFAULT_PAGE_SIZE = 20;
export function ClientSeriesPage({
seriesId,
currentPage,
preferences,
unreadOnly,
pageSize,
}: ClientSeriesPageProps) {
const [series, setSeries] = useState<KomgaSeries | null>(null);
const [books, setBooks] = useState<LibraryResponse<KomgaBook> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectivePageSize = pageSize || preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
} catch (err) {
logger.error({ err }, "Error fetching series books");
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
} finally {
setLoading(false);
}
};
fetchData();
}, [seriesId, currentPage, unreadOnly, effectivePageSize]);
const handleRefresh = async (seriesId: string) => {
try {
// Invalidate cache via API
const cacheResponse = await fetch(`/api/komga/series/${seriesId}/books`, {
method: "DELETE",
});
if (!cacheResponse.ok) {
throw new Error("Erreur lors de l'invalidation du cache");
}
// Recharger les données
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload", // Force un nouveau fetch après invalidation
});
if (!response.ok) {
throw new Error("Erreur lors du rafraîchissement de la série");
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la série" };
}
};
const handleRetry = async () => {
setError(null);
setLoading(true);
try {
const params = new URLSearchParams({
page: String(currentPage - 1),
size: String(effectivePageSize),
unread: String(unreadOnly),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "reload", // Force un nouveau fetch lors du retry
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
}
const data = await response.json();
setSeries(data.series);
setBooks(data.books);
} catch (err) {
logger.error({ err }, "Error fetching series books");
setError(err instanceof Error ? err.message : ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
} finally {
setLoading(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh(seriesId);
},
enabled: !loading && !error && !!series && !!books,
});
if (loading) {
return (
<div className="container py-8 space-y-8">
<div className="space-y-4">
<OptimizedSkeleton className="h-64 w-full rounded" />
<OptimizedSkeleton className="h-10 w-64" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: effectivePageSize }).map((_, i) => (
<OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</div>
);
}
if (!series || !books) {
return (
<div className="container py-8 space-y-8">
<h1 className="text-3xl font-bold">Série</h1>
<ErrorMessage errorCode={ERROR_CODES.SERIES.FETCH_ERROR} onRetry={handleRetry} />
</div>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<div className="container">
<SeriesHeader series={series} refreshSeries={handleRefresh} />
<PaginatedBookGrid
books={books.content || []}
currentPage={currentPage}
totalPages={books.totalPages}
totalElements={books.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
onRefresh={() => handleRefresh(seriesId)}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import type { UserPreferences } from "@/types/preferences";
interface SeriesClientWrapperProps {
children: ReactNode;
seriesId: string;
currentPage: number;
unreadOnly: boolean;
pageSize: number;
preferences: UserPreferences;
}
export function SeriesClientWrapper({
children,
seriesId,
currentPage,
unreadOnly,
pageSize,
}: SeriesClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Fetch fresh data from network with cache bypass
const params = new URLSearchParams({
page: String(currentPage),
size: String(pageSize),
...(unreadOnly && { unreadOnly: "true" }),
});
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh series");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
</>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences";
interface SeriesContentProps {
series: KomgaSeries;
books: LibraryResponse<KomgaBook>;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
pageSize: number;
}
export function SeriesContent({
series,
books,
currentPage,
preferences,
unreadOnly,
}: SeriesContentProps) {
const { refreshSeries } = useRefresh();
return (
<>
<SeriesHeader
series={series}
refreshSeries={refreshSeries || (async () => ({ success: false }))}
/>
<Container>
<PaginatedBookGrid
books={books.content || []}
currentPage={currentPage}
totalPages={books.totalPages}
totalElements={books.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>
</Container>
</>
);
}

View File

@@ -1,5 +1,10 @@
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { ClientSeriesPage } from "./ClientSeriesPage"; import { SeriesService } from "@/lib/services/series.service";
import { SeriesClientWrapper } from "./SeriesClientWrapper";
import { SeriesContent } from "./SeriesContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
interface PageProps { interface PageProps {
@@ -7,6 +12,8 @@ interface PageProps {
searchParams: Promise<{ page?: string; unread?: string; size?: string }>; searchParams: Promise<{ page?: string; unread?: string; size?: string }>;
} }
const DEFAULT_PAGE_SIZE = 20;
export default async function SeriesPage({ params, searchParams }: PageProps) { export default async function SeriesPage({ params, searchParams }: PageProps) {
const seriesId = (await params).seriesId; const seriesId = (await params).seriesId;
const page = (await searchParams).page; const page = (await searchParams).page;
@@ -18,14 +25,41 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur // Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread; const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
return ( try {
<ClientSeriesPage const [books, series] = await Promise.all([
seriesId={seriesId} SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
currentPage={currentPage} SeriesService.getSeries(seriesId),
preferences={preferences} ]);
unreadOnly={unreadOnly}
pageSize={size ? parseInt(size) : undefined} return (
/> <SeriesClientWrapper
); seriesId={seriesId}
currentPage={currentPage}
unreadOnly={unreadOnly}
pageSize={effectivePageSize}
preferences={preferences}
>
<SeriesContent
series={series}
books={books}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
pageSize={effectivePageSize}
/>
</SeriesClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError
? error.code
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />
</main>
);
}
} }

View File

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

View File

@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden"> <div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image <Image
src={`/api/komga/images/books/${book.id}/thumbnail`} src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })} alt={t("books.coverAlt", { title: book.metadata?.title })}
className="object-cover" className="object-cover"
fill fill
sizes="48px" sizes="64px"
priority={false} priority={false}
unoptimized
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View File

@@ -1,130 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { HomeContent } from "./HomeContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { HomePageSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { HomeData } from "@/types/home";
import logger from "@/lib/logger";
export function ClientHomePage() {
const router = useRouter();
const [data, setData] = useState<HomeData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/komga/home", {
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) {
const errorData = await response.json();
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
// Si la config Komga est manquante, rediriger vers les settings
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
router.push("/settings");
return;
}
throw new Error(errorCode);
}
const homeData = await response.json();
setData(homeData);
} catch (err) {
logger.error({ err }, "Error fetching home data");
setError(err instanceof Error ? err.message : ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleRefresh = async () => {
try {
// Invalider le cache via l'API
const deleteResponse = await fetch("/api/komga/home", {
method: "DELETE",
});
if (!deleteResponse.ok) {
throw new Error("Erreur lors de l'invalidation du cache");
}
// Récupérer les nouvelles données
const response = await fetch("/api/komga/home", {
cache: "reload", // Force un nouveau fetch après invalidation
});
if (!response.ok) {
throw new Error("Erreur lors du rafraîchissement de la page d'accueil");
}
const homeData = await response.json();
setData(homeData);
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !loading && !error && !!data,
});
if (loading) {
return <HomePageSkeleton />;
}
const handleRetry = () => {
fetchData();
};
if (error) {
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={error} onRetry={handleRetry} />
</main>
);
}
if (!data) {
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={ERROR_CODES.KOMGA.SERVER_UNREACHABLE} onRetry={handleRetry} />
</main>
);
}
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<HomeContent data={data} refreshHome={handleRefresh} />
</>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { RefreshButton } from "@/components/library/RefreshButton";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { useTranslate } from "@/hooks/useTranslate";
import logger from "@/lib/logger";
interface HomeClientWrapperProps {
children: ReactNode;
}
export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
const router = useRouter();
const { t } = useTranslate();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
// Fetch fresh data from network with cache bypass
const response = await fetch("/api/komga/home", {
cache: "no-store",
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error("Failed to refresh home");
}
// Trigger Next.js revalidation to update the UI
router.refresh();
return { success: true };
} catch (error) {
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
} finally {
setIsRefreshing(false);
}
};
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
await handleRefresh();
},
enabled: !isRefreshing,
});
return (
<>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
progress={pullToRefresh.progress}
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<main className="container mx-auto px-4 py-8 space-y-12">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
</div>
{children}
</main>
</>
);
}

View File

@@ -1,122 +1,74 @@
"use client";
import { HeroSection } from "./HeroSection";
import { MediaRow } from "./MediaRow"; import { MediaRow } from "./MediaRow";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { HomeData } from "@/types/home"; import type { HomeData } from "@/types/home";
import { RefreshButton } from "@/components/library/RefreshButton";
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { useTranslate } from "@/hooks/useTranslate";
import { useEffect, useState } from "react";
interface HomeContentProps { interface HomeContentProps {
data: HomeData; data: HomeData;
refreshHome: () => Promise<{ success: boolean; error?: string }>;
} }
export function HomeContent({ data, refreshHome }: HomeContentProps) { const optimizeSeriesData = (series: KomgaSeries[]) => {
const { t } = useTranslate(); return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
const [showHero, setShowHero] = useState(false); id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
// Vérifier si la HeroSection a déjà été affichée const optimizeBookData = (books: KomgaBook[]) => {
useEffect(() => { return books.map(({ id, metadata, readProgress, media }) => ({
const heroShown = localStorage.getItem("heroSectionShown"); id,
if (!heroShown && data.ongoing && data.ongoing.length > 0) { metadata: {
setShowHero(true); title: metadata.title,
localStorage.setItem("heroSectionShown", "true"); number: metadata.number,
} },
}, [data.ongoing]); readProgress: readProgress || { page: 0 },
media,
// Vérification des données pour le debug }));
// logger.info("HomeContent - Données reçues:", { };
// ongoingCount: data.ongoing?.length || 0,
// recentlyReadCount: data.recentlyRead?.length || 0,
// onDeckCount: data.onDeck?.length || 0,
// });
const optimizeSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeHeroSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
export function HomeContent({ data }: HomeContentProps) {
return ( return (
<main className="container mx-auto px-4 py-8 space-y-12"> <div className="space-y-12">
<div className="flex justify-between items-center"> {data.ongoing && data.ongoing.length > 0 && (
<h1 className="text-3xl font-bold">{t("home.title")}</h1> <MediaRow
<RefreshButton libraryId="home" refreshLibrary={refreshHome} /> titleKey="home.sections.continue_series"
</div> items={optimizeSeriesData(data.ongoing)}
{/* Hero Section - Afficher uniquement si nous avons des séries en cours et si elle n'a jamais été affichée */} iconName="LibraryBig"
{showHero && data.ongoing && data.ongoing.length > 0 && ( />
<HeroSection series={optimizeHeroSeriesData(data.ongoing)} />
)} )}
{/* Sections de contenu */} {data.ongoingBooks && data.ongoingBooks.length > 0 && (
<div className="space-y-12"> <MediaRow
{data.ongoing && data.ongoing.length > 0 && ( titleKey="home.sections.continue_reading"
<MediaRow items={optimizeBookData(data.ongoingBooks)}
title={t("home.sections.continue_series")} iconName="BookOpen"
items={optimizeSeriesData(data.ongoing)} />
icon={LibraryBig} )}
/>
)}
{data.ongoingBooks && data.ongoingBooks.length > 0 && ( {data.onDeck && data.onDeck.length > 0 && (
<MediaRow <MediaRow
title={t("home.sections.continue_reading")} titleKey="home.sections.up_next"
items={optimizeBookData(data.ongoingBooks)} items={optimizeBookData(data.onDeck)}
icon={BookOpen} iconName="Clock"
/> />
)} )}
{data.onDeck && data.onDeck.length > 0 && ( {data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow <MediaRow
title={t("home.sections.up_next")} titleKey="home.sections.latest_series"
items={optimizeBookData(data.onDeck)} items={optimizeSeriesData(data.latestSeries)}
icon={Clock} iconName="Sparkles"
/> />
)} )}
{data.latestSeries && data.latestSeries.length > 0 && ( {data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow <MediaRow
title={t("home.sections.latest_series")} titleKey="home.sections.recently_added"
items={optimizeSeriesData(data.latestSeries)} items={optimizeBookData(data.recentlyRead)}
icon={Sparkles} iconName="History"
/> />
)} )}
</div>
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
title={t("home.sections.recently_added")}
items={optimizeBookData(data.recentlyRead)}
icon={History}
/>
)}
</div>
</main>
); );
} }

View File

@@ -7,7 +7,7 @@ import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { ScrollContainer } from "@/components/ui/scroll-container"; import { ScrollContainer } from "@/components/ui/scroll-container";
import { Section } from "@/components/ui/section"; import { Section } from "@/components/ui/section";
import type { LucideIcon } from "lucide-react"; import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -38,14 +38,23 @@ interface OptimizedBook extends BaseItem {
} }
interface MediaRowProps { interface MediaRowProps {
title: string; titleKey: string;
items: (OptimizedSeries | OptimizedBook)[]; items: (OptimizedSeries | OptimizedBook)[];
icon?: LucideIcon; iconName?: string;
} }
export function MediaRow({ title, items, icon }: MediaRowProps) { const iconMap = {
LibraryBig,
BookOpen,
Clock,
Sparkles,
History,
};
export function MediaRow({ titleKey, items, iconName }: MediaRowProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslate(); const { t } = useTranslate();
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
const onItemClick = (item: OptimizedSeries | OptimizedBook) => { const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`; const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
@@ -55,7 +64,7 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
if (!items.length) return null; if (!items.length) return null;
return ( return (
<Section title={title} icon={icon}> <Section title={t(titleKey)} icon={icon}>
<ScrollContainer <ScrollContainer
showArrows={true} showArrows={true}
scrollAmount={400} scrollAmount={400}

View File

@@ -7,10 +7,9 @@ import { Sidebar } from "@/components/layout/Sidebar";
import { InstallPWA } from "../ui/InstallPWA"; import { InstallPWA } from "../ui/InstallPWA";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus"; import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext"; import { usePreferences } from "@/contexts/PreferencesContext";
import { ImageCacheProvider } from "@/contexts/ImageCacheContext"; import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
@@ -136,10 +135,6 @@ export default function ClientLayout({
}; };
}, [isSidebarOpen]); }, [isSidebarOpen]);
useEffect(() => {
// Enregistrer le service worker
registerServiceWorker();
}, []);
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader // Ne pas afficher le header et la sidebar sur les routes publiques et le reader
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/"); const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
@@ -152,7 +147,7 @@ export default function ClientLayout({
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ImageCacheProvider> <ServiceWorkerProvider>
{/* Background fixe pour les images et gradients */} {/* Background fixe pour les images et gradients */}
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />} {hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div <div
@@ -184,7 +179,7 @@ export default function ClientLayout({
<Toaster /> <Toaster />
<NetworkStatus /> <NetworkStatus />
</div> </div>
</ImageCacheProvider> </ServiceWorkerProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import { usePreferences } from "@/contexts/PreferencesContext";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
@@ -44,37 +43,12 @@ export function Sidebar({
const { t } = useTranslate(); const { t } = useTranslate();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { preferences } = usePreferences();
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []); const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []); const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const refreshLibraries = useCallback(async () => {
setIsRefreshing(true);
try {
const response = await fetch("/api/komga/libraries");
if (!response.ok) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR);
}
const data = await response.json();
setLibraries(data);
} catch (error) {
logger.error({ err: error }, "Erreur de chargement des bibliothèques:");
toast({
title: "Erreur",
description:
error instanceof AppError
? error.message
: getErrorMessage(ERROR_CODES.LIBRARY.FETCH_ERROR),
variant: "destructive",
});
} finally {
setIsRefreshing(false);
}
}, [toast]);
const refreshFavorites = useCallback(async () => { const refreshFavorites = useCallback(async () => {
try { try {
const favoritesResponse = await fetch("/api/komga/favorites"); const favoritesResponse = await fetch("/api/komga/favorites");
@@ -111,13 +85,6 @@ export function Sidebar({
} }
}, [toast]); }, [toast]);
useEffect(() => {
if (Object.keys(preferences).length > 0) {
refreshLibraries();
refreshFavorites();
}
}, [preferences, refreshLibraries, refreshFavorites]);
// Mettre à jour les favoris quand ils changent // Mettre à jour les favoris quand ils changent
useEffect(() => { useEffect(() => {
const handleFavoritesChange = () => { const handleFavoritesChange = () => {
@@ -133,7 +100,10 @@ export function Sidebar({
const handleRefresh = async () => { const handleRefresh = async () => {
setIsRefreshing(true); setIsRefreshing(true);
await Promise.all([refreshLibraries(), refreshFavorites()]); // Revalider côté serveur via router.refresh()
router.refresh();
// Petit délai pour laisser le temps au serveur
setTimeout(() => setIsRefreshing(false), 500);
}; };
const handleLogout = async () => { const handleLogout = async () => {

View File

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

View File

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

View File

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

View File

@@ -1,61 +0,0 @@
"use client";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { usePreferences } from "@/contexts/PreferencesContext";
import logger from "@/lib/logger";
export function CacheModeSwitch() {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const { preferences, updatePreferences } = usePreferences();
const handleToggle = async (checked: boolean) => {
setIsLoading(true);
try {
// Mettre à jour les préférences
await updatePreferences({ cacheMode: checked ? "memory" : "file" });
// Mettre à jour le mode de cache côté serveur
const res = await fetch("/api/komga/cache/mode", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ mode: checked ? "memory" : "file" }),
});
if (!res.ok) throw new Error();
toast({
title: "Mode de cache modifié",
description: `Le cache est maintenant en mode ${checked ? "mémoire" : "fichier"}`,
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la modification du mode de cache:");
toast({
variant: "destructive",
title: "Erreur",
description: "Impossible de modifier le mode de cache",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center space-x-2">
<Switch
id="cache-mode"
checked={preferences.cacheMode === "memory"}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
<Label htmlFor="cache-mode" className="text-sm text-muted-foreground">
Cache en mémoire {isLoading && "(chargement...)"}
</Label>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils"; import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
import { useImageUrl } from "@/hooks/useImageUrl";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { MarkAsReadButton } from "./mark-as-read-button"; import { MarkAsReadButton } from "./mark-as-read-button";
import { MarkAsUnreadButton } from "./mark-as-unread-button"; import { MarkAsUnreadButton } from "./mark-as-unread-button";
@@ -63,8 +62,7 @@ export function BookCover({
const { t } = useTranslate(); const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id); const { isAccessible } = useBookOfflineStatus(book.id);
const baseUrl = getImageUrl("book", book.id); const imageUrl = getImageUrl("book", book.id);
const imageUrl = useImageUrl(baseUrl);
const isCompleted = book.readProgress?.completed || false; const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book); const currentPage = ClientOfflineBookService.getCurrentPage(book);

View File

@@ -0,0 +1,12 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -4,7 +4,6 @@ import { CoverClient } from "./cover-client";
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils"; import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url"; import { getImageUrl } from "@/lib/utils/image-url";
import { useImageUrl } from "@/hooks/useImageUrl";
export function SeriesCover({ export function SeriesCover({
series, series,
@@ -12,8 +11,7 @@ export function SeriesCover({
className, className,
showProgressUi = true, showProgressUi = true,
}: SeriesCoverProps) { }: SeriesCoverProps) {
const baseUrl = getImageUrl("series", series.id); const imageUrl = getImageUrl("series", series.id);
const imageUrl = useImageUrl(baseUrl);
const isCompleted = series.booksCount === series.booksReadCount; const isCompleted = series.booksCount === series.booksReadCount;
const readBooks = series.booksReadCount; const readBooks = series.booksReadCount;

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import logger from "@/lib/logger";
interface ImageCacheContextType {
cacheVersion: string;
flushImageCache: () => void;
getImageUrl: (baseUrl: string) => string;
}
const ImageCacheContext = createContext<ImageCacheContextType | undefined>(undefined);
export function ImageCacheProvider({ children }: { children: React.ReactNode }) {
const [cacheVersion, setCacheVersion] = useState<string>("");
// Initialiser la version depuis localStorage au montage
useEffect(() => {
const storedVersion = localStorage.getItem("imageCacheVersion");
if (storedVersion) {
setCacheVersion(storedVersion);
} else {
const newVersion = Date.now().toString();
setCacheVersion(newVersion);
localStorage.setItem("imageCacheVersion", newVersion);
}
}, []);
const flushImageCache = useCallback(() => {
const newVersion = Date.now().toString();
setCacheVersion(newVersion);
localStorage.setItem("imageCacheVersion", newVersion);
logger.info(`🗑️ Image cache flushed - new version: ${newVersion}`);
}, []);
const getImageUrl = useCallback(
(baseUrl: string) => {
if (!cacheVersion) return baseUrl;
const separator = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${separator}v=${cacheVersion}`;
},
[cacheVersion]
);
return (
<ImageCacheContext.Provider value={{ cacheVersion, flushImageCache, getImageUrl }}>
{children}
</ImageCacheContext.Provider>
);
}
export function useImageCache() {
const context = useContext(ImageCacheContext);
if (context === undefined) {
throw new Error("useImageCache must be used within an ImageCacheProvider");
}
return context;
}

View File

@@ -16,6 +16,10 @@ interface PreferencesContextType {
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined); const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
// Module-level flag to prevent duplicate fetches (survives StrictMode remounts)
let preferencesFetchInProgress = false;
let preferencesFetched = false;
export function PreferencesProvider({ export function PreferencesProvider({
children, children,
initialPreferences, initialPreferences,
@@ -29,7 +33,17 @@ export function PreferencesProvider({
); );
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Check if we have valid initial preferences from server
const hasValidInitialPreferences =
initialPreferences && Object.keys(initialPreferences).length > 0;
const fetchPreferences = useCallback(async () => { const fetchPreferences = useCallback(async () => {
// Prevent concurrent fetches
if (preferencesFetchInProgress || preferencesFetched) {
return;
}
preferencesFetchInProgress = true;
try { try {
const response = await fetch("/api/preferences"); const response = await fetch("/api/preferences");
if (!response.ok) { if (!response.ok) {
@@ -45,25 +59,30 @@ export function PreferencesProvider({
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode, viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
}, },
}); });
preferencesFetched = true;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des préférences"); logger.error({ err: error }, "Erreur lors de la récupération des préférences");
setPreferences(defaultPreferences); setPreferences(defaultPreferences);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
preferencesFetchInProgress = false;
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
// Recharger les préférences quand la session change (connexion/déconnexion)
if (status === "authenticated") { if (status === "authenticated") {
// Toujours recharger depuis l'API pour avoir les dernières valeurs // Skip refetch if we already have valid initial preferences from server
// même si on a des initialPreferences (qui peuvent être en cache) if (hasValidInitialPreferences) {
preferencesFetched = true; // Mark as fetched since we have server data
return;
}
fetchPreferences(); fetchPreferences();
} else if (status === "unauthenticated") { } else if (status === "unauthenticated") {
// Réinitialiser aux préférences par défaut quand l'utilisateur se déconnecte // Reset to defaults when user logs out
setPreferences(defaultPreferences); setPreferences(defaultPreferences);
preferencesFetched = false; // Allow refetch on next login
} }
}, [status, fetchPreferences]); }, [status, fetchPreferences, hasValidInitialPreferences]);
const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => { const updatePreferences = useCallback(async (newPreferences: Partial<UserPreferences>) => {
try { try {

View File

@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
interface RefreshContextType {
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
}
const RefreshContext = createContext<RefreshContextType>({});
export function RefreshProvider({
children,
refreshLibrary,
refreshSeries,
}: {
children: ReactNode;
refreshLibrary?: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
refreshSeries?: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
}) {
return (
<RefreshContext.Provider value={{ refreshLibrary, refreshSeries }}>
{children}
</RefreshContext.Provider>
);
}
export function useRefresh() {
return useContext(RefreshContext);
}

View File

@@ -0,0 +1,368 @@
"use client";
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
import logger from "@/lib/logger";
interface CacheStats {
static: { size: number; entries: number };
pages: { size: number; entries: number };
api: { size: number; entries: number };
images: { size: number; entries: number };
books: { size: number; entries: number };
total: number;
}
interface CacheEntry {
url: string;
size: number;
}
interface CacheUpdate {
url: string;
timestamp: number;
}
type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
interface ServiceWorkerContextValue {
isSupported: boolean;
isReady: boolean;
version: string | null;
hasNewVersion: boolean;
cacheUpdates: CacheUpdate[];
clearCacheUpdate: (url: string) => void;
clearAllCacheUpdates: () => void;
getCacheStats: () => Promise<CacheStats | null>;
getCacheEntries: (cacheType: CacheType) => Promise<CacheEntry[] | null>;
clearCache: (cacheType?: CacheType) => Promise<boolean>;
skipWaiting: () => void;
reloadForUpdate: () => void;
reinstallServiceWorker: () => Promise<boolean>;
}
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
const [isSupported, setIsSupported] = useState(false);
const [isReady, setIsReady] = useState(false);
const [version, setVersion] = useState<string | null>(null);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
const pendingRequests = useRef<Map<string, (value: unknown) => void>>(new Map());
const waitingWorkerRef = useRef<ServiceWorker | null>(null);
// Handle messages from service worker
const handleMessage = useCallback((event: MessageEvent) => {
try {
// Ignore messages without proper data structure
if (!event.data || typeof event.data !== "object") return;
// Only handle messages from our service worker (check for known message types)
const knownTypes = [
"SW_ACTIVATED",
"SW_VERSION",
"CACHE_UPDATED",
"CACHE_STATS",
"CACHE_STATS_ERROR",
"CACHE_CLEARED",
"CACHE_CLEAR_ERROR",
"CACHE_ENTRIES",
"CACHE_ENTRIES_ERROR",
];
const type = event.data.type;
if (typeof type !== "string" || !knownTypes.includes(type)) return;
const payload = event.data.payload;
switch (type) {
case "SW_ACTIVATED":
setIsReady(true);
setVersion(payload?.version || null);
break;
case "SW_VERSION":
setVersion(payload?.version || null);
break;
case "CACHE_UPDATED": {
const url = typeof payload?.url === "string" ? payload.url : null;
const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now();
if (url) {
setCacheUpdates((prev) => {
// Avoid duplicates for the same URL within 1 second
const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000);
if (existing) return prev;
return [...prev, { url, timestamp }];
});
}
break;
}
case "CACHE_STATS":
const statsResolver = pendingRequests.current.get("CACHE_STATS");
if (statsResolver) {
statsResolver(payload);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_STATS_ERROR":
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
if (statsErrorResolver) {
statsErrorResolver(null);
pendingRequests.current.delete("CACHE_STATS");
}
break;
case "CACHE_CLEARED":
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearResolver) {
clearResolver(true);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_CLEAR_ERROR":
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
if (clearErrorResolver) {
clearErrorResolver(false);
pendingRequests.current.delete("CACHE_CLEARED");
}
break;
case "CACHE_ENTRIES": {
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesResolver) {
entriesResolver(payload?.entries || null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
case "CACHE_ENTRIES_ERROR": {
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
if (entriesErrorResolver) {
entriesErrorResolver(null);
pendingRequests.current.delete("CACHE_ENTRIES");
}
break;
}
default:
// Ignore unknown message types
break;
}
} catch (error) {
// Silently ignore message handling errors to prevent app crashes
// This can happen with malformed messages or during SW reinstall
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line no-console
console.warn("[SW Context] Error handling message:", error, event.data);
}
}
}, []);
// Initialize service worker communication
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
setIsSupported(false);
return;
}
setIsSupported(true);
// Register service worker
registerServiceWorker({
onSuccess: (registration) => {
logger.info({ scope: registration.scope }, "Service worker registered");
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
},
onUpdate: (registration) => {
logger.info("New service worker version available");
setHasNewVersion(true);
waitingWorkerRef.current = registration.waiting;
},
onError: (error) => {
logger.error({ err: error }, "Service worker registration failed");
},
});
// Listen for messages
navigator.serviceWorker.addEventListener("message", handleMessage);
// Check if already controlled
if (navigator.serviceWorker.controller) {
setIsReady(true);
// Request version
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
// Listen for controller changes
const handleControllerChange = () => {
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
};
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
return () => {
navigator.serviceWorker.removeEventListener("message", handleMessage);
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
};
}, [handleMessage]);
const clearCacheUpdate = useCallback((url: string) => {
setCacheUpdates((prev) => prev.filter((u) => u.url !== url));
}, []);
const clearAllCacheUpdates = useCallback(() => {
setCacheUpdates([]);
}, []);
const getCacheStats = useCallback(async (): Promise<CacheStats | null> => {
if (!navigator.serviceWorker.controller) return null;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_STATS", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({ type: "GET_CACHE_STATS" });
// Timeout after 5 seconds
setTimeout(() => {
if (pendingRequests.current.has("CACHE_STATS")) {
pendingRequests.current.delete("CACHE_STATS");
resolve(null);
}
}, 5000);
});
}, []);
const getCacheEntries = useCallback(
async (cacheType: CacheType): Promise<CacheEntry[] | null> => {
if (!navigator.serviceWorker.controller) return null;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_ENTRIES", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({
type: "GET_CACHE_ENTRIES",
payload: { cacheType },
});
// Timeout after 10 seconds (can be slow for large caches)
setTimeout(() => {
if (pendingRequests.current.has("CACHE_ENTRIES")) {
pendingRequests.current.delete("CACHE_ENTRIES");
resolve(null);
}
}, 10000);
});
},
[]
);
const clearCache = useCallback(async (cacheType: CacheType = "all"): Promise<boolean> => {
if (!navigator.serviceWorker.controller) return false;
return new Promise((resolve) => {
pendingRequests.current.set("CACHE_CLEARED", resolve as (value: unknown) => void);
navigator.serviceWorker.controller!.postMessage({
type: "CLEAR_CACHE",
payload: { cacheType },
});
// Timeout after 10 seconds
setTimeout(() => {
if (pendingRequests.current.has("CACHE_CLEARED")) {
pendingRequests.current.delete("CACHE_CLEARED");
resolve(false);
}
}, 10000);
});
}, []);
const skipWaiting = useCallback(() => {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "SKIP_WAITING" });
}
}, []);
const reloadForUpdate = useCallback(() => {
if (waitingWorkerRef.current) {
waitingWorkerRef.current.postMessage({ type: "SKIP_WAITING" });
setHasNewVersion(false);
// Reload will happen automatically when new SW takes control
window.location.reload();
}
}, []);
const reinstallServiceWorker = useCallback(async (): Promise<boolean> => {
try {
// Unregister all service workers
await unregisterServiceWorker();
setIsReady(false);
setVersion(null);
// Re-register
const registration = await registerServiceWorker({
onSuccess: () => {
setIsReady(true);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
}
},
onError: (error) => {
logger.error({ err: error }, "Service worker re-registration failed");
},
});
if (registration) {
// Force update check
await registration.update();
// Reload page to ensure new SW takes control
window.location.reload();
return true;
}
return false;
} catch (error) {
logger.error({ err: error }, "Failed to reinstall service worker");
return false;
}
}, []);
return (
<ServiceWorkerContext.Provider
value={{
isSupported,
isReady,
version,
hasNewVersion,
cacheUpdates,
clearCacheUpdate,
clearAllCacheUpdates,
getCacheStats,
getCacheEntries,
clearCache,
skipWaiting,
reloadForUpdate,
reinstallServiceWorker,
}}
>
{children}
</ServiceWorkerContext.Provider>
);
}
export function useServiceWorker() {
const context = useContext(ServiceWorkerContext);
if (!context) {
throw new Error("useServiceWorker must be used within a ServiceWorkerProvider");
}
return context;
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useMemo, useCallback } from "react";
import { useServiceWorker } from "@/contexts/ServiceWorkerContext";
interface UseCacheUpdateOptions {
/** Match exact URL or use pattern matching */
exact?: boolean;
}
interface UseCacheUpdateResult {
/** Whether there's a pending update for this URL pattern */
hasUpdate: boolean;
/** Timestamp of the last update */
lastUpdateTime: number | null;
/** Clear the update notification for this URL */
clearUpdate: () => void;
/** All matching updates */
updates: Array<{ url: string; timestamp: number }>;
}
/**
* Hook to listen for cache updates from the service worker
*
* @param urlPattern - URL or pattern to match against cache updates
* @param options - Options for matching behavior
*
* @example
* // Match exact URL
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/home', { exact: true });
*
* @example
* // Match URL pattern (contains)
* const { hasUpdate, clearUpdate } = useCacheUpdate('/api/komga/series');
*
* @example
* // Use in component
* useEffect(() => {
* if (hasUpdate) {
* refetch();
* clearUpdate();
* }
* }, [hasUpdate, refetch, clearUpdate]);
*/
export function useCacheUpdate(
urlPattern: string,
options: UseCacheUpdateOptions = {}
): UseCacheUpdateResult {
const { exact = false } = options;
const { cacheUpdates, clearCacheUpdate } = useServiceWorker();
const matchingUpdates = useMemo(() => {
return cacheUpdates.filter((update) => {
if (exact) {
return update.url === urlPattern || update.url.endsWith(urlPattern);
}
return update.url.includes(urlPattern);
});
}, [cacheUpdates, urlPattern, exact]);
const hasUpdate = matchingUpdates.length > 0;
const lastUpdateTime = useMemo(() => {
if (matchingUpdates.length === 0) return null;
return Math.max(...matchingUpdates.map((u) => u.timestamp));
}, [matchingUpdates]);
const clearUpdate = useCallback(() => {
matchingUpdates.forEach((update) => {
clearCacheUpdate(update.url);
});
}, [matchingUpdates, clearCacheUpdate]);
return {
hasUpdate,
lastUpdateTime,
clearUpdate,
updates: matchingUpdates,
};
}
/**
* Hook to check if any cache update is available
* Useful for showing a global "refresh available" indicator
*/
export function useAnyCacheUpdate(): {
hasAnyUpdate: boolean;
updateCount: number;
clearAll: () => void;
} {
const { cacheUpdates, clearAllCacheUpdates } = useServiceWorker();
return {
hasAnyUpdate: cacheUpdates.length > 0,
updateCount: cacheUpdates.length,
clearAll: clearAllCacheUpdates,
};
}

View File

@@ -1,14 +0,0 @@
import { useImageCache } from "@/contexts/ImageCacheContext";
import { useMemo } from "react";
/**
* Hook pour obtenir une URL d'image avec cache busting
* Ajoute automatiquement ?v={cacheVersion} à l'URL
*/
export function useImageUrl(baseUrl: string): string {
const { getImageUrl } = useImageCache();
return useMemo(() => {
return getImageUrl(baseUrl);
}, [baseUrl, getImageUrl]);
}

View File

@@ -69,8 +69,7 @@
"title": "Preferences", "title": "Preferences",
"tabs": { "tabs": {
"display": "Display", "display": "Display",
"connection": "Connection", "connection": "Connection"
"cache": "Cache"
}, },
"display": { "display": {
"title": "Display Preferences", "title": "Display Preferences",
@@ -106,31 +105,9 @@
"title": "Advanced Settings", "title": "Advanced Settings",
"description": "Configure advanced performance and reliability settings.", "description": "Configure advanced performance and reliability settings.",
"save": "Save settings", "save": "Save settings",
"maxConcurrentRequests": {
"label": "Max Concurrent Requests",
"description": "Maximum number of simultaneous requests to Komga server (1-10)"
},
"prefetchCount": { "prefetchCount": {
"label": "Reader Prefetch Count", "label": "Reader Prefetch Count",
"description": "Number of pages to preload in the reader (0-20)" "description": "Number of pages to preload in the reader (0-20)"
},
"circuitBreaker": {
"title": "Circuit Breaker",
"description": "Automatic protection against server overload",
"threshold": {
"label": "Failure Threshold",
"description": "Number of consecutive failures before opening the circuit (1-20)"
},
"timeout": {
"label": "Request Timeout",
"description": "Maximum wait time for a request before considering it failed",
"unit": "milliseconds (1000ms = 1 second)"
},
"resetTimeout": {
"label": "Reset Timeout",
"description": "Time to wait before attempting to close the circuit",
"unit": "milliseconds (1000ms = 1 second)"
}
} }
}, },
"error": { "error": {
@@ -161,73 +138,34 @@
} }
}, },
"cache": { "cache": {
"title": "Cache Configuration", "title": "Cache & Storage",
"description": "Manage data caching settings.", "description": "Manage local cache for optimal offline experience.",
"mode": { "notSupported": "Offline cache is not supported by your browser.",
"label": "Cache mode", "initializing": "Initializing...",
"description": "Memory cache is faster but doesn't persist between restarts" "totalStorage": "Total storage",
}, "imagesQuota": "{used}% of images quota used",
"size": { "static": "Static resources",
"title": "Cache size", "staticDesc": "Next.js scripts, styles and assets",
"server": "Server cache", "pages": "Visited pages",
"serviceWorker": "SW cache (total)", "pagesDesc": "Home, libraries, series and details",
"api": "API cache (data)", "api": "API data",
"items": "{count} item(s)", "apiDesc": "Series, books and library metadata",
"loading": "Loading...", "images": "Images",
"error": "Error loading" "imagesDesc": "Covers and thumbnails (100 MB limit)",
}, "books": "Offline books",
"ttl": { "booksDesc": "Manually downloaded pages",
"default": "Default TTL (minutes)", "clearAll": "Clear all cache",
"home": "Home page TTL", "cleared": "Cache cleared",
"libraries": "Libraries TTL", "clearedDesc": "Cache has been cleared successfully",
"series": "Series TTL", "clearError": "Error clearing cache",
"books": "Books TTL", "unavailable": "Cache statistics unavailable",
"images": "Images TTL", "reinstall": "Reinstall Service Worker",
"imageCacheMaxAge": { "reinstallError": "Error reinstalling Service Worker",
"label": "HTTP Image Cache Duration", "entry": "entry",
"description": "Duration for images to be cached in the browser", "entries": "entries",
"options": { "loadingEntries": "Loading entries...",
"noCache": "No cache (0s)", "noEntries": "No entries in this cache",
"oneHour": "1 hour (3600s)", "loadError": "Error loading entries"
"oneDay": "1 day (86400s)",
"oneWeek": "1 week (604800s)",
"oneMonth": "1 month (2592000s) - Recommended",
"oneYear": "1 year (31536000s)"
}
}
},
"buttons": {
"saveTTL": "Save TTL",
"clear": "Clear cache",
"clearing": "Clearing...",
"clearServiceWorker": "Clear service worker cache",
"clearingServiceWorker": "Clearing service worker cache...",
"flushImageCache": "Force reload images"
},
"messages": {
"ttlSaved": "TTL configuration saved successfully",
"cleared": "Server cache cleared successfully",
"serviceWorkerCleared": "Service worker cache cleared successfully",
"imageCacheFlushed": "Images will be reloaded - refresh the page"
},
"error": {
"title": "Error clearing cache",
"message": "An error occurred while clearing the cache",
"ttl": "Error saving TTL configuration",
"serviceWorkerMessage": "An error occurred while clearing the service worker cache"
},
"entries": {
"title": "Cache content preview",
"serverTitle": "Server cache preview",
"serviceWorkerTitle": "Service worker cache preview",
"loading": "Loading entries...",
"empty": "No entries in cache",
"expired": "Expired",
"daysRemaining": "{count} day(s) remaining",
"hoursRemaining": "{count} hour(s) remaining",
"minutesRemaining": "{count} minute(s) remaining",
"lessThanMinute": "Less than a minute"
}
} }
}, },
"library": { "library": {
@@ -463,14 +401,6 @@
"PREFERENCES_UPDATE_ERROR": "Error updating preferences", "PREFERENCES_UPDATE_ERROR": "Error updating preferences",
"PREFERENCES_CONTEXT_ERROR": "Preferences context error", "PREFERENCES_CONTEXT_ERROR": "Preferences context error",
"CACHE_DELETE_ERROR": "Error deleting cache",
"CACHE_SAVE_ERROR": "Error saving cache",
"CACHE_LOAD_ERROR": "Error loading cache",
"CACHE_CLEAR_ERROR": "Error clearing cache",
"CACHE_MODE_FETCH_ERROR": "Error fetching cache mode",
"CACHE_MODE_UPDATE_ERROR": "Error updating cache mode",
"CACHE_INVALID_MODE": "Invalid cache mode",
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs", "UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
"UI_TABS_CONTENT_ERROR": "Error loading tabs content", "UI_TABS_CONTENT_ERROR": "Error loading tabs content",

View File

@@ -69,8 +69,7 @@
"title": "Préférences", "title": "Préférences",
"tabs": { "tabs": {
"display": "Affichage", "display": "Affichage",
"connection": "Connexion", "connection": "Connexion"
"cache": "Cache"
}, },
"display": { "display": {
"title": "Préférences d'affichage", "title": "Préférences d'affichage",
@@ -106,31 +105,9 @@
"title": "Paramètres avancés", "title": "Paramètres avancés",
"description": "Configurez les paramètres avancés de performance et de fiabilité.", "description": "Configurez les paramètres avancés de performance et de fiabilité.",
"save": "Enregistrer les paramètres", "save": "Enregistrer les paramètres",
"maxConcurrentRequests": {
"label": "Requêtes simultanées max",
"description": "Nombre maximum de requêtes simultanées vers le serveur Komga (1-10)"
},
"prefetchCount": { "prefetchCount": {
"label": "Préchargement du lecteur", "label": "Préchargement du lecteur",
"description": "Nombre de pages à précharger dans le lecteur (0-20)" "description": "Nombre de pages à précharger dans le lecteur (0-20)"
},
"circuitBreaker": {
"title": "Disjoncteur",
"description": "Protection automatique contre la surcharge du serveur",
"threshold": {
"label": "Seuil d'échec",
"description": "Nombre d'échecs consécutifs avant ouverture du circuit (1-20)"
},
"timeout": {
"label": "Délai d'expiration",
"description": "Temps d'attente maximum pour une requête avant de la considérer comme échouée",
"unit": "millisecondes (1000ms = 1 seconde)"
},
"resetTimeout": {
"label": "Délai de réinitialisation",
"description": "Temps d'attente avant de tenter de fermer le circuit",
"unit": "millisecondes (1000ms = 1 seconde)"
}
} }
}, },
"error": { "error": {
@@ -161,73 +138,34 @@
} }
}, },
"cache": { "cache": {
"title": "Configuration du Cache", "title": "Cache et stockage",
"description": "Gérez les paramètres de mise en cache des données.", "description": "Gérez le cache local pour une expérience hors-ligne optimale.",
"mode": { "notSupported": "Le cache hors-ligne n'est pas supporté par votre navigateur.",
"label": "Mode de cache", "initializing": "Initialisation...",
"description": "Le cache en mémoire est plus rapide mais ne persiste pas entre les redémarrages" "totalStorage": "Stockage total",
}, "imagesQuota": "{used}% du quota images utilisé",
"size": { "static": "Ressources statiques",
"title": "Taille du cache", "staticDesc": "Scripts, styles et assets Next.js",
"server": "Cache serveur", "pages": "Pages visitées",
"serviceWorker": "Cache SW (total)", "pagesDesc": "Home, bibliothèques, séries et détails",
"api": "Cache API (données)", "api": "Données API",
"items": "{count} élément(s)", "apiDesc": "Métadonnées des séries, livres et bibliothèques",
"loading": "Chargement...", "images": "Images",
"error": "Erreur lors du chargement" "imagesDesc": "Couvertures et vignettes (limite 100 Mo)",
}, "books": "Livres hors-ligne",
"ttl": { "booksDesc": "Pages téléchargées manuellement",
"default": "TTL par défaut (minutes)", "clearAll": "Vider tout le cache",
"home": "TTL page d'accueil", "cleared": "Cache vidé",
"libraries": "TTL bibliothèques", "clearedDesc": "Le cache a été vidé avec succès",
"series": "TTL séries", "clearError": "Erreur lors du vidage du cache",
"books": "TTL tomes", "unavailable": "Statistiques du cache non disponibles",
"images": "TTL images", "reinstall": "Réinstaller le Service Worker",
"imageCacheMaxAge": { "reinstallError": "Erreur lors de la réinstallation du Service Worker",
"label": "Durée du cache HTTP des images", "entry": "entrée",
"description": "Durée de conservation des images dans le cache du navigateur", "entries": "entrées",
"options": { "loadingEntries": "Chargement des entrées...",
"noCache": "Aucun cache (0s)", "noEntries": "Aucune entrée dans ce cache",
"oneHour": "1 heure (3600s)", "loadError": "Erreur lors du chargement des entrées"
"oneDay": "1 jour (86400s)",
"oneWeek": "1 semaine (604800s)",
"oneMonth": "1 mois (2592000s) - Recommandé",
"oneYear": "1 an (31536000s)"
}
}
},
"buttons": {
"saveTTL": "Sauvegarder les TTL",
"clear": "Vider le cache",
"clearing": "Suppression...",
"clearServiceWorker": "Vider le cache du service worker",
"clearingServiceWorker": "Suppression du cache service worker...",
"flushImageCache": "Forcer le rechargement des images"
},
"messages": {
"ttlSaved": "La configuration des TTL a été sauvegardée avec succès",
"cleared": "Cache serveur supprimé avec succès",
"serviceWorkerCleared": "Cache du service worker supprimé avec succès",
"imageCacheFlushed": "Les images seront rechargées - rafraîchissez la page"
},
"error": {
"title": "Erreur lors de la suppression du cache",
"message": "Une erreur est survenue lors de la suppression du cache",
"ttl": "Erreur lors de la sauvegarde de la configuration TTL",
"serviceWorkerMessage": "Une erreur est survenue lors de la suppression du cache du service worker"
},
"entries": {
"title": "Aperçu du contenu du cache",
"serverTitle": "Aperçu du cache serveur",
"serviceWorkerTitle": "Aperçu du cache service worker",
"loading": "Chargement des entrées...",
"empty": "Aucune entrée dans le cache",
"expired": "Expiré",
"daysRemaining": "{count} jour(s) restant(s)",
"hoursRemaining": "{count} heure(s) restante(s)",
"minutesRemaining": "{count} minute(s) restante(s)",
"lessThanMinute": "Moins d'une minute"
}
} }
}, },
"library": { "library": {
@@ -461,14 +399,6 @@
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences", "PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences", "PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
"CACHE_DELETE_ERROR": "Erreur lors de la suppression du cache",
"CACHE_SAVE_ERROR": "Erreur lors de la sauvegarde du cache",
"CACHE_LOAD_ERROR": "Erreur lors du chargement du cache",
"CACHE_CLEAR_ERROR": "Erreur lors de la suppression du cache",
"CACHE_MODE_FETCH_ERROR": "Erreur lors de la récupération du mode de cache",
"CACHE_MODE_UPDATE_ERROR": "Erreur lors de la mise à jour du mode de cache",
"CACHE_INVALID_MODE": "Mode de cache invalide",
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets", "UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets", "UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",

View File

@@ -1,14 +1,137 @@
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const registerServiceWorker = async () => { interface ServiceWorkerRegistrationOptions {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onError?: (error: Error) => void;
}
/**
* Register the service worker with optional callbacks for update and success events
*/
export const registerServiceWorker = async (
options: ServiceWorkerRegistrationOptions = {}
): Promise<ServiceWorkerRegistration | null> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) { if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return; return null;
}
const { onUpdate, onSuccess, onError } = options;
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
// Check for updates immediately
registration.update().catch(() => {
// Ignore update check errors
});
// Handle updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// New service worker available
logger.info("New service worker available");
onUpdate?.(registration);
} else {
// First install
logger.info("Service worker installed for the first time");
onSuccess?.(registration);
}
}
});
});
// If already active, call success
if (registration.active) {
onSuccess?.(registration);
}
return registration;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ err }, "Service Worker registration failed");
onError?.(err);
return null;
}
};
/**
* Unregister all service workers
*/
export const unregisterServiceWorker = async (): Promise<boolean> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return false;
} }
try { try {
await navigator.serviceWorker.register("/sw.js"); const registrations = await navigator.serviceWorker.getRegistrations();
// logger.info("Service Worker registered with scope:", registration.scope); await Promise.all(registrations.map((reg) => reg.unregister()));
logger.info("All service workers unregistered");
return true;
} catch (error) { } catch (error) {
logger.error({ err: error }, "Service Worker registration failed:"); logger.error({ err: error }, "Failed to unregister service workers");
return false;
}
};
/**
* Send a message to the active service worker
*/
export const sendMessageToSW = <T = unknown>(message: unknown): Promise<T | null> => {
return new Promise((resolve) => {
if (!navigator.serviceWorker.controller) {
resolve(null);
return;
}
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
resolve(event.data as T);
};
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
// Timeout after 5 seconds
setTimeout(() => {
resolve(null);
}, 5000);
});
};
/**
* Check if the app is running as a PWA (standalone mode)
*/
export const isPWA = (): boolean => {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari
("standalone" in window.navigator &&
(window.navigator as { standalone?: boolean }).standalone === true)
);
};
/**
* Get the current service worker registration
*/
export const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return null;
}
try {
return await navigator.serviceWorker.ready;
} catch {
return null;
} }
}; };

View File

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

View File

@@ -1,47 +1,28 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga"; import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { SeriesService } from "./series.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import logger from "@/lib/logger";
export class BookService extends BaseApiService { export class BookService extends BaseApiService {
private static async getImageCacheMaxAge(): Promise<number> {
try {
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
return maxAge;
} catch (error) {
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
return 2592000; // 30 jours par défaut en cas d'erreur
}
}
static async getBook(bookId: string): Promise<KomgaBookWithPages> { static async getBook(bookId: string): Promise<KomgaBookWithPages> {
try { try {
return this.fetchWithCache<KomgaBookWithPages>( // Récupération parallèle des détails du tome et des pages
`book-${bookId}`, const [book, pages] = await Promise.all([
async () => { this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
// Récupération parallèle des détails du tome et des pages this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
const [book, pages] = await Promise.all([ ]);
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
]);
return { return {
book, book,
pages: pages.map((page: any) => page.number), pages: pages.map((page: any) => page.number),
}; };
},
"BOOKS"
);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error); throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
} }
} }
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> { public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
try { try {
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant // Utiliser l'endpoint natif Komga pour obtenir le livre suivant
@@ -63,7 +44,6 @@ export class BookService extends BaseApiService {
static async getBookSeriesId(bookId: string): Promise<string> { static async getBookSeriesId(bookId: string): Promise<string> {
try { try {
// Récupérer le livre sans cache pour éviter les données obsolètes
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }); const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
return book.seriesId; return book.seriesId;
} catch (error) { } catch (error) {
@@ -126,24 +106,10 @@ export class BookService extends BaseApiService {
try { try {
// Ajuster le numéro de page pour l'API Komga (zero-based) // Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber - 1; const adjustedPageNumber = pageNumber - 1;
const response: ImageResponse = await ImageService.getImage( // Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true` `books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
); );
// Convertir le Buffer Node.js en ArrayBuffer proprement
const arrayBuffer = response.buffer.buffer.slice(
response.buffer.byteOffset,
response.buffer.byteOffset + response.buffer.byteLength
) as ArrayBuffer;
const maxAge = await this.getImageCacheMaxAge();
return new Response(arrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
} }
@@ -153,20 +119,13 @@ export class BookService extends BaseApiService {
try { try {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences(); const preferences = await PreferencesService.getPreferences();
const maxAge = await this.getImageCacheMaxAge();
// Si l'utilisateur préfère les vignettes, utiliser la miniature // Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
if (preferences.showThumbnails) { if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`); return ImageService.streamImage(`books/${bookId}/thumbnail`);
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} }
// Sinon, récupérer la première page // Sinon, récupérer la première page (streaming)
return this.getPage(bookId, 1); return this.getPage(bookId, 1);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
@@ -183,17 +142,10 @@ export class BookService extends BaseApiService {
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> { static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
try { try {
const response: ImageResponse = await ImageService.getImage( // Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true` `books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
); );
const maxAge = await this.getImageCacheMaxAge();
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
} }
@@ -211,32 +163,53 @@ export class BookService extends BaseApiService {
}); });
} }
const { LibraryService } = await import("./library.service"); // Use books/list directly with library filter to avoid extra series/list call
// Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length); const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
const randomLibraryId = libraryIds[randomLibraryIndex]; const randomLibraryId = libraryIds[randomLibraryIndex];
// Récupérer juste une page de séries (pas toutes) // Random page offset for variety (assuming most libraries have at least 100 books)
const seriesResponse = await LibraryService.getLibrarySeries(randomLibraryId, 0, 20); const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
if (seriesResponse.content.length === 0) { const searchBody = {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { condition: {
message: "Aucune série trouvée dans les bibliothèques sélectionnées", libraryId: {
}); operator: "is",
} value: randomLibraryId,
},
},
};
// Choisir une série au hasard parmi celles récupérées const booksResponse = await this.fetchFromApi<{
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length); content: KomgaBook[];
const randomSeries = seriesResponse.content[randomSeriesIndex]; totalElements: number;
}>(
// Récupérer les books de cette série avec pagination {
const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100); path: "books/list",
params: { page: String(randomPage), size: "20", sort: "number,asc" },
},
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (booksResponse.content.length === 0) { if (booksResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { // Fallback to page 0 if random page was empty
message: "Aucun livre trouvé dans la série", const fallbackResponse = await this.fetchFromApi<{
}); content: KomgaBook[];
totalElements: number;
}>(
{ path: "books/list", params: { page: "0", size: "20", sort: "number,asc" } },
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (fallbackResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
});
}
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
return fallbackResponse.content[randomBookIndex].id;
} }
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length); const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);

View File

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

View File

@@ -2,30 +2,52 @@ import type { KomgaBook } from "@/types/komga";
export class ClientOfflineBookService { export class ClientOfflineBookService {
static setCurrentPage(book: KomgaBook, page: number) { static setCurrentPage(book: KomgaBook, page: number) {
localStorage.setItem(`${book.id}-page`, page.toString()); if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
try {
localStorage.setItem(`${book.id}-page`, page.toString());
} catch {
// Ignore localStorage errors in SSR
}
}
} }
static getCurrentPage(book: KomgaBook) { static getCurrentPage(book: KomgaBook) {
const readProgressPage = book.readProgress?.page || 0; const readProgressPage = book.readProgress?.page || 0;
if (typeof localStorage !== "undefined") { if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0"; try {
const currentPage = parseInt(cPageLS); const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
const currentPage = parseInt(cPageLS);
if (currentPage < readProgressPage) { if (currentPage < readProgressPage) {
return readProgressPage;
}
return currentPage;
} catch {
return readProgressPage; return readProgressPage;
} }
return currentPage;
} else { } else {
return readProgressPage; return readProgressPage;
} }
} }
static removeCurrentPage(book: KomgaBook) { static removeCurrentPage(book: KomgaBook) {
localStorage.removeItem(`${book.id}-page`); if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
try {
localStorage.removeItem(`${book.id}-page`);
} catch {
// Ignore localStorage errors in SSR
}
}
} }
static removeCurrentPageById(bookId: string) { static removeCurrentPageById(bookId: string) {
localStorage.removeItem(`${bookId}-page`); if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
try {
localStorage.removeItem(`${bookId}-page`);
} catch {
// Ignore localStorage errors in SSR
}
}
} }
} }

View File

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

View File

@@ -0,0 +1,39 @@
import { FavoriteService } from "./favorite.service";
import { SeriesService } from "./series.service";
import type { KomgaSeries } from "@/types/komga";
import logger from "@/lib/logger";
export class FavoritesService {
static async getFavorites(): Promise<KomgaSeries[]> {
try {
const favoriteIds = await FavoriteService.getAllFavoriteIds();
if (favoriteIds.length === 0) {
return [];
}
// Fetch toutes les séries en parallèle
const promises = favoriteIds.map(async (id: string) => {
try {
return await SeriesService.getSeries(id);
} catch (error) {
logger.error({ err: error, seriesId: id }, "Error fetching favorite series");
// Si la série n'existe plus, la retirer des favoris
try {
await FavoriteService.removeFromFavorites(id);
} catch {
// Ignore cleanup errors
}
return null;
}
});
const results = await Promise.all(promises);
return results.filter((series): series is KomgaSeries => series !== null);
} catch (error) {
logger.error({ err: error }, "Error fetching favorites");
return [];
}
}
}

View File

@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { HomeData } from "@/types/home"; import type { HomeData } from "@/types/home";
import { getServerCacheService } from "./server-cache.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
@@ -12,80 +11,55 @@ export class HomeService extends BaseApiService {
static async getHomeData(): Promise<HomeData> { static async getHomeData(): Promise<HomeData> {
try { try {
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([ const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetchWithCache<LibraryResponse<KomgaSeries>>( this.fetchFromApi<LibraryResponse<KomgaSeries>>({
"home-ongoing", path: "series",
async () => params: {
this.fetchFromApi<LibraryResponse<KomgaSeries>>({ read_status: "IN_PROGRESS",
path: "series", sort: "readDate,desc",
params: { page: "0",
read_status: "IN_PROGRESS", size: "10",
sort: "readDate,desc", media_status: "READY",
page: "0", },
size: "10", }),
media_status: "READY", this.fetchFromApi<LibraryResponse<KomgaBook>>({
}, path: "books",
}), params: {
"HOME" read_status: "IN_PROGRESS",
), sort: "readProgress.readDate,desc",
this.fetchWithCache<LibraryResponse<KomgaBook>>( page: "0",
"home-ongoing-books", size: "10",
async () => media_status: "READY",
this.fetchFromApi<LibraryResponse<KomgaBook>>({ },
path: "books", }),
params: { this.fetchFromApi<LibraryResponse<KomgaBook>>({
read_status: "IN_PROGRESS", path: "books/latest",
sort: "readProgress.readDate,desc", params: {
page: "0", page: "0",
size: "10", size: "10",
media_status: "READY", media_status: "READY",
}, },
}), }),
"HOME" this.fetchFromApi<LibraryResponse<KomgaBook>>({
), path: "books/ondeck",
this.fetchWithCache<LibraryResponse<KomgaBook>>( params: {
"home-recently-read", page: "0",
async () => size: "10",
this.fetchFromApi<LibraryResponse<KomgaBook>>({ media_status: "READY",
path: "books/latest", },
params: { }),
page: "0", this.fetchFromApi<LibraryResponse<KomgaSeries>>({
size: "10", path: "series/latest",
media_status: "READY", params: {
}, page: "0",
}), size: "10",
"HOME" media_status: "READY",
), },
this.fetchWithCache<LibraryResponse<KomgaBook>>( }),
"home-on-deck",
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>({
path: "books/ondeck",
params: {
page: "0",
size: "10",
media_status: "READY",
},
}),
"HOME"
),
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
"home-latest-series",
async () =>
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
path: "series/latest",
params: {
page: "0",
size: "10",
media_status: "READY",
},
}),
"HOME"
),
]); ]);
return { return {
ongoing: ongoing.content || [], ongoing: ongoing.content || [],
ongoingBooks: ongoingBooks.content || [], // Nouveau champ ongoingBooks: ongoingBooks.content || [],
recentlyRead: recentlyRead.content || [], recentlyRead: recentlyRead.content || [],
onDeck: onDeck.content || [], onDeck: onDeck.content || [],
latestSeries: latestSeries.content || [], latestSeries: latestSeries.content || [],
@@ -97,17 +71,4 @@ export class HomeService extends BaseApiService {
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
} }
} }
static async invalidateHomeCache(): Promise<void> {
try {
const cacheService = await getServerCacheService();
await cacheService.delete("home-ongoing");
await cacheService.delete("home-ongoing-books"); // Nouvelle clé de cache
await cacheService.delete("home-recently-read");
await cacheService.delete("home-on-deck");
await cacheService.delete("home-latest-series");
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
} }

View File

@@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export interface ImageResponse { // Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
buffer: Buffer; const IMAGE_CACHE_MAX_AGE = 2592000;
contentType: string | null;
}
export class ImageService extends BaseApiService { export class ImageService extends BaseApiService {
static async getImage(path: string): Promise<ImageResponse> { /**
* Stream an image directly from Komga without buffering in memory
* Returns a Response that can be directly returned to the client
*/
static async streamImage(
path: string,
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
): Promise<Response> {
try { try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" }; const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
// NE PAS mettre en cache - les images sont trop grosses et les Buffers ne sérialisent pas bien
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true }); const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
const contentType = response.headers.get("content-type");
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return { // Stream the response body directly without buffering
buffer, return new Response(response.body, {
contentType, status: response.status,
}; headers: {
"Content-Type": response.headers.get("content-type") || "image/jpeg",
"Content-Length": response.headers.get("content-length") || "",
"Cache-Control": `public, max-age=${cacheMaxAge}, immutable`,
},
});
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de l'image"); logger.error({ err: error }, "Erreur lors du streaming de l'image");
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
} }
} }

View File

@@ -1,19 +1,51 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series"; import type { Series } from "@/types/series";
import { getServerCacheService } from "./server-cache.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import type { KomgaLibrary } from "@/types/komga"; import type { KomgaLibrary } from "@/types/komga";
// Raw library type from Komga API (without booksCount)
interface KomgaLibraryRaw {
id: string;
name: string;
root: string;
unavailable: boolean;
}
export class LibraryService extends BaseApiService { export class LibraryService extends BaseApiService {
static async getLibraries(): Promise<KomgaLibrary[]> { static async getLibraries(): Promise<KomgaLibrary[]> {
try { try {
return this.fetchWithCache<KomgaLibrary[]>( const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>({ path: "libraries" });
"libraries",
async () => this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" }), // Enrich each library with book counts
"LIBRARIES" const enrichedLibraries = await Promise.all(
libraries.map(async (library) => {
try {
const booksResponse = await this.fetchFromApi<{ totalElements: number }>({
path: "books",
params: { library_id: library.id, size: "0" },
});
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: booksResponse.totalElements,
booksReadCount: 0,
} as KomgaLibrary;
} catch {
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: 0,
booksReadCount: 0,
} as KomgaLibrary;
}
})
); );
return enrichedLibraries;
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
} }
@@ -21,12 +53,7 @@ export class LibraryService extends BaseApiService {
static async getLibrary(libraryId: string): Promise<KomgaLibrary> { static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
try { try {
const libraries = await this.getLibraries(); return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
const library = libraries.find((library) => library.id === libraryId);
if (!library) {
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, { libraryId });
}
return library;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
throw error; throw error;
@@ -87,35 +114,24 @@ export class LibraryService extends BaseApiService {
const searchBody = { condition }; const searchBody = { condition };
// Clé de cache incluant tous les paramètres const params: Record<string, string | string[]> = {
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${ page: String(page),
search || "" size: String(size),
}`; sort: "metadata.titleSort,asc",
};
const response = await this.fetchWithCache<LibraryResponse<Series>>( // Filtre de recherche Komga (recherche dans le titre)
cacheKey, if (search) {
async () => { params.search = search;
const params: Record<string, string | string[]> = { }
page: String(page),
size: String(size),
sort: "metadata.titleSort,asc",
};
// Filtre de recherche Komga (recherche dans le titre) const response = await this.fetchFromApi<LibraryResponse<Series>>(
if (search) { { path: "series/list", params },
params.search = search; headers,
} {
method: "POST",
return this.fetchFromApi<LibraryResponse<Series>>( body: JSON.stringify(searchBody),
{ path: "series/list", params }, }
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
}
);
},
"SERIES"
); );
// Filtrer uniquement les séries supprimées côté client (léger) // Filtrer uniquement les séries supprimées côté client (léger)
@@ -131,17 +147,6 @@ export class LibraryService extends BaseApiService {
} }
} }
static async invalidateLibrarySeriesCache(libraryId: string): Promise<void> {
try {
const cacheService = await getServerCacheService();
// Invalider toutes les clés de cache pour cette bibliothèque
// Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search}
await cacheService.deleteAll(`library-${libraryId}-series-`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> { static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
try { try {
await this.fetchFromApi( await this.fetchFromApi(

View File

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

View File

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

View File

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

View File

@@ -1,50 +1,23 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries, TTLConfig } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { BookService } from "./book.service"; import { BookService } from "./book.service";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { getServerCacheService } from "./server-cache.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import type { ServerCacheService } from "./server-cache.service";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export class SeriesService extends BaseApiService { export class SeriesService extends BaseApiService {
private static async getImageCacheMaxAge(): Promise<number> {
try {
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
return maxAge;
} catch (error) {
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
return 2592000; // 30 jours par défaut en cas d'erreur
}
}
static async getSeries(seriesId: string): Promise<KomgaSeries> { static async getSeries(seriesId: string): Promise<KomgaSeries> {
try { try {
return this.fetchWithCache<KomgaSeries>( return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
`series-${seriesId}`,
async () => this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` }),
"SERIES"
);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
} }
} }
static async invalidateSeriesCache(seriesId: string): Promise<void> {
try {
const cacheService = await getServerCacheService();
await cacheService.delete(`series-${seriesId}`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async getSeriesBooks( static async getSeriesBooks(
seriesId: string, seriesId: string,
page: number = 0, page: number = 0,
@@ -96,28 +69,19 @@ export class SeriesService extends BaseApiService {
const searchBody = { condition }; const searchBody = { condition };
// Clé de cache incluant tous les paramètres const params: Record<string, string | string[]> = {
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`; page: String(page),
size: String(size),
sort: "number,asc",
};
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>( const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
cacheKey, { path: "books/list", params },
async () => { headers,
const params: Record<string, string | string[]> = { {
page: String(page), method: "POST",
size: String(size), body: JSON.stringify(searchBody),
sort: "number,asc", }
};
return this.fetchFromApi<LibraryResponse<KomgaBook>>(
{ path: "books/list", params },
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
}
);
},
"BOOKS"
); );
// Filtrer uniquement les livres supprimés côté client (léger) // Filtrer uniquement les livres supprimés côté client (léger)
@@ -133,36 +97,17 @@ export class SeriesService extends BaseApiService {
} }
} }
static async invalidateSeriesBooksCache(seriesId: string): Promise<void> {
try {
const cacheService: ServerCacheService = await getServerCacheService();
// Invalider toutes les clés de cache pour cette série
// Format: series-{id}-books-p{page}-s{size}-u{unread}
await cacheService.deleteAll(`series-${seriesId}-books-`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}
}
static async getFirstBook(seriesId: string): Promise<string> { static async getFirstBook(seriesId: string): Promise<string> {
try { try {
return this.fetchWithCache<string>( const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
`series-first-book-${seriesId}`, path: `series/${seriesId}/books`,
async () => { params: { page: "0", size: "1" },
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi< });
LibraryResponse<KomgaBook> if (!data.content || data.content.length === 0) {
>({ throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
path: `series/${seriesId}/books`, }
params: { page: "0", size: "1" },
});
if (!data.content || data.content.length === 0) {
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
}
return data.content[0].id; return data.content[0].id;
},
"SERIES"
);
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération du premier livre"); logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
@@ -173,23 +118,15 @@ export class SeriesService extends BaseApiService {
try { try {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences(); const preferences: UserPreferences = await PreferencesService.getPreferences();
const maxAge = await this.getImageCacheMaxAge();
// Si l'utilisateur préfère les vignettes, utiliser la miniature // Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
if (preferences.showThumbnails) { if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`); return ImageService.streamImage(`series/${seriesId}/thumbnail`);
return new Response(response.buffer.buffer as ArrayBuffer, {
headers: {
"Content-Type": response.contentType || "image/jpeg",
"Cache-Control": `public, max-age=${maxAge}, immutable`,
},
});
} }
// Sinon, récupérer la première page // Sinon, récupérer la première page (streaming)
const firstBookId = await this.getFirstBook(seriesId); const firstBookId = await this.getFirstBook(seriesId);
const response = await BookService.getPage(firstBookId, 1); return BookService.getPage(firstBookId, 1);
return response;
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
} }

View File

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

View File

@@ -1,6 +1,5 @@
/** /**
* Génère l'URL de base pour une image (sans cache version) * Génère l'URL pour une image (thumbnail de série ou de livre)
* Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting
*/ */
export function getImageUrl(type: "series" | "book", id: string) { export function getImageUrl(type: "series" | "book", id: string) {
if (type === "series") { if (type === "series") {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config = { const config = {
darkMode: ["class"], darkMode: ["class"],
@@ -77,7 +78,7 @@ const config = {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [tailwindcssAnimate],
} satisfies Config; } satisfies Config;
export default config; export default config;

View File

@@ -23,5 +23,5 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "temp"]
} }