From ad11bce308bf9ea14631cf5ca475e59ad75732f0 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 4 Jan 2026 11:39:55 +0100 Subject: [PATCH] revert: restore page-by-page download method (old method works better) --- package.json | 1 - pnpm-lock.yaml | 8 - .../api/komga/books/[bookId]/file/route.ts | 56 ------- src/components/ui/book-offline-button.tsx | 140 ++++++++---------- src/lib/services/book.service.ts | 21 --- 5 files changed, 65 insertions(+), 161 deletions(-) delete mode 100644 src/app/api/komga/books/[bookId]/file/route.ts diff --git a/package.json b/package.json index b31925c..edc286e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "fflate": "^0.8.2", "framer-motion": "^12.23.24", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49df579..66720ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - fflate: - specifier: ^0.8.2 - version: 0.8.2 framer-motion: specifier: ^12.23.24 version: 12.23.24(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1898,9 +1895,6 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -4983,8 +4977,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fflate@0.8.2: {} - file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 diff --git a/src/app/api/komga/books/[bookId]/file/route.ts b/src/app/api/komga/books/[bookId]/file/route.ts deleted file mode 100644 index 369ab3c..0000000 --- a/src/app/api/komga/books/[bookId]/file/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 type { NextRequest } from "next/server"; -import logger from "@/lib/logger"; - -export const dynamic = "force-dynamic"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ bookId: string }> } -) { - try { - const { bookId } = await params; - - const response = await BookService.getFile(bookId); - - // Stream the file directly to the client - return new NextResponse(response.body, { - status: 200, - headers: { - "Content-Type": response.headers.get("Content-Type") || "application/octet-stream", - "Content-Length": response.headers.get("Content-Length") || "", - "Content-Disposition": response.headers.get("Content-Disposition") || "", - "Cache-Control": "public, max-age=31536000", - }, - }); - } catch (error) { - logger.error({ err: error }, "API Books File - Erreur:"); - if (error instanceof AppError) { - return NextResponse.json( - { - error: { - code: error.code, - name: "Book file fetch error", - message: getErrorMessage(error.code), - } as AppError, - }, - { status: 500 } - ); - } - return NextResponse.json( - { - error: { - code: ERROR_CODES.BOOK.NOT_FOUND, - name: "Book file fetch error", - message: getErrorMessage(ERROR_CODES.BOOK.NOT_FOUND), - } as AppError, - }, - { status: 500 } - ); - } -} - diff --git a/src/components/ui/book-offline-button.tsx b/src/components/ui/book-offline-button.tsx index c8b167c..2d27bd3 100644 --- a/src/components/ui/book-offline-button.tsx +++ b/src/components/ui/book-offline-button.tsx @@ -6,7 +6,6 @@ import { Button } from "./button"; import { useToast } from "./use-toast"; import type { KomgaBook } from "@/types/komga"; import logger from "@/lib/logger"; -import { unzip } from "fflate"; interface BookOfflineButtonProps { book: KomgaBook; @@ -58,101 +57,92 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) { // Marque le début du téléchargement setBookStatus(book.id, { status: "downloading", - progress: 0, + progress: ((startFromPage - 1) / book.media.pagesCount) * 100, timestamp: Date.now(), - lastDownloadedPage: 0, + lastDownloadedPage: startFromPage - 1, }); - // Télécharger le fichier complet - setDownloadProgress(5); - const fileResponse = await fetch(`/api/komga/books/${book.id}/file`); - if (!fileResponse.ok) throw new Error("Erreur lors du téléchargement du fichier"); - - setDownloadProgress(20); - const arrayBuffer = await fileResponse.arrayBuffer(); - - setDownloadProgress(30); - setBookStatus(book.id, { - status: "downloading", - progress: 30, - timestamp: Date.now(), - }); - - // Décompresser le fichier - const files = await new Promise>((resolve, reject) => { - unzip(new Uint8Array(arrayBuffer), (err, data) => { - if (err) reject(err); - else resolve(data); - }); - }); - - setDownloadProgress(50); - - // Filtrer et trier les images - const imageExtensions = /\.(jpg|jpeg|png|webp|gif)$/i; - const imageFiles = Object.entries(files) - .filter(([name]) => imageExtensions.test(name)) - .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })); - - if (imageFiles.length === 0) { - throw new Error("Aucune image trouvée dans le fichier"); + // Ajoute le livre au cache si on commence depuis le début + if (startFromPage === 1) { + const pagesResponse = await fetch(`/api/komga/images/books/${book.id}/pages/1`); + if (!pagesResponse.ok) throw new Error("Erreur lors de la récupération des pages"); + await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesResponse.clone()); } - // Cache chaque image - for (let i = 0; i < imageFiles.length; i++) { - const [fileName, data] = imageFiles[i]; - const pageNumber = i + 1; + // Cache chaque page avec retry + let failedPages = 0; + for (let i = startFromPage; i <= book.media.pagesCount; i++) { + let retryCount = 0; + const maxRetries = 3; - // Déterminer le type MIME - const ext = fileName.toLowerCase().split(".").pop(); - const mimeType = - ext === "png" - ? "image/png" - : ext === "webp" - ? "image/webp" - : ext === "gif" - ? "image/gif" - : "image/jpeg"; - - const blob = new Blob([data], { type: mimeType }); - const fakeResponse = new Response(blob, { - headers: { - "Content-Type": mimeType, - "Content-Length": String(data.length), - }, - }); - - await cache.put(`/api/komga/images/books/${book.id}/pages/${pageNumber}`, fakeResponse); + while (retryCount < maxRetries) { + try { + const pageResponse = await fetch(`/api/komga/images/books/${book.id}/pages/${i}`); + if (!pageResponse.ok) { + retryCount++; + if (retryCount === maxRetries) { + failedPages++; + logger.error( + `Échec du téléchargement de la page ${i} après ${maxRetries} tentatives` + ); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); // Attendre 1s avant de réessayer + continue; + } + await cache.put( + `/api/komga/images/books/${book.id}/pages/${i}`, + pageResponse.clone() + ); + break; // Sortir de la boucle si réussi + } catch (error) { + retryCount++; + if (retryCount === maxRetries) { + failedPages++; + logger.error({ err: error }, `Erreur lors du téléchargement de la page ${i}:`); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } // Mise à jour du statut - const progress = 50 + ((i + 1) / imageFiles.length) * 50; + const progress = (i / book.media.pagesCount) * 100; setDownloadProgress(progress); setBookStatus(book.id, { status: "downloading", progress, timestamp: Date.now(), - lastDownloadedPage: pageNumber, + lastDownloadedPage: i, }); // Vérifier si le statut a changé pendant le téléchargement const currentStatus = getBookStatus(book.id); if (currentStatus.status === "idle") { + // Le téléchargement a été annulé throw new Error("Téléchargement annulé"); } } - // Marquer comme disponible - const pagesMetaResponse = new Response(JSON.stringify({ count: imageFiles.length }), { - headers: { "Content-Type": "application/json" }, - }); - await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesMetaResponse); - - setIsAvailableOffline(true); - setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() }); - toast({ - title: "Livre téléchargé", - description: `${imageFiles.length} pages disponibles hors ligne`, - }); + if (failedPages > 0) { + // Si des pages ont échoué, on supprime tout le cache pour ce livre + await cache.delete(`/api/komga/images/books/${book.id}/pages`); + for (let i = 1; i <= book.media.pagesCount; i++) { + await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`); + } + setIsAvailableOffline(false); + setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() }); + toast({ + title: "Erreur", + description: `${failedPages} page(s) n'ont pas pu être téléchargées. Le livre ne sera pas disponible hors ligne.`, + variant: "destructive", + }); + } else { + setIsAvailableOffline(true); + setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() }); + toast({ + title: "Livre téléchargé", + description: "Le livre est maintenant disponible hors ligne", + }); + } } catch (error) { logger.error({ err: error }, "Erreur lors du téléchargement:"); // Ne pas changer le statut si le téléchargement a été volontairement annulé @@ -169,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) { setDownloadProgress(0); } }, - [book.id, getBookStatus, setBookStatus, toast] + [book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast] ); const checkOfflineAvailability = useCallback(async () => { diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 3007003..01ec23e 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -115,27 +115,6 @@ export class BookService extends BaseApiService { } } - static async getFile(bookId: string): Promise { - try { - const config = await this.getKomgaConfig(); - const url = this.buildUrl(config, `books/${bookId}/file`); - const headers = this.getAuthHeaders(config); - - const response = await fetch(url, { - method: "GET", - headers, - }); - - if (!response.ok) { - throw new AppError(ERROR_CODES.BOOK.NOT_FOUND); - } - - return response; - } catch (error) { - throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error); - } - } - static async getCover(bookId: string): Promise { try { // Récupérer les préférences de l'utilisateur