refactor: streamline image handling by implementing direct streaming in BookService and ImageService, and update .gitignore to include temp directory
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ prisma/data/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
temp/
|
||||||
@@ -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) }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user