feat: add fflate library for file decompression and implement file download functionality in BookOfflineButton component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m18s

This commit is contained in:
Julien Froidefond
2026-01-04 11:32:48 +01:00
parent 0d33462349
commit 1ffe99285d
5 changed files with 161 additions and 65 deletions

View File

@@ -32,6 +32,7 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fflate": "^0.8.2",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",

8
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
fflate:
specifier: ^0.8.2
version: 0.8.2
framer-motion: framer-motion:
specifier: ^12.23.24 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) 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: picomatch:
optional: true optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@@ -4977,6 +4983,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fflate@0.8.2: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0

View File

@@ -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 }
);
}
}

View File

@@ -6,6 +6,7 @@ import { Button } from "./button";
import { useToast } from "./use-toast"; import { useToast } from "./use-toast";
import type { KomgaBook } from "@/types/komga"; import type { KomgaBook } from "@/types/komga";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { unzip } from "fflate";
interface BookOfflineButtonProps { interface BookOfflineButtonProps {
book: KomgaBook; book: KomgaBook;
@@ -57,92 +58,101 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Marque le début du téléchargement // Marque le début du téléchargement
setBookStatus(book.id, { setBookStatus(book.id, {
status: "downloading", status: "downloading",
progress: ((startFromPage - 1) / book.media.pagesCount) * 100, progress: 0,
timestamp: Date.now(), timestamp: Date.now(),
lastDownloadedPage: startFromPage - 1, lastDownloadedPage: 0,
}); });
// Ajoute le livre au cache si on commence depuis le début // Télécharger le fichier complet
if (startFromPage === 1) { setDownloadProgress(5);
const pagesResponse = await fetch(`/api/komga/images/books/${book.id}/pages/1`); const fileResponse = await fetch(`/api/komga/books/${book.id}/file`);
if (!pagesResponse.ok) throw new Error("Erreur lors de la récupération des pages"); if (!fileResponse.ok) throw new Error("Erreur lors du téléchargement du fichier");
await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesResponse.clone());
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<Record<string, Uint8Array>>((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 // Cache chaque image
let failedPages = 0; for (let i = 0; i < imageFiles.length; i++) {
for (let i = startFromPage; i <= book.media.pagesCount; i++) { const [fileName, data] = imageFiles[i];
let retryCount = 0; const pageNumber = i + 1;
const maxRetries = 3;
while (retryCount < maxRetries) { // Déterminer le type MIME
try { const ext = fileName.toLowerCase().split(".").pop();
const pageResponse = await fetch(`/api/komga/images/books/${book.id}/pages/${i}`); const mimeType =
if (!pageResponse.ok) { ext === "png"
retryCount++; ? "image/png"
if (retryCount === maxRetries) { : ext === "webp"
failedPages++; ? "image/webp"
logger.error( : ext === "gif"
`Échec du téléchargement de la page ${i} après ${maxRetries} tentatives` ? "image/gif"
); : "image/jpeg";
}
await new Promise((resolve) => setTimeout(resolve, 1000)); // Attendre 1s avant de réessayer const blob = new Blob([data], { type: mimeType });
continue; const fakeResponse = new Response(blob, {
} headers: {
await cache.put( "Content-Type": mimeType,
`/api/komga/images/books/${book.id}/pages/${i}`, "Content-Length": String(data.length),
pageResponse.clone() },
); });
break; // Sortir de la boucle si réussi
} catch (error) { await cache.put(`/api/komga/images/books/${book.id}/pages/${pageNumber}`, fakeResponse);
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 // Mise à jour du statut
const progress = (i / book.media.pagesCount) * 100; const progress = 50 + ((i + 1) / imageFiles.length) * 50;
setDownloadProgress(progress); setDownloadProgress(progress);
setBookStatus(book.id, { setBookStatus(book.id, {
status: "downloading", status: "downloading",
progress, progress,
timestamp: Date.now(), timestamp: Date.now(),
lastDownloadedPage: i, lastDownloadedPage: pageNumber,
}); });
// Vérifier si le statut a changé pendant le téléchargement // Vérifier si le statut a changé pendant le téléchargement
const currentStatus = getBookStatus(book.id); const currentStatus = getBookStatus(book.id);
if (currentStatus.status === "idle") { if (currentStatus.status === "idle") {
// Le téléchargement a été annulé
throw new Error("Téléchargement annulé"); throw new Error("Téléchargement annulé");
} }
} }
if (failedPages > 0) { // Marquer comme disponible
// Si des pages ont échoué, on supprime tout le cache pour ce livre const pagesMetaResponse = new Response(JSON.stringify({ count: imageFiles.length }), {
await cache.delete(`/api/komga/images/books/${book.id}/pages`); headers: { "Content-Type": "application/json" },
for (let i = 1; i <= book.media.pagesCount; i++) { });
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`); await cache.put(`/api/komga/images/books/${book.id}/pages`, pagesMetaResponse);
}
setIsAvailableOffline(false); setIsAvailableOffline(true);
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() }); setBookStatus(book.id, { status: "available", progress: 100, timestamp: Date.now() });
toast({ toast({
title: "Erreur", title: "Livre téléchargé",
description: `${failedPages} page(s) n'ont pas pu être téléchargées. Le livre ne sera pas disponible hors ligne.`, description: `${imageFiles.length} pages disponibles 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) { } catch (error) {
logger.error({ err: error }, "Erreur lors du téléchargement:"); logger.error({ err: error }, "Erreur lors du téléchargement:");
// Ne pas changer le statut si le téléchargement a été volontairement annulé // 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); setDownloadProgress(0);
} }
}, },
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast] [book.id, getBookStatus, setBookStatus, toast]
); );
const checkOfflineAvailability = useCallback(async () => { const checkOfflineAvailability = useCallback(async () => {

View File

@@ -115,6 +115,27 @@ export class BookService extends BaseApiService {
} }
} }
static async getFile(bookId: string): Promise<Response> {
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<Response> { static async getCover(bookId: string): Promise<Response> {
try { try {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur