feat: perf optimisation
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
This commit is contained in:
@@ -1,401 +0,0 @@
|
||||
# Plan d'Optimisation des Performances - StripStream
|
||||
|
||||
## 🔴 Problèmes Identifiés
|
||||
|
||||
### Problème Principal : Pagination côté client au lieu de Komga
|
||||
|
||||
**Code actuel problématique :**
|
||||
|
||||
```typescript
|
||||
// library.service.ts - ligne 59
|
||||
size: "5000"; // Récupère TOUTES les séries d'un coup
|
||||
|
||||
// series.service.ts - ligne 69
|
||||
size: "1000"; // Récupère TOUS les livres d'un coup
|
||||
```
|
||||
|
||||
**Impact :**
|
||||
|
||||
- Charge massive en mémoire (stocker 5000 séries)
|
||||
- Temps de réponse longs (transfert de gros JSON)
|
||||
- Cache volumineux et inefficace
|
||||
- Pagination manuelle côté serveur Node.js
|
||||
|
||||
### Autres Problèmes
|
||||
|
||||
1. **TRIPLE cache conflictuel**
|
||||
|
||||
- **Service Worker** : Cache les données API dans `DATA_CACHE` avec SWR
|
||||
- **ServerCacheService** : Cache côté serveur avec SWR
|
||||
- **Headers HTTP** : `Cache-Control` sur les routes API
|
||||
- Comportements imprévisibles, données désynchronisées
|
||||
|
||||
2. **Clés de cache trop larges**
|
||||
|
||||
- `library-{id}-all-series` → stocke TOUT
|
||||
- Pas de clé par page/filtres
|
||||
|
||||
3. **Préférences rechargées à chaque requête**
|
||||
|
||||
- `PreferencesService.getPreferences()` fait une query DB à chaque fois
|
||||
- Pas de mise en cache des préférences
|
||||
|
||||
4. **ISR mal configuré**
|
||||
- `export const revalidate = 60` sur routes dynamiques
|
||||
- Conflit avec le cache serveur
|
||||
|
||||
---
|
||||
|
||||
## ✅ Plan de Développement
|
||||
|
||||
### Phase 1 : Pagination Native Komga (PRIORITÉ HAUTE)
|
||||
|
||||
- [x] **1.1 Refactorer `LibraryService.getLibrarySeries()`**
|
||||
|
||||
- Utiliser directement la pagination Komga
|
||||
- Endpoint: `POST /api/v1/series/list?page={page}&size={size}`
|
||||
- Supprimer `getAllLibrarySeries()` et le slice manuel
|
||||
- Passer les filtres (unread, search) directement à Komga
|
||||
|
||||
- [x] **1.2 Refactorer `SeriesService.getSeriesBooks()`**
|
||||
|
||||
- Utiliser directement la pagination Komga
|
||||
- Endpoint: `POST /api/v1/books/list?page={page}&size={size}`
|
||||
- Supprimer `getAllSeriesBooks()` et le slice manuel (gardée pour book.service.ts)
|
||||
|
||||
- [x] **1.3 Adapter les clés de cache**
|
||||
|
||||
- Clé incluant page + size + filtres
|
||||
- Format: `library-{id}-series-p{page}-s{size}-u{unread}-q{search}` ✅
|
||||
- Format: `series-{id}-books-p{page}-s{size}-u{unread}` ✅
|
||||
|
||||
- [x] **1.4 Mettre à jour les routes API**
|
||||
- `/api/komga/libraries/[libraryId]/series` ✅ (utilise déjà `LibraryService.getLibrarySeries()` refactoré)
|
||||
- `/api/komga/series/[seriesId]/books` ✅ (utilise déjà `SeriesService.getSeriesBooks()` refactoré)
|
||||
|
||||
### Phase 2 : Simplification du Cache (Triple → Simple)
|
||||
|
||||
**Objectif : Passer de 3 couches de cache à 1 seule (ServerCacheService)**
|
||||
|
||||
- [x] **2.1 Désactiver le cache SW pour les données API**
|
||||
|
||||
- Modifier `sw.js` : retirer le cache des routes `/api/komga/*` (sauf images)
|
||||
- Garder uniquement le cache SW pour : images, static, navigation
|
||||
- Le cache serveur suffit pour les données
|
||||
|
||||
- [x] **2.2 Supprimer les headers HTTP Cache-Control**
|
||||
|
||||
- Retirer `Cache-Control` des NextResponse dans les routes API
|
||||
- Évite les conflits avec le cache serveur
|
||||
- Note: Conservé pour les images de pages de livres (max-age=31536000)
|
||||
|
||||
- [x] **2.3 Supprimer `revalidate` des routes dynamiques**
|
||||
|
||||
- Routes API = dynamiques, pas besoin d'ISR
|
||||
- Le cache serveur suffit
|
||||
|
||||
- [x] **2.4 Optimiser les TTL ServerCacheService**
|
||||
- Réduire TTL des listes paginées (2 min) ✅
|
||||
- Garder TTL court pour les données avec progression (2 min) ✅
|
||||
- Garder TTL long pour les images (7 jours) ✅
|
||||
|
||||
**Résultat final :**
|
||||
|
||||
| Type de donnée | Cache utilisé | Stratégie |
|
||||
| ---------------- | ------------------ | ------------- |
|
||||
| Images | SW (IMAGES_CACHE) | Cache-First |
|
||||
| Static (\_next/) | SW (STATIC_CACHE) | Cache-First |
|
||||
| Données API | ServerCacheService | SWR |
|
||||
| Navigation | SW | Network-First |
|
||||
|
||||
### Phase 3 : Optimisation des Préférences
|
||||
|
||||
- [ ] **3.1 Cacher les préférences utilisateur**
|
||||
|
||||
- Créer `PreferencesService.getCachedPreferences()`
|
||||
- TTL court (1 minute)
|
||||
- Invalidation manuelle lors des modifications
|
||||
|
||||
- [ ] **3.2 Réduire les appels DB**
|
||||
- Grouper les appels de config Komga + préférences
|
||||
- Request-level caching (par requête HTTP)
|
||||
|
||||
### Phase 4 : Optimisation du Home
|
||||
|
||||
- [ ] **4.1 Paralléliser intelligemment les appels Komga**
|
||||
|
||||
- Les 5 appels sont déjà en parallèle ✅
|
||||
- Vérifier que le circuit breaker ne bloque pas
|
||||
|
||||
- [ ] **4.2 Réduire la taille des données Home**
|
||||
- Utiliser des projections (ne récupérer que les champs nécessaires)
|
||||
- Limiter à 10 items par section (déjà fait ✅)
|
||||
|
||||
### Phase 5 : Nettoyage et Simplification
|
||||
|
||||
- [ ] **5.1 Supprimer le code mort**
|
||||
|
||||
- `getAllLibrarySeries()` (après phase 1)
|
||||
- `getAllSeriesBooks()` (après phase 1)
|
||||
|
||||
- [ ] **5.2 Documenter la nouvelle architecture**
|
||||
|
||||
- Mettre à jour `docs/caching.md`
|
||||
- Documenter les nouvelles clés de cache
|
||||
|
||||
- [ ] **5.3 Ajouter des métriques**
|
||||
- Temps de réponse des requêtes Komga
|
||||
- Hit/Miss ratio du cache
|
||||
- Taille des payloads
|
||||
|
||||
---
|
||||
|
||||
## 📝 Implémentation Détaillée
|
||||
|
||||
### Phase 1.1 : Nouveau `LibraryService.getLibrarySeries()`
|
||||
|
||||
```typescript
|
||||
static async getLibrarySeries(
|
||||
libraryId: string,
|
||||
page: number = 0,
|
||||
size: number = 20,
|
||||
unreadOnly: boolean = false,
|
||||
search?: string
|
||||
): Promise<LibraryResponse<Series>> {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Construction du body de recherche pour Komga
|
||||
const condition: Record<string, any> = {
|
||||
libraryId: { operator: "is", value: libraryId },
|
||||
};
|
||||
|
||||
// Filtre unread natif Komga
|
||||
if (unreadOnly) {
|
||||
condition.readStatus = { operator: "is", value: "IN_PROGRESS" };
|
||||
// OU utiliser: complete: { operator: "is", value: false }
|
||||
}
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
// Clé de cache incluant tous les paramètres
|
||||
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${search || ''}`;
|
||||
|
||||
const response = await this.fetchWithCache<LibraryResponse<Series>>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const params: Record<string, string> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.titleSort,asc",
|
||||
};
|
||||
|
||||
// Filtre de recherche
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
return this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
);
|
||||
},
|
||||
"SERIES"
|
||||
);
|
||||
|
||||
// Filtrer les séries supprimées côté client (léger)
|
||||
response.content = response.content.filter((series) => !series.deleted);
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 1.2 : Nouveau `SeriesService.getSeriesBooks()`
|
||||
|
||||
```typescript
|
||||
static async getSeriesBooks(
|
||||
seriesId: string,
|
||||
page: number = 0,
|
||||
size: number = 24,
|
||||
unreadOnly: boolean = false
|
||||
): Promise<LibraryResponse<KomgaBook>> {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
const condition: Record<string, any> = {
|
||||
seriesId: { operator: "is", value: seriesId },
|
||||
};
|
||||
|
||||
if (unreadOnly) {
|
||||
condition.readStatus = { operator: "isNot", value: "READ" };
|
||||
}
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
|
||||
|
||||
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
cacheKey,
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/list",
|
||||
params: {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "number,asc",
|
||||
},
|
||||
},
|
||||
headers,
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
),
|
||||
"BOOKS"
|
||||
);
|
||||
|
||||
// Filtrer les livres supprimés côté client (léger)
|
||||
response.content = response.content.filter((book) => !book.deleted);
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2.1 : Modification du Service Worker
|
||||
|
||||
```javascript
|
||||
// sw.js - SUPPRIMER cette section
|
||||
// Route 3: API data → Stale-While-Revalidate (if cacheable)
|
||||
// if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) {
|
||||
// event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE));
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Garder uniquement :
|
||||
// - Route 1: Images → Cache-First
|
||||
// - Route 2: RSC payloads → Stale-While-Revalidate (pour navigation)
|
||||
// - Route 4: Static → Cache-First
|
||||
// - Route 5: Navigation → Network-First
|
||||
```
|
||||
|
||||
**Pourquoi supprimer le cache SW des données API ?**
|
||||
|
||||
- Le ServerCacheService fait déjà du SWR côté serveur
|
||||
- Pas de bénéfice à cacher 2 fois
|
||||
- Simplifie l'invalidation (un seul endroit)
|
||||
- Les données restent accessibles en mode online via ServerCache
|
||||
|
||||
### Phase 2.2 : Routes API simplifiées
|
||||
|
||||
```typescript
|
||||
// libraries/[libraryId]/series/route.ts
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ libraryId: string }> }
|
||||
) {
|
||||
const libraryId = (await params).libraryId;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "0");
|
||||
const size = parseInt(searchParams.get("size") || "20");
|
||||
const unreadOnly = searchParams.get("unread") === "true";
|
||||
const search = searchParams.get("search") || undefined;
|
||||
|
||||
const [series, library] = await Promise.all([
|
||||
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
|
||||
LibraryService.getLibrary(libraryId),
|
||||
]);
|
||||
|
||||
// Plus de headers Cache-Control !
|
||||
return NextResponse.json({ series, library });
|
||||
}
|
||||
|
||||
// Supprimer: export const revalidate = 60;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Gains Attendus
|
||||
|
||||
| Métrique | Avant | Après (estimé) |
|
||||
| ------------------------- | ------------ | -------------- |
|
||||
| Payload initial Library | ~500KB - 5MB | ~10-50KB |
|
||||
| Temps 1ère page Library | 2-10s | 200-500ms |
|
||||
| Mémoire cache par library | ~5MB | ~50KB/page |
|
||||
| Requêtes Komga par page | 1 grosse | 1 petite |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Impact sur le Mode Offline
|
||||
|
||||
**Avant (triple cache) :**
|
||||
|
||||
- Données API cachées par le SW → navigation offline possible
|
||||
|
||||
**Après (cache serveur uniquement) :**
|
||||
|
||||
- Données API non cachées côté client
|
||||
- Mode offline limité aux images déjà vues
|
||||
- Page offline.html affichée si pas de connexion
|
||||
|
||||
**Alternative si offline critique :**
|
||||
|
||||
- Option 1 : Garder le cache SW uniquement pour les pages "Home" et "Library" visitées
|
||||
- Option 2 : Utiliser IndexedDB pour un vrai mode offline (plus complexe)
|
||||
- Option 3 : Accepter la limitation (majoritaire pour un reader de comics)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tests à Effectuer
|
||||
|
||||
- [ ] Test pagination avec grande bibliothèque (>1000 séries)
|
||||
- [ ] Test filtres (unread, search) avec pagination
|
||||
- [ ] Test changement de page rapide (pas de race conditions)
|
||||
- [ ] Test invalidation cache (refresh)
|
||||
- [ ] Test mode offline → vérifier que offline.html s'affiche
|
||||
- [ ] Test images offline → doivent rester accessibles
|
||||
|
||||
---
|
||||
|
||||
## 📅 Ordre de Priorité
|
||||
|
||||
1. **Urgent** : Phase 1 (pagination native) - Impact maximal
|
||||
2. **Important** : Phase 2 (simplification cache) - Évite les bugs
|
||||
3. **Moyen** : Phase 3 (préférences) - Optimisation secondaire
|
||||
4. **Faible** : Phase 4-5 (nettoyage) - Polish
|
||||
|
||||
---
|
||||
|
||||
## Notes Techniques
|
||||
|
||||
### API Komga - Pagination
|
||||
|
||||
L'API Komga supporte nativement :
|
||||
|
||||
- `page` : Index de page (0-based)
|
||||
- `size` : Nombre d'éléments par page
|
||||
- `sort` : Tri (ex: `metadata.titleSort,asc`)
|
||||
|
||||
Endpoint POST `/api/v1/series/list` accepte un body avec `condition` pour filtrer.
|
||||
|
||||
### Filtres Komga disponibles
|
||||
|
||||
```json
|
||||
{
|
||||
"condition": {
|
||||
"libraryId": { "operator": "is", "value": "xxx" },
|
||||
"readStatus": { "operator": "is", "value": "IN_PROGRESS" },
|
||||
"complete": { "operator": "is", "value": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Réponse paginée Komga
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [...],
|
||||
"pageable": { "pageNumber": 0, "pageSize": 20 },
|
||||
"totalElements": 150,
|
||||
"totalPages": 8,
|
||||
"first": true,
|
||||
"last": false
|
||||
}
|
||||
```
|
||||
75
docs/komga-api-summary.md
Normal file
75
docs/komga-api-summary.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Résumé Spec Komga OpenAPI v1.24.1
|
||||
|
||||
## Authentication
|
||||
- **Basic Auth** ou **API Key** (`X-API-Key` header)
|
||||
- Sessions: cookie `KOMGA-SESSION` ou header `X-Auth-Token`
|
||||
- "Remember me" supporté
|
||||
|
||||
## Endpoints Principaux
|
||||
|
||||
### Libraries
|
||||
| Méthode | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/libraries` | Liste des bibliothèques |
|
||||
| GET | `/libraries/{id}` | Détail d'une bibliothèque |
|
||||
|
||||
### Series
|
||||
| Méthode | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/series` | Liste des séries (GET avec params) |
|
||||
| POST | `/series/list` | Liste paginée avec filtres (JSON body) |
|
||||
| GET | `/series/{id}` | Détail d'une série |
|
||||
| GET | `/series/{id}/thumbnail` | Vignette (image complète) |
|
||||
| GET | `/series/{id}/books` | Livres d'une série |
|
||||
|
||||
### Books
|
||||
| Méthode | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/books` | Liste des livres |
|
||||
| POST | `/books/list` | Liste paginée avec filtres |
|
||||
| GET | `/books/{id}` | Détail d'un livre |
|
||||
| GET | `/books/{id}/pages` | Liste des pages |
|
||||
| GET | `/books/{id}/pages/{n}` | Image d'une page (streaming) |
|
||||
| GET | `/books/{id}/pages/{n}/thumbnail` | Miniature (300px max) |
|
||||
|
||||
### Collections & Readlists
|
||||
| Méthode | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET/POST | `/collections` | CRUD Collections |
|
||||
| GET/POST | `/readlists` | CRUD Readlists |
|
||||
|
||||
## Pagination
|
||||
|
||||
**Paramètres query:**
|
||||
- `page` - Index 0-based
|
||||
- `size` - Taille de page
|
||||
- `sort` - Tri (ex: `metadata.titleSort,asc`)
|
||||
|
||||
**Corps JSON (POST):**
|
||||
```json
|
||||
{
|
||||
"condition": {
|
||||
"libraryId": { "operator": "is", "value": "xxx" }
|
||||
},
|
||||
"fullTextSearch": "query"
|
||||
}
|
||||
```
|
||||
|
||||
## Opérateurs
|
||||
- `is`, `isNot`
|
||||
- `contains`, `containsNot`
|
||||
- `before`, `after`, `beforeOrEqual`, `afterOrEqual`
|
||||
|
||||
## Opérateurs Logiques
|
||||
- `allOf` - ET logique
|
||||
- `anyOf` - OU logique
|
||||
|
||||
## Images
|
||||
|
||||
| Type | Endpoint | Taille |
|
||||
|------|----------|--------|
|
||||
| Vignette série | `/series/{id}/thumbnail` | Taille originale |
|
||||
| Page livre | `/books/{id}/pages/{n}` | Taille originale (streaming) |
|
||||
| Miniature page | `/books/{id}/pages/{n}/thumbnail` | 300px max |
|
||||
|
||||
**Note:** Komga ne fournit pas de redimensionnement pour les vignettes de séries.
|
||||
146
docs/plan-optimisation.md
Normal file
146
docs/plan-optimisation.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Plan d'Optimisation des Performances
|
||||
|
||||
> Dernière mise à jour: 2026-02-27
|
||||
|
||||
## État Actuel
|
||||
|
||||
### Ce qui fonctionne bien
|
||||
- ✅ Pagination native Komga (`POST /series/list`, `POST /books/list`)
|
||||
- ✅ Prefetching des pages de livre (avec déduplication)
|
||||
- ✅ 5 appels parallèles pour la page Home
|
||||
- ✅ Service Worker pour images et navigation
|
||||
- ✅ Timeout et retry sur les appels API
|
||||
- ✅ Prisma singleton pour éviter les connexions multiples
|
||||
- ✅ **Cache serveur API avec Next.js revalidate** (ajouté)
|
||||
|
||||
---
|
||||
|
||||
## Analyse Complète
|
||||
|
||||
### ⚡ Problèmes de Performance
|
||||
|
||||
#### 🔴 Critique - N+1 API Calls dans getLibraries
|
||||
**Impact:** Fort | **Fichiers:** `src/lib/services/library.service.ts:22-46`
|
||||
|
||||
Les appels pour récupérer le count des livres sont parallèles (Promise.all), mais on pourrait utiliser l'endpoint Komga `expand=booksCount` si disponible.
|
||||
|
||||
#### 🟡 Cache préférences (IMPACT: MOYEN)
|
||||
**Symptôme:** Chaque lecture de préférences = 1 query DB
|
||||
|
||||
**Fichier:** `src/lib/services/preferences.service.ts`
|
||||
|
||||
---
|
||||
|
||||
### 🔒 Problemes de Securite
|
||||
|
||||
#### 🔴 Critique - Auth Header en clair
|
||||
**Impact:** HIGH | **Fichiers:** `src/lib/services/config-db.service.ts:21-23`
|
||||
|
||||
```typescript
|
||||
// Problème: authHeader stocké en clair dans la DB
|
||||
const authHeader: string = Buffer.from(`${data.username}:${data.password}`).toString("base64");
|
||||
```
|
||||
|
||||
**Solution:** Chiffrer avec AES-256 avant de stocker. Ajouter `ENCRYPTION_KEY` dans .env
|
||||
|
||||
#### 🔴 Pas de rate limiting
|
||||
**Impact:** HIGH | **Fichiers:** Toutes les routes API
|
||||
|
||||
**Solution:** Ajouter `rate-limiter-flexible` pour limiter les requêtes par IP/user
|
||||
|
||||
#### 🟡 Pas de sanitization des inputs
|
||||
**Impact:** MEDIUM | **Fichiers:** `library.service.ts`, `series.service.ts`
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Autres Problemes
|
||||
|
||||
#### Service Worker double-cache (IMPACT: FAIBLE)
|
||||
**Symptôme:** Conflit entre cache SW et navigateur
|
||||
|
||||
**Fichier:** `public/sw.js` (ligne 528-536)
|
||||
|
||||
#### RequestDeduplicationService non utilise
|
||||
**Impact:** Moyen | **Fichier:** `src/lib/services/request-deduplication.service.ts`
|
||||
|
||||
#### getHomeData echoue completement si une requete echoue
|
||||
**Impact:** Fort | **Fichier:** `src/app/api/komga/home/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## Priorites d'implementation
|
||||
|
||||
### ✅ Phase 2: Performance (COMPLETEE)
|
||||
- **Cache serveur API via fetchFromApi avec option `revalidate`**
|
||||
- Fichiers modifiés:
|
||||
- `src/lib/services/base-api.service.ts` - ajout option `revalidate` dans fetch
|
||||
- `src/lib/services/library.service.ts` - CACHE_TTL = 300s (5 min)
|
||||
- `src/lib/services/home.service.ts` - CACHE_TTL = 120s (2 min)
|
||||
- `src/lib/services/series.service.ts` - CACHE_TTL = 120s (2 min)
|
||||
- `src/lib/services/book.service.ts` - CACHE_TTL = 60s (1 min)
|
||||
|
||||
### Phase 1: Securite (Priorite HAUTE)
|
||||
|
||||
1. **Chiffrer les identifiants Komga**
|
||||
```typescript
|
||||
// src/lib/utils/encryption.ts
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Ajouter rate limiting**
|
||||
|
||||
### Phase 3: Fiabilite (Priorite MOYENNE)
|
||||
|
||||
1. **Graceful degradation** sur Home (afficher données partielles si un appel échoue)
|
||||
2. **Cache préférences**
|
||||
|
||||
### Phase 4: Nettoyage (Priorite FAIBLE)
|
||||
|
||||
1. **Supprimer double-cache SW** pour les données API
|
||||
|
||||
---
|
||||
|
||||
## TTL Recommandes pour Cache
|
||||
|
||||
| Donnee | TTL | Status |
|
||||
|--------|-----|--------|
|
||||
| Home | 2 min | ✅ Implementé |
|
||||
| Series list | 2 min | ✅ Implementé |
|
||||
| Books list | 2 min | ✅ Implementé |
|
||||
| Book details | 1 min | ✅ Implementé |
|
||||
| Libraries | 5 min | ✅ Implementé |
|
||||
| Preferences | - | ⏳ Non fait |
|
||||
|
||||
---
|
||||
|
||||
## Fichiers a Modifier
|
||||
|
||||
### ✅ Performance (COMPLET)
|
||||
1. ~~`src/lib/services/server-cache.service.ts`~~ - Supprimé, on utilise Next.js natif
|
||||
2. ~~`src/lib/services/base-api.service.ts`~~ - Ajout option `revalidate` dans fetch
|
||||
3. ~~`src/lib/services/home.service.ts`~~ - CACHE_TTL = 120s
|
||||
4. ~~`src/lib/services/library.service.ts`~~ - CACHE_TTL = 300s
|
||||
5. ~~`src/lib/services/series.service.ts`~~ - CACHE_TTL = 120s
|
||||
6. ~~`src/lib/services/book.service.ts`~~ - CACHE_TTL = 60s
|
||||
|
||||
### 🔒 Securite (A FAIRE)
|
||||
1. `src/lib/utils/encryption.ts` (nouveau)
|
||||
2. `src/lib/services/config-db.service.ts` - Utiliser chiffrement
|
||||
3. `src/middleware.ts` - Ajouter rate limiting
|
||||
|
||||
### Fiabilite (A FAIRE)
|
||||
1. `src/app/api/komga/home/route.ts` - Graceful degradation
|
||||
2. `src/lib/services/base-api.service.ts` - Utiliser deduplication
|
||||
|
||||
### Nettoyage (A FAIRE)
|
||||
1. `public/sw.js` - Supprimer cache API
|
||||
65
project-intelligence/business-domain.md
Normal file
65
project-intelligence/business-domain.md
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- Context: project-intelligence/business | Priority: high | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Business Domain
|
||||
|
||||
**Purpose**: Business context, problems solved, and value created for the comic/manga reader app.
|
||||
**Last Updated**: 2026-02-27
|
||||
|
||||
## Quick Reference
|
||||
- **Update When**: Business direction changes, new features shipped
|
||||
- **Audience**: Developers needing context, stakeholders
|
||||
|
||||
## Project Identity
|
||||
```
|
||||
Project Name: Stripstream
|
||||
Tagline: Modern web reader for digital comics and manga
|
||||
Problem: Need a responsive, feature-rich web interface for reading comics from Komga servers
|
||||
Solution: Next.js PWA that syncs with Komga API, supports offline reading, multiple reading modes
|
||||
```
|
||||
|
||||
## Target Users
|
||||
| Segment | Who | Needs | Pain Points |
|
||||
|---------|-----|-------|--------------|
|
||||
| Comic/Manga Readers | Users with Komga servers | Read comics in browser | Komga web UI is limited |
|
||||
| Mobile Readers | iPad/Android users | Offline reading, touch gestures | No native mobile app |
|
||||
| Library Organizers | Users with large collections | Search, filter, track progress | Hard to manage |
|
||||
|
||||
## Value Proposition
|
||||
**For Users**:
|
||||
- Read comics anywhere via responsive PWA
|
||||
- Offline reading with local storage
|
||||
- Multiple viewing modes (single/double page, RTL, scroll)
|
||||
- Progress sync with Komga server
|
||||
- Light/Dark mode, language support (EN/FR)
|
||||
|
||||
**For Business**:
|
||||
- Open source showcase
|
||||
- Demonstrates Next.js + Komga integration patterns
|
||||
|
||||
## Key Features
|
||||
- **Sync**: Read progress, series/books lists with Komga
|
||||
- **Reader**: RTL, double/single page, zoom, thumbnails, fullscreen
|
||||
- **Offline**: PWA with local book downloads
|
||||
- **UI**: Dark/light, responsive, loading/error states
|
||||
- **Lists**: Pagination, search, mark read/unread, favorites
|
||||
- **Settings**: Cache TTL, Komga config, display preferences
|
||||
|
||||
## Tech Stack Context
|
||||
- Integrates with **Komga** (comic server API)
|
||||
- Uses **MongoDB** for local caching/preferences
|
||||
- **NextAuth** for authentication (session-based)
|
||||
|
||||
## Success Metrics
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Page load | <2s |
|
||||
| Reader responsiveness | 60fps |
|
||||
| PWA install rate | Track via analytics |
|
||||
|
||||
## Constraints
|
||||
- Requires Komga server (not standalone)
|
||||
- Mobile storage limits for offline books
|
||||
|
||||
## Related Files
|
||||
- `technical-domain.md` - Tech stack and code patterns
|
||||
- `business-tech-bridge.md` - Business-technical mapping
|
||||
65
project-intelligence/business-tech-bridge.md
Normal file
65
project-intelligence/business-tech-bridge.md
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- Context: project-intelligence/bridge | Priority: medium | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Business-Tech Bridge
|
||||
|
||||
**Purpose**: Map business concepts to technical implementation.
|
||||
**Last Updated**: 2026-02-27
|
||||
|
||||
## Quick Reference
|
||||
- **Update When**: New features that bridge business and tech
|
||||
- **Audience**: Developers, product
|
||||
|
||||
---
|
||||
|
||||
## Business → Technical Mapping
|
||||
|
||||
| Business Concept | Technical Implementation |
|
||||
|-----------------|------------------------|
|
||||
| Read comics | `BookService.getBook()`, PhotoswipeReader component |
|
||||
| Sync progress | Komga API calls in `ReadProgressService` |
|
||||
| Offline reading | Service Worker + IndexedDB via `ClientOfflineBookService` |
|
||||
| User preferences | MongoDB + `PreferencesService` |
|
||||
| Library management | `LibraryService`, `SeriesService` |
|
||||
| Authentication | NextAuth v5 + `AuthServerService` |
|
||||
|
||||
---
|
||||
|
||||
## User Flows → API Routes
|
||||
|
||||
| User Action | API Route | Service |
|
||||
|-------------|-----------|---------|
|
||||
| View home | `GET /api/komga/home` | `HomeService` |
|
||||
| Browse series | `GET /api/komga/libraries/:id/series` | `SeriesService` |
|
||||
| Read book | `GET /api/komga/books/:id` | `BookService` |
|
||||
| Update progress | `POST /api/komga/books/:id/read-progress` | `BookService` |
|
||||
| Download book | `GET /api/komga/images/books/:id/pages/:n` | `ImageService` |
|
||||
|
||||
---
|
||||
|
||||
## Components → Services
|
||||
|
||||
| UI Component | Service Layer |
|
||||
|--------------|---------------|
|
||||
| HomeContent | HomeService |
|
||||
| SeriesGrid | SeriesService |
|
||||
| BookCover | BookService |
|
||||
| PhotoswipeReader | ImageService |
|
||||
| FavoritesButton | FavoriteService |
|
||||
| SettingsPanel | PreferencesService |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User Request → API Route → Service → Komga API / MongoDB → Response
|
||||
↓
|
||||
Logger (Pino)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
- `technical-domain.md` - Tech stack and code patterns
|
||||
- `business-domain.md` - Business context
|
||||
- `decisions-log.md` - Architecture decisions
|
||||
130
project-intelligence/decisions-log.md
Normal file
130
project-intelligence/decisions-log.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<!-- Context: project-intelligence/decisions | Priority: medium | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Decisions Log
|
||||
|
||||
**Purpose**: Record architecture decisions with context and rationale.
|
||||
**Last Updated**: 2026-02-27
|
||||
|
||||
## Quick Reference
|
||||
- **Update When**: New architecture decisions
|
||||
- **Audience**: Developers, architects
|
||||
|
||||
---
|
||||
|
||||
## ADR-001: Use Prisma with MongoDB
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: Need database for caching Komga responses and storing user preferences.
|
||||
|
||||
**Decision**: Use Prisma ORM with MongoDB adapter.
|
||||
|
||||
**Rationale**:
|
||||
- Type-safe queries across the app
|
||||
- Schema migration support
|
||||
- Works well with MongoDB's flexible schema
|
||||
|
||||
**Alternatives Considered**:
|
||||
- Mongoose: Less type-safe, manual schema management
|
||||
- Raw MongoDB driver: No type safety, verbose
|
||||
|
||||
---
|
||||
|
||||
## ADR-002: Service Layer Pattern
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: API routes need business logic separated from HTTP handling.
|
||||
|
||||
**Decision**: Create service classes in `src/lib/services/` (BookService, SeriesService, etc.)
|
||||
|
||||
**Rationale**:
|
||||
- Separation of concerns
|
||||
- Testable business logic
|
||||
- Reusable across API routes
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// API route (thin)
|
||||
export async function GET(request: NextRequest, { params }) {
|
||||
const book = await BookService.getBook(bookId);
|
||||
return NextResponse.json(book);
|
||||
}
|
||||
|
||||
// Service (business logic)
|
||||
class BookService {
|
||||
static async getBook(bookId: string) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ADR-003: Custom AppError with Error Codes
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: Need consistent error handling across API.
|
||||
|
||||
**Decision**: Custom `AppError` class with error codes from `ERROR_CODES` constant.
|
||||
|
||||
**Rationale**:
|
||||
- Consistent error format: `{ error: { code, name, message } }`
|
||||
- Typed error codes for client handling
|
||||
- Centralized error messages via `getErrorMessage()`
|
||||
|
||||
---
|
||||
|
||||
## ADR-004: Radix UI + Tailwind for Components
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: Need accessible UI components without fighting a component library.
|
||||
|
||||
**Decision**: Use Radix UI primitives with custom Tailwind styling.
|
||||
|
||||
**Rationale**:
|
||||
- Radix provides accessible primitives
|
||||
- Full control over styling via Tailwind
|
||||
- Shadcn-like pattern (cva + cn)
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Client-Side Request Deduplication
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: Multiple components may request same data (e.g., home page with series, books, continue reading).
|
||||
|
||||
**Decision**: `RequestDeduplicationService` with React query-like deduplication.
|
||||
|
||||
**Rationale**:
|
||||
- Reduces Komga API calls
|
||||
- Consistent data across components
|
||||
- Configurable TTL
|
||||
|
||||
---
|
||||
|
||||
## ADR-006: PWA with Offline Book Storage
|
||||
|
||||
**Date**: 2024
|
||||
**Status**: Accepted
|
||||
|
||||
**Context**: Users want to read offline, especially on mobile.
|
||||
|
||||
**Decision**: Next PWA + Service Worker + IndexedDB for storing book blobs.
|
||||
|
||||
**Rationale**:
|
||||
- Full offline capability
|
||||
- Background sync when online
|
||||
- Local storage limits on mobile
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
- `technical-domain.md` - Tech stack details
|
||||
- `business-domain.md` - Business context
|
||||
64
project-intelligence/living-notes.md
Normal file
64
project-intelligence/living-notes.md
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- Context: project-intelligence/living-notes | Priority: low | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Living Notes
|
||||
|
||||
**Purpose**: Development notes, TODOs, and temporary information.
|
||||
**Last Updated**: 2026-02-27
|
||||
|
||||
## Quick Reference
|
||||
- **Update When**: Adding dev notes, tracking issues
|
||||
- **Audience**: Developers
|
||||
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
- Performance optimization (see PLAN_OPTIMISATION_PERFORMANCES.md)
|
||||
- Reducing bundle size
|
||||
- Image optimization
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Service Layer
|
||||
All business logic lives in `src/lib/services/`. API routes are thin wrappers.
|
||||
|
||||
### API Error Handling
|
||||
Use `AppError` class from `@/utils/errors`. Always include error code from `ERROR_CODES`.
|
||||
|
||||
### Component Patterns
|
||||
- UI components: `src/components/ui/` (Radix + Tailwind)
|
||||
- Feature components: `src/components/*/` (by feature)
|
||||
- Use `cva` for variant props
|
||||
- Use `cn` from `@/lib/utils` for class merging
|
||||
|
||||
### Types
|
||||
- Komga types: `src/types/komga/`
|
||||
- App types: `src/types/`
|
||||
|
||||
### Database
|
||||
- Prisma schema: `prisma/schema.prisma`
|
||||
- MongoDB connection: `src/lib/prisma.ts`
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Large libraries may be slow to load (pagination helps)
|
||||
- Offline storage limited by device space
|
||||
|
||||
---
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- [ ] Add more reader modes
|
||||
- [ ] User collections/tags
|
||||
- [ ] Reading statistics
|
||||
- [ ] Better caching strategy
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
- `technical-domain.md` - Code patterns
|
||||
- `decisions-log.md` - Architecture decisions
|
||||
23
project-intelligence/navigation.md
Normal file
23
project-intelligence/navigation.md
Normal file
@@ -0,0 +1,23 @@
|
||||
<!-- Context: project-intelligence/navigation | Priority: critical | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Project Intelligence
|
||||
|
||||
Quick overview of project patterns and context files.
|
||||
|
||||
## Quick Routes
|
||||
|
||||
| File | Description | Priority |
|
||||
|------|-------------|----------|
|
||||
| [technical-domain.md](./technical-domain.md) | Tech stack, architecture, patterns | critical |
|
||||
| [business-domain.md](./business-domain.md) | Business logic, domain model | high |
|
||||
| [decisions-log.md](./decisions-log.md) | Architecture decisions | medium |
|
||||
| [living-notes.md](./living-notes.md) | Development notes | low |
|
||||
| [business-tech-bridge.md](./business-tech-bridge.md) | Business-technical mapping | medium |
|
||||
|
||||
## All Files Complete |
|
||||
|
||||
## Usage
|
||||
|
||||
- **AI Agents**: Read technical-domain.md for code patterns
|
||||
- **New Developers**: Start with technical-domain.md + business-domain.md
|
||||
- **Architecture**: Check decisions-log.md for rationale
|
||||
154
project-intelligence/technical-domain.md
Normal file
154
project-intelligence/technical-domain.md
Normal file
@@ -0,0 +1,154 @@
|
||||
<!-- Context: project-intelligence/technical | Priority: critical | Version: 1.0 | Updated: 2026-02-27 -->
|
||||
|
||||
# Technical Domain
|
||||
|
||||
**Purpose**: Tech stack, architecture, development patterns for this project.
|
||||
**Last Updated**: 2026-02-27
|
||||
|
||||
## Quick Reference
|
||||
**Update Triggers**: Tech stack changes | New patterns | Architecture decisions
|
||||
**Audience**: Developers, AI agents
|
||||
|
||||
## Primary Stack
|
||||
| Layer | Technology | Version | Rationale |
|
||||
|-------|-----------|---------|-----------|
|
||||
| Framework | Next.js | 15.5.9 | App Router, Server Components |
|
||||
| Language | TypeScript | 5.3.3 | Type safety |
|
||||
| Database | MongoDB | - | Flexible schema for media metadata |
|
||||
| ORM | Prisma | 6.17.1 | Type-safe DB queries |
|
||||
| Styling | Tailwind CSS | 3.4.1 | Utility-first |
|
||||
| UI Library | Radix UI | - | Accessible components |
|
||||
| Animation | Framer Motion | 12.x | Declarative animations |
|
||||
| Auth | NextAuth | v5 | Session management |
|
||||
| Validation | Zod | 3.22.4 | Schema validation |
|
||||
| Logger | Pino | 10.x | Structured logging |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
├── components/ # React components (ui/, features/)
|
||||
├── lib/ # Services, utils, config
|
||||
│ └── services/ # Business logic (BookService, etc.)
|
||||
├── hooks/ # Custom React hooks
|
||||
├── types/ # TypeScript type definitions
|
||||
├── utils/ # Helper functions
|
||||
├── contexts/ # React contexts
|
||||
├── constants/ # App constants
|
||||
└── i18n/ # Internationalization
|
||||
```
|
||||
|
||||
## Code Patterns
|
||||
### API Endpoint
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { BookService } from "@/lib/services/book.service";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ bookId: string }> }) {
|
||||
try {
|
||||
const bookId: string = (await params).bookId;
|
||||
const data = await BookService.getBook(bookId);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "API Books - Erreur:");
|
||||
if (error instanceof AppError) {
|
||||
const isNotFound = error.code === ERROR_CODES.BOOK.NOT_FOUND;
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, name: "Error", message: getErrorMessage(error.code) } },
|
||||
{ status: isNotFound ? 404 : 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: { code: ERROR_CODES.BOOK.NOT_FOUND, name: "Error", message: "Internal error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component with Variants
|
||||
```typescript
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva("inline-flex items-center justify-center...", {
|
||||
variants: {
|
||||
variant: { default: "...", destructive: "...", outline: "..." },
|
||||
size: { default: "...", sm: "...", lg: "..." },
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
});
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonProps, ButtonProps>(({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
```
|
||||
|
||||
### Feature Component
|
||||
```typescript
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
|
||||
interface HomeContentProps {
|
||||
data: HomeData;
|
||||
}
|
||||
|
||||
export function HomeContent({ data }: HomeContentProps) {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{data.ongoing && <MediaRow titleKey="home.sections.continue" items={data.ongoing} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
| Type | Convention | Example |
|
||||
|------|-----------|---------|
|
||||
| Files | kebab-case | book-cover.tsx |
|
||||
| Components | PascalCase | BookCover |
|
||||
| Functions | camelCase | getBookById |
|
||||
| Types | PascalCase | KomgaBook |
|
||||
| Database | snake_case | read_progress |
|
||||
| API Routes | kebab-case | /api/komga/books |
|
||||
|
||||
## Code Standards
|
||||
- TypeScript strict mode enabled
|
||||
- Zod for request/response validation
|
||||
- Prisma for all database queries (type-safe)
|
||||
- Server Components by default, Client Components when needed
|
||||
- Custom AppError class with error codes
|
||||
- Structured logging with Pino
|
||||
- Error responses: `{ error: { code, name, message } }`
|
||||
|
||||
## Security Requirements
|
||||
- Validate all user input with Zod
|
||||
- Parameterized queries via Prisma (prevents SQL injection)
|
||||
- Sanitize before rendering (React handles this)
|
||||
- HTTPS only in production
|
||||
- Auth via NextAuth v5
|
||||
- Role-based access control (admin, user)
|
||||
- API routes protected with session checks
|
||||
|
||||
## 📂 Codebase References
|
||||
**API Routes**: `src/app/api/**/route.ts` - All API endpoints
|
||||
**Services**: `src/lib/services/*.service.ts` - Business logic layer
|
||||
**Components**: `src/components/ui/`, `src/components/*/` - UI and feature components
|
||||
**Types**: `src/types/**` - TypeScript definitions
|
||||
**Config**: package.json, tsconfig.json, prisma/schema.prisma
|
||||
|
||||
## Related Files
|
||||
- business-domain.md - Business logic and domain model
|
||||
- decisions-log.md - Architecture decisions
|
||||
@@ -7,6 +7,8 @@ import type { KomgaBookWithPages } from "@/types/komga";
|
||||
import type { NextRequest } from "next/server";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache handled in service via fetchFromApi options
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bookId: string }> }
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppError } from "@/utils/errors";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache handled in service via fetchFromApi options
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await HomeService.getHomeData();
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getErrorMessage } from "@/utils/errors";
|
||||
import type { NextRequest } from "next/server";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache handled in service via fetchFromApi options
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -5,7 +5,8 @@ import { AppError } from "@/utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cache handled in service via fetchFromApi options
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getErrorMessage } from "@/utils/errors";
|
||||
import type { NextRequest } from "next/server";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache handled in service via fetchFromApi options
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -93,7 +93,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
}
|
||||
|
||||
if (librariesData.status === "fulfilled") {
|
||||
libraries = librariesData.value;
|
||||
libraries = librariesData.value || [];
|
||||
}
|
||||
|
||||
if (favoritesData.status === "fulfilled") {
|
||||
|
||||
@@ -8,6 +8,8 @@ import logger from "@/lib/logger";
|
||||
interface KomgaRequestInit extends RequestInit {
|
||||
isImage?: boolean;
|
||||
noJson?: boolean;
|
||||
/** Next.js cache duration in seconds. Use false to disable cache, number for TTL */
|
||||
revalidate?: number | false;
|
||||
}
|
||||
|
||||
interface KomgaUrlBuilder {
|
||||
@@ -90,7 +92,8 @@ export abstract class BaseApiService {
|
||||
}
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug || isCacheDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
@@ -100,11 +103,23 @@ export abstract class BaseApiService {
|
||||
params,
|
||||
isImage: options.isImage,
|
||||
noJson: options.noJson,
|
||||
revalidate: options.revalidate,
|
||||
},
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
cache: "enabled",
|
||||
ttl: options.revalidate,
|
||||
},
|
||||
"💾 Cache enabled"
|
||||
);
|
||||
}
|
||||
|
||||
// Timeout de 15 secondes pour éviter les blocages longs
|
||||
const timeoutMs = 15000;
|
||||
const controller = new AbortController();
|
||||
@@ -122,6 +137,10 @@ export abstract class BaseApiService {
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache
|
||||
next: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} catch (fetchError: any) {
|
||||
// Gestion spécifique des erreurs DNS
|
||||
@@ -139,6 +158,10 @@ export abstract class BaseApiService {
|
||||
// Force IPv4 si IPv6 pose problème
|
||||
// @ts-ignore
|
||||
family: 4,
|
||||
// Next.js cache
|
||||
next: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
@@ -152,6 +175,10 @@ export abstract class BaseApiService {
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache
|
||||
next: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
throw fetchError;
|
||||
@@ -160,8 +187,9 @@ export abstract class BaseApiService {
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
@@ -173,6 +201,16 @@ export abstract class BaseApiService {
|
||||
);
|
||||
}
|
||||
|
||||
// Log potential cache hit/miss based on response time
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
// Fast response (< 50ms) is likely a cache hit
|
||||
if (duration < 50) {
|
||||
logger.info({ url, duration: `${duration}ms` }, "⚡ Cache HIT (fast response)");
|
||||
} else {
|
||||
logger.info({ url, duration: `${duration}ms` }, "🔄 Cache MISS (slow response)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
|
||||
@@ -6,12 +6,22 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
export class BookService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 60; // 1 minute
|
||||
|
||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||
try {
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
||||
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
||||
this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<{ number: number }[]>(
|
||||
{ path: `books/${bookId}/pages` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -44,7 +54,11 @@ export class BookService extends BaseApiService {
|
||||
|
||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||
try {
|
||||
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
|
||||
const book = await this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return book.seriesId;
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
|
||||
@@ -8,10 +8,13 @@ import { AppError } from "../../utils/errors";
|
||||
export type { HomeData };
|
||||
|
||||
export class HomeService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||
|
||||
static async getHomeData(): Promise<HomeData> {
|
||||
try {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
@@ -20,8 +23,12 @@ export class HomeService extends BaseApiService {
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
@@ -30,31 +37,46 @@ export class HomeService extends BaseApiService {
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,18 +14,28 @@ interface KomgaLibraryRaw {
|
||||
}
|
||||
|
||||
export class LibraryService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||
try {
|
||||
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>({ path: "libraries" });
|
||||
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>(
|
||||
{ path: "libraries" },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
|
||||
// Enrich each library with book counts
|
||||
// Enrich each library with book counts (parallel requests)
|
||||
const enrichedLibraries = await Promise.all(
|
||||
libraries.map(async (library) => {
|
||||
try {
|
||||
const booksResponse = await this.fetchFromApi<{ totalElements: number }>({
|
||||
const booksResponse = await this.fetchFromApi<{ totalElements: number }>(
|
||||
{
|
||||
path: "books",
|
||||
params: { library_id: library.id, size: "0" },
|
||||
});
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return {
|
||||
...library,
|
||||
importLastModified: "",
|
||||
@@ -76,40 +86,19 @@ export class LibraryService extends BaseApiService {
|
||||
let condition: any;
|
||||
|
||||
if (unreadOnly) {
|
||||
// Utiliser allOf pour combiner libraryId avec anyOf pour UNREAD ou IN_PROGRESS
|
||||
condition = {
|
||||
allOf: [
|
||||
{
|
||||
libraryId: {
|
||||
operator: "is",
|
||||
value: libraryId,
|
||||
},
|
||||
},
|
||||
{ libraryId: { operator: "is", value: libraryId } },
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "UNREAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "IN_PROGRESS",
|
||||
},
|
||||
},
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = {
|
||||
libraryId: {
|
||||
operator: "is",
|
||||
value: libraryId,
|
||||
},
|
||||
};
|
||||
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||
}
|
||||
|
||||
const searchBody: { condition: any; fullTextSearch?: string } = { condition };
|
||||
@@ -127,13 +116,10 @@ export class LibraryService extends BaseApiService {
|
||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
{ method: "POST", body: JSON.stringify(searchBody), revalidate: this.CACHE_TTL }
|
||||
);
|
||||
|
||||
// Filtrer uniquement les séries supprimées côté client (léger)
|
||||
// Filtrer uniquement les séries supprimées
|
||||
const filteredContent = response.content.filter((series) => !series.deleted);
|
||||
|
||||
return {
|
||||
@@ -149,12 +135,9 @@ export class LibraryService extends BaseApiService {
|
||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||
try {
|
||||
await this.fetchFromApi(
|
||||
{
|
||||
path: `libraries/${libraryId}/scan`,
|
||||
params: { deep: String(deep) },
|
||||
},
|
||||
{ path: `libraries/${libraryId}/scan`, params: { deep: String(deep) } },
|
||||
{},
|
||||
{ method: "POST", noJson: true }
|
||||
{ method: "POST", noJson: true, revalidate: 0 } // bypass cache on mutations
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.SCAN_ERROR, { libraryId }, error);
|
||||
|
||||
@@ -10,9 +10,15 @@ import type { UserPreferences } from "@/types/preferences";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export class SeriesService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||
|
||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||
try {
|
||||
return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
|
||||
return this.fetchFromApi<KomgaSeries>(
|
||||
{ path: `series/${seriesId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
@@ -81,6 +87,7 @@ export class SeriesService extends BaseApiService {
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user