From 0d7d27ef82a3d61fdf7c03160e1a51ac56bab7d1 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sat, 3 Jan 2026 22:03:35 +0100 Subject: [PATCH] refactor: streamline image handling by implementing direct streaming in BookService and ImageService, and update .gitignore to include temp directory --- .gitignore | 4 ++- src/lib/services/book.service.ts | 52 +++++++++--------------------- src/lib/services/image.service.ts | 34 +++++++++++-------- src/lib/services/series.service.ts | 19 +++-------- 4 files changed, 42 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index af53e33..e389998 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,6 @@ mongo-keyfile prisma/data/ *.db *.sqlite -*.sqlite3 \ No newline at end of file +*.sqlite3 + +temp/ \ No newline at end of file diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 3836c66..01ec23e 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -1,14 +1,10 @@ import { BaseApiService } from "./base-api.service"; import type { KomgaBook, KomgaBookWithPages } from "@/types/komga"; -import type { ImageResponse } from "./image.service"; import { ImageService } from "./image.service"; import { PreferencesService } from "./preferences.service"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; -// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas) -const IMAGE_CACHE_MAX_AGE = 2592000; - export class BookService extends BaseApiService { static async getBook(bookId: string): Promise { try { @@ -110,22 +106,10 @@ export class BookService extends BaseApiService { try { // Ajuster le numéro de page pour l'API Komga (zero-based) const adjustedPageNumber = pageNumber - 1; - const response: ImageResponse = await ImageService.getImage( + // Stream directement sans buffer en mémoire + return ImageService.streamImage( `books/${bookId}/pages/${adjustedPageNumber}?zero_based=true` ); - - // Convertir le Buffer Node.js en ArrayBuffer proprement - const arrayBuffer = response.buffer.buffer.slice( - response.buffer.byteOffset, - response.buffer.byteOffset + response.buffer.byteLength - ) as ArrayBuffer; - - return new Response(arrayBuffer, { - headers: { - "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`, - }, - }); } catch (error) { throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); } @@ -136,18 +120,12 @@ export class BookService extends BaseApiService { // Récupérer les préférences de l'utilisateur const preferences = await PreferencesService.getPreferences(); - // Si l'utilisateur préfère les vignettes, utiliser la miniature + // Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming) if (preferences.showThumbnails) { - const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`); - return new Response(response.buffer.buffer as ArrayBuffer, { - headers: { - "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`, - }, - }); + return ImageService.streamImage(`books/${bookId}/thumbnail`); } - // Sinon, récupérer la première page + // Sinon, récupérer la première page (streaming) return this.getPage(bookId, 1); } catch (error) { throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); @@ -164,16 +142,10 @@ export class BookService extends BaseApiService { static async getPageThumbnail(bookId: string, pageNumber: number): Promise { try { - const response: ImageResponse = await ImageService.getImage( + // Stream directement sans buffer en mémoire + return ImageService.streamImage( `books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true` ); - - return new Response(response.buffer.buffer as ArrayBuffer, { - headers: { - "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`, - }, - }); } catch (error) { throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error); } @@ -207,8 +179,14 @@ export class BookService extends BaseApiService { }, }; - const booksResponse = await this.fetchFromApi<{ content: KomgaBook[]; totalElements: number }>( - { path: "books/list", params: { page: String(randomPage), size: "20", sort: "number,asc" } }, + const booksResponse = await this.fetchFromApi<{ + content: KomgaBook[]; + totalElements: number; + }>( + { + path: "books/list", + params: { page: String(randomPage), size: "20", sort: "number,asc" }, + }, { "Content-Type": "application/json" }, { method: "POST", body: JSON.stringify(searchBody) } ); diff --git a/src/lib/services/image.service.ts b/src/lib/services/image.service.ts index 43d3efb..892574a 100644 --- a/src/lib/services/image.service.ts +++ b/src/lib/services/image.service.ts @@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; import logger from "@/lib/logger"; -export interface ImageResponse { - buffer: Buffer; - contentType: string | null; -} +// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas) +const IMAGE_CACHE_MAX_AGE = 2592000; export class ImageService extends BaseApiService { - static async getImage(path: string): Promise { + /** + * Stream an image directly from Komga without buffering in memory + * Returns a Response that can be directly returned to the client + */ + static async streamImage( + path: string, + cacheMaxAge: number = IMAGE_CACHE_MAX_AGE + ): Promise { try { const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" }; - // NE PAS mettre en cache - les images sont trop grosses et les Buffers ne sérialisent pas bien const response = await this.fetchFromApi({ path }, headers, { isImage: true }); - const contentType = response.headers.get("content-type"); - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - return { - buffer, - contentType, - }; + // Stream the response body directly without buffering + return new Response(response.body, { + status: response.status, + headers: { + "Content-Type": response.headers.get("content-type") || "image/jpeg", + "Content-Length": response.headers.get("content-length") || "", + "Cache-Control": `public, max-age=${cacheMaxAge}, immutable`, + }, + }); } catch (error) { - logger.error({ err: error }, "Erreur lors de la récupération de l'image"); + logger.error({ err: error }, "Erreur lors du streaming de l'image"); throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error); } } diff --git a/src/lib/services/series.service.ts b/src/lib/services/series.service.ts index f0d0d88..2cf7b78 100644 --- a/src/lib/services/series.service.ts +++ b/src/lib/services/series.service.ts @@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service"; import type { LibraryResponse } from "@/types/library"; import type { KomgaBook, KomgaSeries } from "@/types/komga"; import { BookService } from "./book.service"; -import type { ImageResponse } from "./image.service"; import { ImageService } from "./image.service"; import { PreferencesService } from "./preferences.service"; import { ERROR_CODES } from "../../constants/errorCodes"; @@ -10,9 +9,6 @@ import { AppError } from "../../utils/errors"; import type { UserPreferences } from "@/types/preferences"; import logger from "@/lib/logger"; -// Cache HTTP navigateur : 30 jours (immutable car les images ne changent pas) -const IMAGE_CACHE_MAX_AGE = 2592000; - export class SeriesService extends BaseApiService { static async getSeries(seriesId: string): Promise { try { @@ -123,21 +119,14 @@ export class SeriesService extends BaseApiService { // Récupérer les préférences de l'utilisateur const preferences: UserPreferences = await PreferencesService.getPreferences(); - // Si l'utilisateur préfère les vignettes, utiliser la miniature + // Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming) if (preferences.showThumbnails) { - const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`); - return new Response(response.buffer.buffer as ArrayBuffer, { - headers: { - "Content-Type": response.contentType || "image/jpeg", - "Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`, - }, - }); + return ImageService.streamImage(`series/${seriesId}/thumbnail`); } - // Sinon, récupérer la première page + // Sinon, récupérer la première page (streaming) const firstBookId = await this.getFirstBook(seriesId); - const response = await BookService.getPage(firstBookId, 1); - return response; + return BookService.getPage(firstBookId, 1); } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); }