diff --git a/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts index 4cd1aa5..69d7fcf 100644 --- a/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts +++ b/src/app/api/komga/books/[bookId]/pages/[pageNumber]/route.ts @@ -5,6 +5,7 @@ import { ERROR_CODES } from "@/constants/errorCodes"; import { getErrorMessage } from "@/utils/errors"; import { AppError } from "@/utils/errors"; import logger from "@/lib/logger"; +import { requestDeduplicationService } from "@/lib/services/request-deduplication.service"; export const dynamic = "force-dynamic"; @@ -29,7 +30,13 @@ export async function GET( ); } - const response = await BookService.getPage(bookIdParam, pageNumber); + // Utiliser la déduplication pour éviter les requêtes dupliquées vers Komga + // Si plusieurs clients demandent la même page simultanément, une seule requête est faite + const deduplicationKey = `book-page:${bookIdParam}:${pageNumber}`; + const response = await requestDeduplicationService.deduplicate( + deduplicationKey, + () => BookService.getPage(bookIdParam, pageNumber) + ); const buffer = await response.arrayBuffer(); const headers = new Headers(); headers.set("Content-Type", response.headers.get("Content-Type") || "image/jpeg"); diff --git a/src/components/reader/PhotoswipeReader.tsx b/src/components/reader/PhotoswipeReader.tsx index fc14b8b..6f8e82d 100644 --- a/src/components/reader/PhotoswipeReader.tsx +++ b/src/components/reader/PhotoswipeReader.tsx @@ -67,6 +67,8 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP // Prefetch current and next pages + // Deduplication in useImageLoader prevents redundant requests + // Server queue (RequestQueueService) handles concurrency limits useEffect(() => { // Prefetch pages starting from current page prefetchPages(currentPage, prefetchCount); diff --git a/src/components/reader/hooks/useImageLoader.ts b/src/components/reader/hooks/useImageLoader.ts index a862a13..6f4d514 100644 --- a/src/components/reader/hooks/useImageLoader.ts +++ b/src/components/reader/hooks/useImageLoader.ts @@ -20,6 +20,8 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB const [imageBlobUrls, setImageBlobUrls] = useState>({}); const loadedImagesRef = useRef(loadedImages); const imageBlobUrlsRef = useRef(imageBlobUrls); + // Track ongoing fetch requests to prevent duplicates + const pendingFetchesRef = useRef>(new Set()); // Keep refs in sync with state useEffect(() => { @@ -42,8 +44,19 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB return; } + // Check if this page is already being fetched + if (pendingFetchesRef.current.has(pageNum)) { + return; + } + + // Mark as pending + pendingFetchesRef.current.add(pageNum); + try { - const response = await fetch(getPageUrl(pageNum)); + // Use browser cache if available - the server sets Cache-Control headers + const response = await fetch(getPageUrl(pageNum), { + cache: 'default', // Respect Cache-Control headers from server + }); if (!response.ok) { return; } @@ -68,10 +81,15 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB img.src = blobUrl; } catch { // Silently fail prefetch + } finally { + // Remove from pending set + pendingFetchesRef.current.delete(pageNum); } }, [getPageUrl]); // Prefetch multiple pages starting from a given page + // The server-side queue (RequestQueueService) handles concurrency limits + // We only deduplicate to avoid redundant HTTP requests const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => { const pagesToPrefetch = []; @@ -80,17 +98,22 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB if (pageNum <= _pages.length) { const hasDimensions = loadedImagesRef.current[pageNum]; const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; + const isPending = pendingFetchesRef.current.has(pageNum); - // Prefetch if we don't have both dimensions AND blob URL - if (!hasDimensions || !hasBlobUrl) { + // Prefetch if we don't have both dimensions AND blob URL AND it's not already pending + if ((!hasDimensions || !hasBlobUrl) && !isPending) { pagesToPrefetch.push(pageNum); } } } - // Prefetch all pages in parallel + // Let all prefetch requests run - the server queue will manage concurrency + // The browser cache and our deduplication prevent redundant requests if (pagesToPrefetch.length > 0) { - await Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))); + // Fire all requests in parallel - server queue handles throttling + Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => { + // Silently fail - prefetch is non-critical + }); } }, [prefetchImage, prefetchCount, _pages.length]); @@ -108,17 +131,23 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB const nextBookPageKey = `next-${pageNum}`; const hasDimensions = loadedImagesRef.current[nextBookPageKey]; const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey]; + const isPending = pendingFetchesRef.current.has(nextBookPageKey); - if (!hasDimensions || !hasBlobUrl) { + if ((!hasDimensions || !hasBlobUrl) && !isPending) { pagesToPrefetch.push({ pageNum, nextBookPageKey }); } } - // Prefetch all pages in parallel + // Let all prefetch requests run - server queue handles concurrency if (pagesToPrefetch.length > 0) { - await Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => { + Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => { + // Mark as pending + pendingFetchesRef.current.add(nextBookPageKey); + try { - const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`); + const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { + cache: 'default', // Respect Cache-Control headers from server + }); if (!response.ok) { return; } @@ -143,8 +172,12 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB img.src = blobUrl; } catch { // Silently fail prefetch + } finally { + pendingFetchesRef.current.delete(nextBookPageKey); } - })); + })).catch(() => { + // Silently fail - prefetch is non-critical + }); } }, [nextBook, prefetchCount]); diff --git a/src/lib/services/request-deduplication.service.ts b/src/lib/services/request-deduplication.service.ts new file mode 100644 index 0000000..e0dfb6b --- /dev/null +++ b/src/lib/services/request-deduplication.service.ts @@ -0,0 +1,70 @@ +/** + * Service de déduplication des requêtes côté serveur + * Évite de lancer plusieurs requêtes identiques vers Komga simultanément + */ +type PendingRequest = Promise; + +class RequestDeduplicationService { + // Map pour tracker les requêtes en cours par clé unique + private pendingRequests = new Map>(); + + /** + * Exécute une requête de manière dédupliquée + * Si une requête identique est déjà en cours, on attend son résultat + * au lieu d'en lancer une nouvelle + */ + async deduplicate( + key: string, + fetcher: () => Promise, + ttl: number = 60000 // 60 secondes max pour éviter les fuites mémoire + ): Promise { + // Vérifier si une requête identique est déjà en cours + const existing = this.pendingRequests.get(key); + if (existing) { + return existing as Promise; + } + + // Créer une nouvelle promesse pour cette requête + const promise = fetcher() + .then((result) => { + // Nettoyer après le succès + this.pendingRequests.delete(key); + return result; + }) + .catch((error) => { + // Nettoyer après l'erreur + this.pendingRequests.delete(key); + throw error; + }); + + // Stocker la promesse en cours + this.pendingRequests.set(key, promise); + + // Timeout de sécurité pour éviter les fuites mémoire + setTimeout(() => { + if (this.pendingRequests.has(key)) { + this.pendingRequests.delete(key); + } + }, ttl); + + return promise; + } + + /** + * Nettoie toutes les requêtes en cours (pour les tests) + */ + clear(): void { + this.pendingRequests.clear(); + } + + /** + * Retourne le nombre de requêtes en cours + */ + getPendingCount(): number { + return this.pendingRequests.size; + } +} + +// Singleton instance +export const requestDeduplicationService = new RequestDeduplicationService(); +