From 1ffe99285d6098256ff9f2bb47e87674ac5b96f3 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 4 Jan 2026 11:32:48 +0100 Subject: [PATCH] feat: add fflate library for file decompression and implement file download functionality in BookOfflineButton component --- 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, 161 insertions(+), 65 deletions(-) create mode 100644 src/app/api/komga/books/[bookId]/file/route.ts diff --git a/package.json b/package.json index edc286e..b31925c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "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 66720ac..49df579 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ 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) @@ -1895,6 +1898,9 @@ 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} @@ -4977,6 +4983,8 @@ 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 new file mode 100644 index 0000000..369ab3c --- /dev/null +++ b/src/app/api/komga/books/[bookId]/file/route.ts @@ -0,0 +1,56 @@ +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 2d27bd3..c8b167c 100644 --- a/src/components/ui/book-offline-button.tsx +++ b/src/components/ui/book-offline-button.tsx @@ -6,6 +6,7 @@ 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; @@ -57,92 +58,101 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) { // Marque le début du téléchargement setBookStatus(book.id, { status: "downloading", - progress: ((startFromPage - 1) / book.media.pagesCount) * 100, + progress: 0, timestamp: Date.now(), - lastDownloadedPage: startFromPage - 1, + lastDownloadedPage: 0, }); - // 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()); + // 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"); } - // Cache chaque page avec retry - let failedPages = 0; - for (let i = startFromPage; i <= book.media.pagesCount; i++) { - let retryCount = 0; - const maxRetries = 3; + // Cache chaque image + for (let i = 0; i < imageFiles.length; i++) { + const [fileName, data] = imageFiles[i]; + const pageNumber = i + 1; - 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)); - } - } + // 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); // Mise à jour du statut - const progress = (i / book.media.pagesCount) * 100; + const progress = 50 + ((i + 1) / imageFiles.length) * 50; setDownloadProgress(progress); setBookStatus(book.id, { status: "downloading", progress, timestamp: Date.now(), - lastDownloadedPage: i, + lastDownloadedPage: pageNumber, }); // 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é"); } } - 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", - }); - } + // 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`, + }); } 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é @@ -159,7 +169,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) { setDownloadProgress(0); } }, - [book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast] + [book.id, getBookStatus, setBookStatus, toast] ); const checkOfflineAvailability = useCallback(async () => { diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 01ec23e..3007003 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -115,6 +115,27 @@ 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