Compare commits
4 Commits
034aa69f8d
...
0c3a54c62c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3a54c62c | |||
| bcfd602353 | |||
| 38c7e59366 | |||
| b9c8b05bc8 |
@@ -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
|
||||||
14248
komga-openapi.json
Normal file
14248
komga-openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
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 type { NextRequest } from "next/server";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
// Cache handled in service via fetchFromApi options
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ bookId: string }> }
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
@@ -21,6 +23,9 @@ export async function GET(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "API Books - Erreur:");
|
logger.error({ err: error }, "API Books - Erreur:");
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
const isNotFound =
|
||||||
|
error.code === ERROR_CODES.BOOK.NOT_FOUND ||
|
||||||
|
(error.code === ERROR_CODES.KOMGA.HTTP_ERROR && (error as any).params?.status === 404);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: {
|
error: {
|
||||||
@@ -29,7 +34,7 @@ export async function GET(
|
|||||||
message: getErrorMessage(error.code),
|
message: getErrorMessage(error.code),
|
||||||
} as AppError,
|
} as AppError,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: isNotFound ? 404 : 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AppError } from "@/utils/errors";
|
|||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
// Cache handled in service via fetchFromApi options
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const data = await HomeService.getHomeData();
|
const data = await HomeService.getHomeData();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getErrorMessage } from "@/utils/errors";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
// Cache handled in service via fetchFromApi options
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { AppError } from "@/utils/errors";
|
|||||||
import type { KomgaLibrary } from "@/types/komga";
|
import type { KomgaLibrary } from "@/types/komga";
|
||||||
import { getErrorMessage } from "@/utils/errors";
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
// Cache handled in service via fetchFromApi options
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getErrorMessage } from "@/utils/errors";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
// Cache handled in service via fetchFromApi options
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (librariesData.status === "fulfilled") {
|
if (librariesData.status === "fulfilled") {
|
||||||
libraries = librariesData.value;
|
libraries = librariesData.value || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (favoritesData.status === "fulfilled") {
|
if (favoritesData.status === "fulfilled") {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ImageOff } from "lucide-react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ImageLoader } from "@/components/ui/image-loader";
|
import { ImageLoader } from "@/components/ui/image-loader";
|
||||||
|
|
||||||
@@ -18,96 +17,31 @@ export const CoverClient = ({
|
|||||||
className,
|
className,
|
||||||
isCompleted = false,
|
isCompleted = false,
|
||||||
}: CoverClientProps) => {
|
}: CoverClientProps) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Reset état quand l'URL change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImageError(false);
|
const img = imgRef.current;
|
||||||
setIsLoading(true);
|
if (img?.complete && img.naturalWidth > 0) {
|
||||||
setRetryCount(0);
|
|
||||||
}, [imageUrl]);
|
|
||||||
|
|
||||||
// Timeout de sécurité : si l'image ne se charge pas en 30 secondes, on arrête le loading
|
|
||||||
useEffect(() => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
if (isLoading) {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setImageError(true);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [imageUrl, isLoading, retryCount]);
|
|
||||||
|
|
||||||
// Si en erreur, réessayer automatiquement après 2 secondes
|
|
||||||
useEffect(() => {
|
|
||||||
if (imageError) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setImageError(false);
|
|
||||||
setIsLoading(true);
|
|
||||||
setRetryCount((prev) => prev + 1);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [imageError]);
|
|
||||||
|
|
||||||
const handleLoad = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
setImageError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
setImageError(true);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ajouter un timestamp pour forcer le rechargement en cas de retry
|
|
||||||
const imageUrlWithRetry =
|
|
||||||
retryCount > 0
|
|
||||||
? `${imageUrl}${imageUrl.includes("?") ? "&" : "?"}retry=${retryCount}`
|
|
||||||
: imageUrl;
|
|
||||||
|
|
||||||
if (imageError) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-muted/80 backdrop-blur-md rounded-lg">
|
|
||||||
<ImageOff className="w-12 h-12 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<ImageLoader isLoading={isLoading} />
|
<ImageLoader isLoading={isLoading} />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
<img
|
||||||
src={imageUrlWithRetry}
|
ref={imgRef}
|
||||||
|
src={imageUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full object-cover transition-opacity duration-300 rounded-lg",
|
"absolute inset-0 w-full h-full object-cover rounded-lg",
|
||||||
isCompleted && "opacity-50",
|
isCompleted && "opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onError={handleError}
|
onLoad={() => setIsLoading(false)}
|
||||||
onLoad={handleLoad}
|
onError={() => setIsLoading(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import logger from "@/lib/logger";
|
|||||||
interface KomgaRequestInit extends RequestInit {
|
interface KomgaRequestInit extends RequestInit {
|
||||||
isImage?: boolean;
|
isImage?: boolean;
|
||||||
noJson?: boolean;
|
noJson?: boolean;
|
||||||
|
/** Next.js cache duration in seconds. Use false to disable cache, number for TTL */
|
||||||
|
revalidate?: number | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KomgaUrlBuilder {
|
interface KomgaUrlBuilder {
|
||||||
@@ -90,7 +92,8 @@ export abstract class BaseApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
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) {
|
if (isDebug) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -100,11 +103,23 @@ export abstract class BaseApiService {
|
|||||||
params,
|
params,
|
||||||
isImage: options.isImage,
|
isImage: options.isImage,
|
||||||
noJson: options.noJson,
|
noJson: options.noJson,
|
||||||
|
revalidate: options.revalidate,
|
||||||
},
|
},
|
||||||
"🔵 Komga Request"
|
"🔵 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
|
// Timeout de 15 secondes pour éviter les blocages longs
|
||||||
const timeoutMs = 15000;
|
const timeoutMs = 15000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -122,6 +137,10 @@ export abstract class BaseApiService {
|
|||||||
connectTimeout: timeoutMs,
|
connectTimeout: timeoutMs,
|
||||||
bodyTimeout: timeoutMs,
|
bodyTimeout: timeoutMs,
|
||||||
headersTimeout: timeoutMs,
|
headersTimeout: timeoutMs,
|
||||||
|
// Next.js cache
|
||||||
|
next: options.revalidate !== undefined
|
||||||
|
? { revalidate: options.revalidate }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
} catch (fetchError: any) {
|
} catch (fetchError: any) {
|
||||||
// Gestion spécifique des erreurs DNS
|
// Gestion spécifique des erreurs DNS
|
||||||
@@ -139,6 +158,10 @@ export abstract class BaseApiService {
|
|||||||
// Force IPv4 si IPv6 pose problème
|
// Force IPv4 si IPv6 pose problème
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
family: 4,
|
family: 4,
|
||||||
|
// Next.js cache
|
||||||
|
next: options.revalidate !== undefined
|
||||||
|
? { revalidate: options.revalidate }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||||
// Retry automatique sur timeout de connexion (cold start)
|
// Retry automatique sur timeout de connexion (cold start)
|
||||||
@@ -152,6 +175,10 @@ export abstract class BaseApiService {
|
|||||||
connectTimeout: timeoutMs,
|
connectTimeout: timeoutMs,
|
||||||
bodyTimeout: timeoutMs,
|
bodyTimeout: timeoutMs,
|
||||||
headersTimeout: timeoutMs,
|
headersTimeout: timeoutMs,
|
||||||
|
// Next.js cache
|
||||||
|
next: options.revalidate !== undefined
|
||||||
|
? { revalidate: options.revalidate }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw fetchError;
|
throw fetchError;
|
||||||
@@ -160,8 +187,9 @@ export abstract class BaseApiService {
|
|||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (isDebug) {
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (isDebug) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
url,
|
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 (!response.ok) {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -6,12 +6,22 @@ import { ERROR_CODES } from "../../constants/errorCodes";
|
|||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
|
|
||||||
export class BookService extends BaseApiService {
|
export class BookService extends BaseApiService {
|
||||||
|
private static readonly CACHE_TTL = 60; // 1 minute
|
||||||
|
|
||||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||||
try {
|
try {
|
||||||
// Récupération parallèle des détails du tome et des pages
|
// Récupération parallèle des détails du tome et des pages
|
||||||
const [book, pages] = await Promise.all([
|
const [book, pages] = await Promise.all([
|
||||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
this.fetchFromApi<KomgaBook>(
|
||||||
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
{ path: `books/${bookId}` },
|
||||||
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
|
this.fetchFromApi<{ number: number }[]>(
|
||||||
|
{ path: `books/${bookId}/pages` },
|
||||||
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -33,7 +43,7 @@ export class BookService extends BaseApiService {
|
|||||||
if (
|
if (
|
||||||
error instanceof AppError &&
|
error instanceof AppError &&
|
||||||
error.code === ERROR_CODES.KOMGA.HTTP_ERROR &&
|
error.code === ERROR_CODES.KOMGA.HTTP_ERROR &&
|
||||||
(error as any).context?.status === 404
|
(error as any).params?.status === 404
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,11 @@ export class BookService extends BaseApiService {
|
|||||||
|
|
||||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||||
try {
|
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;
|
return book.seriesId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { AppError } from "../../utils/errors";
|
|||||||
export type { HomeData };
|
export type { HomeData };
|
||||||
|
|
||||||
export class HomeService extends BaseApiService {
|
export class HomeService extends BaseApiService {
|
||||||
|
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||||
|
|
||||||
static async getHomeData(): Promise<HomeData> {
|
static async getHomeData(): Promise<HomeData> {
|
||||||
try {
|
try {
|
||||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||||
|
{
|
||||||
path: "series",
|
path: "series",
|
||||||
params: {
|
params: {
|
||||||
read_status: "IN_PROGRESS",
|
read_status: "IN_PROGRESS",
|
||||||
@@ -20,8 +23,12 @@ export class HomeService extends BaseApiService {
|
|||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
|
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||||
|
{
|
||||||
path: "books",
|
path: "books",
|
||||||
params: {
|
params: {
|
||||||
read_status: "IN_PROGRESS",
|
read_status: "IN_PROGRESS",
|
||||||
@@ -30,31 +37,46 @@ export class HomeService extends BaseApiService {
|
|||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
|
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||||
|
{
|
||||||
path: "books/latest",
|
path: "books/latest",
|
||||||
params: {
|
params: {
|
||||||
page: "0",
|
page: "0",
|
||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
|
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||||
|
{
|
||||||
path: "books/ondeck",
|
path: "books/ondeck",
|
||||||
params: {
|
params: {
|
||||||
page: "0",
|
page: "0",
|
||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
|
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||||
|
{
|
||||||
path: "series/latest",
|
path: "series/latest",
|
||||||
params: {
|
params: {
|
||||||
page: "0",
|
page: "0",
|
||||||
size: "10",
|
size: "10",
|
||||||
media_status: "READY",
|
media_status: "READY",
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,18 +14,28 @@ interface KomgaLibraryRaw {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LibraryService extends BaseApiService {
|
export class LibraryService extends BaseApiService {
|
||||||
|
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||||
try {
|
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(
|
const enrichedLibraries = await Promise.all(
|
||||||
libraries.map(async (library) => {
|
libraries.map(async (library) => {
|
||||||
try {
|
try {
|
||||||
const booksResponse = await this.fetchFromApi<{ totalElements: number }>({
|
const booksResponse = await this.fetchFromApi<{ totalElements: number }>(
|
||||||
|
{
|
||||||
path: "books",
|
path: "books",
|
||||||
params: { library_id: library.id, size: "0" },
|
params: { library_id: library.id, size: "0" },
|
||||||
});
|
},
|
||||||
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...library,
|
...library,
|
||||||
importLastModified: "",
|
importLastModified: "",
|
||||||
@@ -76,43 +86,22 @@ export class LibraryService extends BaseApiService {
|
|||||||
let condition: any;
|
let condition: any;
|
||||||
|
|
||||||
if (unreadOnly) {
|
if (unreadOnly) {
|
||||||
// Utiliser allOf pour combiner libraryId avec anyOf pour UNREAD ou IN_PROGRESS
|
|
||||||
condition = {
|
condition = {
|
||||||
allOf: [
|
allOf: [
|
||||||
{
|
{ libraryId: { operator: "is", value: libraryId } },
|
||||||
libraryId: {
|
|
||||||
operator: "is",
|
|
||||||
value: libraryId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{
|
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||||
readStatus: {
|
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||||
operator: "is",
|
|
||||||
value: "UNREAD",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
readStatus: {
|
|
||||||
operator: "is",
|
|
||||||
value: "IN_PROGRESS",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
condition = {
|
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||||
libraryId: {
|
|
||||||
operator: "is",
|
|
||||||
value: libraryId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchBody = { condition };
|
const searchBody: { condition: any; fullTextSearch?: string } = { condition };
|
||||||
|
|
||||||
const params: Record<string, string | string[]> = {
|
const params: Record<string, string | string[]> = {
|
||||||
page: String(page),
|
page: String(page),
|
||||||
@@ -120,21 +109,17 @@ export class LibraryService extends BaseApiService {
|
|||||||
sort: "metadata.titleSort,asc",
|
sort: "metadata.titleSort,asc",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtre de recherche Komga (recherche dans le titre)
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.search = search;
|
searchBody.fullTextSearch = search;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||||
{ path: "series/list", params },
|
{ path: "series/list", params },
|
||||||
headers,
|
headers,
|
||||||
{
|
{ method: "POST", body: JSON.stringify(searchBody), revalidate: this.CACHE_TTL }
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(searchBody),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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);
|
const filteredContent = response.content.filter((series) => !series.deleted);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -150,12 +135,9 @@ export class LibraryService extends BaseApiService {
|
|||||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.fetchFromApi(
|
await this.fetchFromApi(
|
||||||
{
|
{ 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) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.LIBRARY.SCAN_ERROR, { libraryId }, 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";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export class SeriesService extends BaseApiService {
|
export class SeriesService extends BaseApiService {
|
||||||
|
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||||
|
|
||||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||||
try {
|
try {
|
||||||
return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
|
return this.fetchFromApi<KomgaSeries>(
|
||||||
|
{ path: `series/${seriesId}` },
|
||||||
|
{},
|
||||||
|
{ revalidate: this.CACHE_TTL }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,7 @@ export class SeriesService extends BaseApiService {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(searchBody),
|
body: JSON.stringify(searchBody),
|
||||||
|
revalidate: this.CACHE_TTL,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user