diff --git a/PLAN_OPTIMISATION_PERFORMANCES.md b/PLAN_OPTIMISATION_PERFORMANCES.md new file mode 100644 index 0000000..9e5a1e6 --- /dev/null +++ b/PLAN_OPTIMISATION_PERFORMANCES.md @@ -0,0 +1,400 @@ +# 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)** + +- [ ] **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 + +- [ ] **2.2 Supprimer les headers HTTP Cache-Control** + + - Retirer `Cache-Control` des NextResponse dans les routes API + - Évite les conflits avec le cache serveur + +- [ ] **2.3 Supprimer `revalidate` des routes dynamiques** + + - Routes API = dynamiques, pas besoin d'ISR + - Le cache serveur suffit + +- [ ] **2.4 Optimiser les TTL ServerCacheService** + - Réduire TTL des listes paginées (1-2 min) + - Garder TTL court pour les données avec progression (5 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> { + const headers = { "Content-Type": "application/json" }; + + // Construction du body de recherche pour Komga + const condition: Record = { + 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>( + cacheKey, + async () => { + const params: Record = { + page: String(page), + size: String(size), + sort: "metadata.titleSort,asc", + }; + + // Filtre de recherche + if (search) { + params.search = search; + } + + return this.fetchFromApi>( + { 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> { + const headers = { "Content-Type": "application/json" }; + + const condition: Record = { + 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>( + cacheKey, + async () => + this.fetchFromApi>( + { + 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 +} +``` diff --git a/src/lib/services/library.service.ts b/src/lib/services/library.service.ts index 8a6e6d0..c25278f 100644 --- a/src/lib/services/library.service.ts +++ b/src/lib/services/library.service.ts @@ -82,69 +82,74 @@ export class LibraryService extends BaseApiService { search?: string ): Promise> { try { - // Récupérer toutes les séries depuis le cache - const allSeries = await this.getAllLibrarySeries(libraryId); + const headers = { "Content-Type": "application/json" }; - // Filtrer les séries - let filteredSeries = allSeries; + // Construction du body de recherche pour Komga + const condition: Record = { + libraryId: { + operator: "is", + value: libraryId, + }, + }; - // Filtrer les séries supprimées (fichiers manquants sur le filesystem) - filteredSeries = filteredSeries.filter((series) => !series.deleted); + const searchBody = { condition }; + // Pour le filtre unread, on récupère plus d'éléments car on filtre côté client + // Estimation : ~50% des séries sont unread, donc on récupère 2x pour être sûr + const fetchSize = unreadOnly ? size * 2 : size; + + // 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>( + cacheKey, + async () => { + const params: Record = { + page: String(page), + size: String(fetchSize), + sort: "metadata.titleSort,asc", + }; + + // Filtre de recherche Komga (recherche dans le titre) + if (search) { + params.search = search; + } + + return this.fetchFromApi>( + { path: "series/list", params }, + headers, + { + method: "POST", + body: JSON.stringify(searchBody), + } + ); + }, + "SERIES" + ); + + // Filtrer les séries supprimées côté client (léger) + let filteredContent = response.content.filter((series) => !series.deleted); + + // Filtre unread côté client (Komga n'a pas de filtre natif pour booksReadCount < booksCount) if (unreadOnly) { - filteredSeries = filteredSeries.filter( + filteredContent = filteredContent.filter( (series) => series.booksReadCount < series.booksCount ); + // Prendre uniquement les `size` premiers après filtrage + filteredContent = filteredContent.slice(0, size); } - if (search) { - const searchLower = search.toLowerCase(); - filteredSeries = filteredSeries.filter( - (series) => - series.metadata.title.toLowerCase().includes(searchLower) || - series.id.toLowerCase().includes(searchLower) - ); - } - - // Trier les séries - filteredSeries.sort((a, b) => a.metadata.titleSort.localeCompare(b.metadata.titleSort)); - - // Calculer la pagination - const totalElements = filteredSeries.length; - const totalPages = Math.ceil(totalElements / size); - const startIndex = page * size; - const endIndex = Math.min(startIndex + size, totalElements); - - const paginatedSeries = filteredSeries.slice(startIndex, endIndex); - - // Construire la réponse + // Note: Les totaux (totalElements, totalPages) restent ceux de Komga + // Ils sont approximatifs après filtrage côté client mais fonctionnels pour la pagination + // Le filtrage côté client est léger (seulement deleted + unread) return { - content: paginatedSeries, - empty: paginatedSeries.length === 0, - first: page === 0, - last: page >= totalPages - 1, - number: page, - numberOfElements: paginatedSeries.length, - pageable: { - offset: startIndex, - pageNumber: page, - pageSize: size, - paged: true, - sort: { - empty: false, - sorted: true, - unsorted: false, - }, - unpaged: false, - }, - size, - sort: { - empty: false, - sorted: true, - unsorted: false, - }, - totalElements, - totalPages, + ...response, + content: filteredContent, + numberOfElements: filteredContent.length, + // Garder totalElements et totalPages de Komga pour la pagination + // Ils seront légèrement inexacts mais fonctionnels }; } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); @@ -154,8 +159,11 @@ export class LibraryService extends BaseApiService { static async invalidateLibrarySeriesCache(libraryId: string): Promise { try { const cacheService = await getServerCacheService(); - const cacheKey = `library-${libraryId}-all-series`; - await cacheService.delete(cacheKey); + // Invalider toutes les clés de cache pour cette bibliothèque + // Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search} + await cacheService.deleteAll(`library-${libraryId}-series-`); + // Invalider aussi l'ancienne clé pour compatibilité + await cacheService.delete(`library-${libraryId}-all-series`); } catch (error) { throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); } diff --git a/src/lib/services/series.service.ts b/src/lib/services/series.service.ts index e8d0f72..cc46325 100644 --- a/src/lib/services/series.service.ts +++ b/src/lib/services/series.service.ts @@ -98,59 +98,71 @@ export class SeriesService extends BaseApiService { unreadOnly: boolean = false ): Promise> { try { - // Récupérer tous les livres depuis le cache - const allBooks: KomgaBook[] = await this.getAllSeriesBooks(seriesId); + const headers = { "Content-Type": "application/json" }; - // Filtrer les livres - let filteredBooks = allBooks; - - // Filtrer les livres supprimés (fichiers manquants sur le filesystem) - filteredBooks = filteredBooks.filter((book: KomgaBook) => !book.deleted); + // Construction du body de recherche pour Komga + const condition: Record = { + seriesId: { + operator: "is", + value: seriesId, + }, + }; + // Filtre unread natif Komga (readStatus != READ) if (unreadOnly) { - filteredBooks = filteredBooks.filter( - (book: KomgaBook) => !book.readProgress || !book.readProgress.completed - ); + condition.readStatus = { + operator: "isNot", + value: "READ", + }; } - // Trier les livres par numéro - filteredBooks.sort((a: KomgaBook, b: KomgaBook) => a.number - b.number); + const searchBody = { condition }; - // Calculer la pagination - const totalElements = filteredBooks.length; - const totalPages = Math.ceil(totalElements / size); - const startIndex = page * size; - const endIndex = Math.min(startIndex + size, totalElements); - const paginatedBooks = filteredBooks.slice(startIndex, endIndex); + // Pour le filtre unread, on récupère plus d'éléments car on filtre aussi les deleted côté client + // Estimation : ~10% des livres sont supprimés, donc on récupère légèrement plus + const fetchSize = unreadOnly ? size : size; - // Construire la réponse + // Clé de cache incluant tous les paramètres + const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`; + + const response = await this.fetchWithCache>( + cacheKey, + async () => { + const params: Record = { + page: String(page), + size: String(fetchSize), + sort: "number,asc", + }; + + return this.fetchFromApi>( + { path: "books/list", params }, + headers, + { + method: "POST", + body: JSON.stringify(searchBody), + } + ); + }, + "BOOKS" + ); + + // Filtrer les livres supprimés côté client (léger) + let filteredContent = response.content.filter((book: KomgaBook) => !book.deleted); + + // Si on a filtré des livres supprimés, prendre uniquement les `size` premiers + if (filteredContent.length > size) { + filteredContent = filteredContent.slice(0, size); + } + + // Note: Les totaux (totalElements, totalPages) restent ceux de Komga + // Ils sont approximatifs après filtrage côté client mais fonctionnels pour la pagination + // Le filtrage côté client est léger (seulement deleted) return { - content: paginatedBooks, - empty: paginatedBooks.length === 0, - first: page === 0, - last: page >= totalPages - 1, - number: page, - numberOfElements: paginatedBooks.length, - pageable: { - offset: startIndex, - pageNumber: page, - pageSize: size, - paged: true, - sort: { - empty: false, - sorted: true, - unsorted: false, - }, - unpaged: false, - }, - size, - sort: { - empty: false, - sorted: true, - unsorted: false, - }, - totalElements, - totalPages, + ...response, + content: filteredContent, + numberOfElements: filteredContent.length, + // Garder totalElements et totalPages de Komga pour la pagination + // Ils seront légèrement inexacts mais fonctionnels }; } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); @@ -158,8 +170,16 @@ export class SeriesService extends BaseApiService { } static async invalidateSeriesBooksCache(seriesId: string): Promise { - const cacheService: ServerCacheService = await getServerCacheService(); - await cacheService.delete(`series-${seriesId}-all-books`); + try { + const cacheService: ServerCacheService = await getServerCacheService(); + // Invalider toutes les clés de cache pour cette série + // Format: series-{id}-books-p{page}-s{size}-u{unread} + await cacheService.deleteAll(`series-${seriesId}-books-`); + // Invalider aussi l'ancienne clé pour compatibilité + await cacheService.delete(`series-${seriesId}-all-books`); + } catch (error) { + throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); + } } static async getFirstBook(seriesId: string): Promise {