feat: perf optimisation
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s

This commit is contained in:
2026-02-27 16:23:05 +01:00
parent bcfd602353
commit 0c3a54c62c
20 changed files with 883 additions and 489 deletions

View File

@@ -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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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 }> }

View File

@@ -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();

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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") {

View File

@@ -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(

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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,
}
);