refactor: streamline image handling by implementing direct streaming in BookService and ImageService, and update .gitignore to include temp directory

This commit is contained in:
Julien Froidefond
2026-01-03 22:03:35 +01:00
parent e903b55a46
commit 0d7d27ef82
4 changed files with 42 additions and 67 deletions

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ prisma/data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
temp/

View File

@@ -1,14 +1,10 @@
import { BaseApiService } from "./base-api.service"; import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga"; import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; 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 { export class BookService extends BaseApiService {
static async getBook(bookId: string): Promise<KomgaBookWithPages> { static async getBook(bookId: string): Promise<KomgaBookWithPages> {
try { try {
@@ -110,22 +106,10 @@ export class BookService extends BaseApiService {
try { try {
// Ajuster le numéro de page pour l'API Komga (zero-based) // Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber - 1; 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` `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) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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 // Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences(); 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) { if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`books/${bookId}/thumbnail`); return ImageService.streamImage(`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`,
},
});
} }
// Sinon, récupérer la première page // Sinon, récupérer la première page (streaming)
return this.getPage(bookId, 1); return this.getPage(bookId, 1);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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<Response> { static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
try { 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` `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) { } catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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 }>( const booksResponse = await this.fetchFromApi<{
{ path: "books/list", params: { page: String(randomPage), size: "20", sort: "number,asc" } }, content: KomgaBook[];
totalElements: number;
}>(
{
path: "books/list",
params: { page: String(randomPage), size: "20", sort: "number,asc" },
},
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) } { method: "POST", body: JSON.stringify(searchBody) }
); );

View File

@@ -3,28 +3,34 @@ import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export interface ImageResponse { // Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
buffer: Buffer; const IMAGE_CACHE_MAX_AGE = 2592000;
contentType: string | null;
}
export class ImageService extends BaseApiService { export class ImageService extends BaseApiService {
static async getImage(path: string): Promise<ImageResponse> { /**
* 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<Response> {
try { try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" }; 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<Response>({ path }, headers, { isImage: true }); const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
const contentType = response.headers.get("content-type");
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return { // Stream the response body directly without buffering
buffer, return new Response(response.body, {
contentType, 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) { } 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); throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
} }
} }

View File

@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { BookService } from "./book.service"; import { BookService } from "./book.service";
import type { ImageResponse } from "./image.service";
import { ImageService } from "./image.service"; import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service"; import { PreferencesService } from "./preferences.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
@@ -10,9 +9,6 @@ import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import logger from "@/lib/logger"; 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 { export class SeriesService extends BaseApiService {
static async getSeries(seriesId: string): Promise<KomgaSeries> { static async getSeries(seriesId: string): Promise<KomgaSeries> {
try { try {
@@ -123,21 +119,14 @@ export class SeriesService extends BaseApiService {
// Récupérer les préférences de l'utilisateur // Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences(); 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) { if (preferences.showThumbnails) {
const response: ImageResponse = await ImageService.getImage(`series/${seriesId}/thumbnail`); return ImageService.streamImage(`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`,
},
});
} }
// Sinon, récupérer la première page // Sinon, récupérer la première page (streaming)
const firstBookId = await this.getFirstBook(seriesId); const firstBookId = await this.getFirstBook(seriesId);
const response = await BookService.getPage(firstBookId, 1); return BookService.getPage(firstBookId, 1);
return response;
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
} }