refactor: remove caching-related API endpoints and configurations, update preferences structure, and clean up unused services
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m22s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m22s
This commit is contained in:
@@ -1,19 +1,10 @@
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import type { CacheType } from "@/types/cache";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import type { ServerCacheService } from "./server-cache.service";
|
||||
import { RequestMonitorService } from "./request-monitor.service";
|
||||
import { RequestQueueService } from "./request-queue.service";
|
||||
import { CircuitBreakerService } from "./circuit-breaker.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export type { CacheType };
|
||||
|
||||
interface KomgaRequestInit extends RequestInit {
|
||||
isImage?: boolean;
|
||||
noJson?: boolean;
|
||||
@@ -25,68 +16,7 @@ interface KomgaUrlBuilder {
|
||||
}
|
||||
|
||||
export abstract class BaseApiService {
|
||||
private static requestQueueInitialized = false;
|
||||
private static circuitBreakerInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialise le RequestQueueService avec les préférences de l'utilisateur
|
||||
*/
|
||||
private static async initializeRequestQueue(): Promise<void> {
|
||||
if (this.requestQueueInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Configurer le getter qui récupère dynamiquement la valeur depuis les préférences
|
||||
RequestQueueService.setMaxConcurrentGetter(async () => {
|
||||
try {
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
return preferences.komgaMaxConcurrentRequests;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to get preferences for request queue");
|
||||
return 5; // Valeur par défaut
|
||||
}
|
||||
});
|
||||
|
||||
this.requestQueueInitialized = true;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to initialize request queue");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le CircuitBreakerService avec les préférences de l'utilisateur
|
||||
*/
|
||||
private static async initializeCircuitBreaker(): Promise<void> {
|
||||
if (this.circuitBreakerInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Configurer le getter qui récupère dynamiquement la config depuis les préférences
|
||||
CircuitBreakerService.setConfigGetter(async () => {
|
||||
try {
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
return preferences.circuitBreakerConfig;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to get preferences for circuit breaker");
|
||||
return {
|
||||
threshold: 5,
|
||||
timeout: 30000,
|
||||
resetTimeout: 60000,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
this.circuitBreakerInitialized = true;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to initialize circuit breaker");
|
||||
}
|
||||
}
|
||||
|
||||
protected static async getKomgaConfig(): Promise<AuthConfig> {
|
||||
// Initialiser les services si ce n'est pas déjà fait
|
||||
await Promise.all([this.initializeRequestQueue(), this.initializeCircuitBreaker()]);
|
||||
try {
|
||||
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||
if (!config) {
|
||||
@@ -117,22 +47,6 @@ export abstract class BaseApiService {
|
||||
});
|
||||
}
|
||||
|
||||
protected static async fetchWithCache<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
type: CacheType = "DEFAULT"
|
||||
): Promise<T> {
|
||||
const cacheService: ServerCacheService = await getServerCacheService();
|
||||
|
||||
try {
|
||||
const result = await cacheService.getOrSet(key, fetcher, type);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected static buildUrl(
|
||||
config: AuthConfig,
|
||||
path: string,
|
||||
@@ -159,12 +73,6 @@ export abstract class BaseApiService {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected static async resolveWithFallback(url: string): Promise<string> {
|
||||
// DNS resolution is only needed server-side and causes build issues
|
||||
// The fetch API will handle DNS resolution automatically
|
||||
return url;
|
||||
}
|
||||
|
||||
protected static async fetchFromApi<T>(
|
||||
urlBuilder: KomgaUrlBuilder,
|
||||
headersOptions = {},
|
||||
@@ -172,7 +80,7 @@ export abstract class BaseApiService {
|
||||
): Promise<T> {
|
||||
const config: AuthConfig = await this.getKomgaConfig();
|
||||
const { path, params } = urlBuilder;
|
||||
const url = await this.resolveWithFallback(this.buildUrl(config, path, params));
|
||||
const url = this.buildUrl(config, path, params);
|
||||
|
||||
const headers: Headers = this.getAuthHeaders(config);
|
||||
if (headersOptions) {
|
||||
@@ -185,10 +93,6 @@ export abstract class BaseApiService {
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
const queueStats = {
|
||||
active: RequestQueueService.getActiveCount(),
|
||||
queued: RequestQueueService.getQueueLength(),
|
||||
};
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
@@ -196,74 +100,64 @@ export abstract class BaseApiService {
|
||||
params,
|
||||
isImage: options.isImage,
|
||||
noJson: options.noJson,
|
||||
queue: queueStats,
|
||||
},
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
|
||||
// Timeout réduit à 15 secondes pour éviter les blocages longs
|
||||
// Timeout de 15 secondes pour éviter les blocages longs
|
||||
const timeoutMs = 15000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
// Utiliser le circuit breaker pour éviter de surcharger Komga
|
||||
const response = await CircuitBreakerService.execute(async () => {
|
||||
// Enqueue la requête pour limiter la concurrence
|
||||
return await RequestQueueService.enqueue(async () => {
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// Configure undici connection timeouts
|
||||
// @ts-ignore - undici-specific options not in standard fetch types
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
} catch (fetchError: any) {
|
||||
// Gestion spécifique des erreurs DNS
|
||||
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
|
||||
logger.error(
|
||||
`DNS resolution failed for ${url}. Retrying with different DNS settings...`
|
||||
);
|
||||
let response: Response;
|
||||
|
||||
// Retry avec des paramètres DNS différents
|
||||
return await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Force IPv4 si IPv6 pose problème
|
||||
// @ts-ignore
|
||||
family: 4,
|
||||
});
|
||||
}
|
||||
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||
|
||||
return await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
throw fetchError;
|
||||
}
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options not in standard fetch types
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
});
|
||||
} catch (fetchError: any) {
|
||||
// Gestion spécifique des erreurs DNS
|
||||
if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Force IPv4 si IPv6 pose problème
|
||||
// @ts-ignore
|
||||
family: 4,
|
||||
});
|
||||
} else if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-ignore - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
});
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
@@ -320,7 +214,6 @@ export abstract class BaseApiService {
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
RequestMonitorService.decrementActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaBookWithPages, TTLConfig } from "@/types/komga";
|
||||
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
||||
import type { ImageResponse } from "./image.service";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { SeriesService } from "./series.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
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 BookService extends BaseApiService {
|
||||
private static async getImageCacheMaxAge(): Promise<number> {
|
||||
try {
|
||||
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
|
||||
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
|
||||
return maxAge;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
|
||||
return 2592000; // 30 jours par défaut en cas d'erreur
|
||||
}
|
||||
}
|
||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaBookWithPages>(
|
||||
`book-${bookId}`,
|
||||
async () => {
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
||||
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
||||
]);
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` }),
|
||||
this.fetchFromApi<{ number: number }[]>({ path: `books/${bookId}/pages` }),
|
||||
]);
|
||||
|
||||
return {
|
||||
book,
|
||||
pages: pages.map((page: any) => page.number),
|
||||
};
|
||||
},
|
||||
"BOOKS"
|
||||
);
|
||||
return {
|
||||
book,
|
||||
pages: pages.map((page: any) => page.number),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
||||
try {
|
||||
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant
|
||||
@@ -63,7 +49,6 @@ export class BookService extends BaseApiService {
|
||||
|
||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||
try {
|
||||
// Récupérer le livre sans cache pour éviter les données obsolètes
|
||||
const book = await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}` });
|
||||
return book.seriesId;
|
||||
} catch (error) {
|
||||
@@ -136,12 +121,10 @@ export class BookService extends BaseApiService {
|
||||
response.buffer.byteOffset + response.buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
return new Response(arrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -153,7 +136,6 @@ export class BookService extends BaseApiService {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
||||
if (preferences.showThumbnails) {
|
||||
@@ -161,7 +143,7 @@ export class BookService extends BaseApiService {
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -186,12 +168,11 @@ export class BookService extends BaseApiService {
|
||||
const response: ImageResponse = await ImageService.getImage(
|
||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
||||
);
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Circuit Breaker pour éviter de surcharger Komga quand il est défaillant
|
||||
* Évite l'effet avalanche en coupant les requêtes vers un service défaillant
|
||||
*/
|
||||
import type { CircuitBreakerConfig } from "@/types/preferences";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface CircuitBreakerState {
|
||||
state: "CLOSED" | "OPEN" | "HALF_OPEN";
|
||||
failureCount: number;
|
||||
lastFailureTime: number;
|
||||
nextAttemptTime: number;
|
||||
}
|
||||
|
||||
class CircuitBreaker {
|
||||
private state: CircuitBreakerState = {
|
||||
state: "CLOSED",
|
||||
failureCount: 0,
|
||||
lastFailureTime: 0,
|
||||
nextAttemptTime: 0,
|
||||
};
|
||||
|
||||
private config = {
|
||||
failureThreshold: 5, // Nombre d'échecs avant ouverture
|
||||
recoveryTimeout: 30000, // 30s avant tentative de récupération
|
||||
resetTimeout: 60000, // Délai de reset après échec
|
||||
};
|
||||
|
||||
private getConfigFromPreferences: (() => Promise<CircuitBreakerConfig>) | null = null;
|
||||
|
||||
/**
|
||||
* Configure une fonction pour récupérer dynamiquement la config depuis les préférences
|
||||
*/
|
||||
setConfigGetter(getter: () => Promise<CircuitBreakerConfig>): void {
|
||||
this.getConfigFromPreferences = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la config actuelle, soit depuis les préférences, soit depuis les valeurs par défaut
|
||||
*/
|
||||
private async getCurrentConfig(): Promise<typeof this.config> {
|
||||
if (this.getConfigFromPreferences) {
|
||||
try {
|
||||
const prefConfig = await this.getConfigFromPreferences();
|
||||
return {
|
||||
failureThreshold: prefConfig.threshold ?? 5,
|
||||
recoveryTimeout: prefConfig.timeout ?? 30000,
|
||||
resetTimeout: prefConfig.resetTimeout ?? 60000,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Error getting circuit breaker config from preferences");
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const config = await this.getCurrentConfig();
|
||||
|
||||
if (this.state.state === "OPEN") {
|
||||
if (Date.now() < this.state.nextAttemptTime) {
|
||||
throw new Error("Circuit breaker is OPEN - Komga service unavailable");
|
||||
}
|
||||
this.state.state = "HALF_OPEN";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.onFailure(config);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private onSuccess(): void {
|
||||
if (this.state.state === "HALF_OPEN") {
|
||||
this.state.failureCount = 0;
|
||||
this.state.state = "CLOSED";
|
||||
logger.info("[CIRCUIT-BREAKER] ✅ Circuit closed - Komga recovered");
|
||||
}
|
||||
}
|
||||
|
||||
private async onFailure(config: typeof this.config): Promise<void> {
|
||||
this.state.failureCount++;
|
||||
this.state.lastFailureTime = Date.now();
|
||||
|
||||
if (this.state.failureCount >= config.failureThreshold) {
|
||||
this.state.state = "OPEN";
|
||||
this.state.nextAttemptTime = Date.now() + config.resetTimeout;
|
||||
logger.warn(
|
||||
`[CIRCUIT-BREAKER] 🔴 Circuit OPEN - Komga failing (${this.state.failureCount} failures, reset in ${config.resetTimeout}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getState(): CircuitBreakerState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = {
|
||||
state: "CLOSED",
|
||||
failureCount: 0,
|
||||
lastFailureTime: 0,
|
||||
nextAttemptTime: 0,
|
||||
};
|
||||
logger.info("[CIRCUIT-BREAKER] 🔄 Circuit reset");
|
||||
}
|
||||
}
|
||||
|
||||
export const CircuitBreakerService = new CircuitBreaker();
|
||||
@@ -2,7 +2,7 @@ import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { User, KomgaConfigData, TTLConfigData, KomgaConfig, TTLConfig } from "@/types/komga";
|
||||
import type { User, KomgaConfigData, KomgaConfig } from "@/types/komga";
|
||||
|
||||
export class ConfigDBService {
|
||||
private static async getCurrentUser(): Promise<User> {
|
||||
@@ -62,58 +62,4 @@ export class ConfigDBService {
|
||||
throw new AppError(ERROR_CODES.CONFIG.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getTTLConfig(): Promise<TTLConfig | null> {
|
||||
try {
|
||||
const user: User | null = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const config = await prisma.tTLConfig.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
return config as TTLConfig | null;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.CONFIG.TTL_FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async saveTTLConfig(data: TTLConfigData): Promise<TTLConfig> {
|
||||
try {
|
||||
const user: User | null = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const config = await prisma.tTLConfig.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
defaultTTL: data.defaultTTL,
|
||||
homeTTL: data.homeTTL,
|
||||
librariesTTL: data.librariesTTL,
|
||||
seriesTTL: data.seriesTTL,
|
||||
booksTTL: data.booksTTL,
|
||||
imagesTTL: data.imagesTTL,
|
||||
imageCacheMaxAge: data.imageCacheMaxAge,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
defaultTTL: data.defaultTTL,
|
||||
homeTTL: data.homeTTL,
|
||||
librariesTTL: data.librariesTTL,
|
||||
seriesTTL: data.seriesTTL,
|
||||
booksTTL: data.booksTTL,
|
||||
imagesTTL: data.imagesTTL,
|
||||
imageCacheMaxAge: data.imageCacheMaxAge,
|
||||
},
|
||||
});
|
||||
|
||||
return config as TTLConfig;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.CONFIG.TTL_SAVE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
@@ -12,80 +11,55 @@ export class HomeService extends BaseApiService {
|
||||
static async getHomeData(): Promise<HomeData> {
|
||||
try {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
||||
"home-ongoing",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-ongoing-books",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readProgress.readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-recently-read",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
"home-on-deck",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchWithCache<LibraryResponse<KomgaSeries>>(
|
||||
"home-latest-series",
|
||||
async () =>
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
"HOME"
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readProgress.readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>({
|
||||
path: "series/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ongoing: ongoing.content || [],
|
||||
ongoingBooks: ongoingBooks.content || [], // Nouveau champ
|
||||
ongoingBooks: ongoingBooks.content || [],
|
||||
recentlyRead: recentlyRead.content || [],
|
||||
onDeck: onDeck.content || [],
|
||||
latestSeries: latestSeries.content || [],
|
||||
@@ -97,17 +71,4 @@ export class HomeService extends BaseApiService {
|
||||
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async invalidateHomeCache(): Promise<void> {
|
||||
try {
|
||||
const cacheService = await getServerCacheService();
|
||||
await cacheService.delete("home-ongoing");
|
||||
await cacheService.delete("home-ongoing-books"); // Nouvelle clé de cache
|
||||
await cacheService.delete("home-recently-read");
|
||||
await cacheService.delete("home-on-deck");
|
||||
await cacheService.delete("home-latest-series");
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { Series } from "@/types/series";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
@@ -9,11 +8,7 @@ import type { KomgaLibrary } from "@/types/komga";
|
||||
export class LibraryService extends BaseApiService {
|
||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaLibrary[]>(
|
||||
"libraries",
|
||||
async () => this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" }),
|
||||
"LIBRARIES"
|
||||
);
|
||||
return this.fetchFromApi<KomgaLibrary[]>({ path: "libraries" });
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
@@ -87,35 +82,24 @@ export class LibraryService extends BaseApiService {
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
// Clé de cache incluant tous les paramètres
|
||||
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${
|
||||
search || ""
|
||||
}`;
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.titleSort,asc",
|
||||
};
|
||||
|
||||
const response = await this.fetchWithCache<LibraryResponse<Series>>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.titleSort,asc",
|
||||
};
|
||||
// Filtre de recherche Komga (recherche dans le titre)
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
// Filtre de recherche Komga (recherche dans le titre)
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
return this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
},
|
||||
"SERIES"
|
||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
|
||||
// Filtrer uniquement les séries supprimées côté client (léger)
|
||||
@@ -131,17 +115,6 @@ export class LibraryService extends BaseApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static async invalidateLibrarySeriesCache(libraryId: string): Promise<void> {
|
||||
try {
|
||||
const cacheService = await getServerCacheService();
|
||||
// Invalider toutes les clés de cache pour cette bibliothèque
|
||||
// Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search}
|
||||
await cacheService.deleteAll(`library-${libraryId}-series-`);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||
try {
|
||||
await this.fetchFromApi(
|
||||
|
||||
@@ -2,11 +2,7 @@ import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type {
|
||||
UserPreferences,
|
||||
BackgroundPreferences,
|
||||
CircuitBreakerConfig,
|
||||
} from "@/types/preferences";
|
||||
import type { UserPreferences, BackgroundPreferences } from "@/types/preferences";
|
||||
import { defaultPreferences } from "@/types/preferences";
|
||||
import type { User } from "@/types/komga";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
@@ -37,7 +33,6 @@ export class PreferencesService {
|
||||
|
||||
return {
|
||||
showThumbnails: preferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: preferences.showOnlyUnread,
|
||||
displayMode: {
|
||||
...defaultPreferences.displayMode,
|
||||
@@ -45,9 +40,7 @@ export class PreferencesService {
|
||||
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
|
||||
},
|
||||
background: preferences.background as unknown as BackgroundPreferences,
|
||||
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests,
|
||||
readerPrefetchCount: preferences.readerPrefetchCount,
|
||||
circuitBreakerConfig: preferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
@@ -65,17 +58,12 @@ export class PreferencesService {
|
||||
const updateData: Record<string, any> = {};
|
||||
if (preferences.showThumbnails !== undefined)
|
||||
updateData.showThumbnails = preferences.showThumbnails;
|
||||
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
|
||||
if (preferences.showOnlyUnread !== undefined)
|
||||
updateData.showOnlyUnread = preferences.showOnlyUnread;
|
||||
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
|
||||
if (preferences.background !== undefined) updateData.background = preferences.background;
|
||||
if (preferences.komgaMaxConcurrentRequests !== undefined)
|
||||
updateData.komgaMaxConcurrentRequests = preferences.komgaMaxConcurrentRequests;
|
||||
if (preferences.readerPrefetchCount !== undefined)
|
||||
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
||||
if (preferences.circuitBreakerConfig !== undefined)
|
||||
updateData.circuitBreakerConfig = preferences.circuitBreakerConfig;
|
||||
|
||||
const updatedPreferences = await prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
@@ -83,28 +71,20 @@ export class PreferencesService {
|
||||
create: {
|
||||
userId,
|
||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
|
||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||
background: (preferences.background ??
|
||||
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
||||
circuitBreakerConfig: (preferences.circuitBreakerConfig ??
|
||||
defaultPreferences.circuitBreakerConfig) as unknown as Prisma.InputJsonValue,
|
||||
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests ?? 5,
|
||||
readerPrefetchCount: preferences.readerPrefetchCount ?? 5,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
showThumbnails: updatedPreferences.showThumbnails,
|
||||
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
|
||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||
background: updatedPreferences.background as unknown as BackgroundPreferences,
|
||||
komgaMaxConcurrentRequests: updatedPreferences.komgaMaxConcurrentRequests,
|
||||
readerPrefetchCount: updatedPreferences.readerPrefetchCount,
|
||||
circuitBreakerConfig:
|
||||
updatedPreferences.circuitBreakerConfig as unknown as CircuitBreakerConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Service de monitoring des requêtes concurrentes vers Komga
|
||||
* Permet de tracker le nombre de requêtes actives et d'alerter en cas de charge élevée
|
||||
*/
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
class RequestMonitor {
|
||||
private activeRequests = 0;
|
||||
private readonly thresholds = {
|
||||
warning: 10,
|
||||
high: 20,
|
||||
critical: 30,
|
||||
};
|
||||
|
||||
incrementActive(): number {
|
||||
this.activeRequests++;
|
||||
this.checkThresholds();
|
||||
return this.activeRequests;
|
||||
}
|
||||
|
||||
decrementActive(): number {
|
||||
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
||||
return this.activeRequests;
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.activeRequests;
|
||||
}
|
||||
|
||||
private checkThresholds(): void {
|
||||
const count = this.activeRequests;
|
||||
|
||||
if (count >= this.thresholds.critical) {
|
||||
logger.warn(`[REQUEST-MONITOR] 🔴 CRITICAL concurrency: ${count} active requests`);
|
||||
} else if (count >= this.thresholds.high) {
|
||||
logger.warn(`[REQUEST-MONITOR] ⚠️ HIGH concurrency: ${count} active requests`);
|
||||
} else if (count >= this.thresholds.warning) {
|
||||
logger.info(`[REQUEST-MONITOR] ⚡ Warning concurrency: ${count} active requests`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const RequestMonitorService = new RequestMonitor();
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Service de gestion de queue pour limiter les requêtes concurrentes vers Komga
|
||||
* Évite de surcharger Komga avec trop de requêtes simultanées
|
||||
*/
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface QueuedRequest<T> {
|
||||
execute: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: any) => void;
|
||||
}
|
||||
|
||||
class RequestQueue {
|
||||
private queue: QueuedRequest<any>[] = [];
|
||||
private activeCount = 0;
|
||||
private maxConcurrent: number;
|
||||
private getMaxConcurrent: (() => Promise<number>) | null = null;
|
||||
|
||||
constructor(maxConcurrent?: number) {
|
||||
// Valeur par défaut
|
||||
this.maxConcurrent = maxConcurrent ?? 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure une fonction pour récupérer dynamiquement le max concurrent depuis les préférences
|
||||
*/
|
||||
setMaxConcurrentGetter(getter: () => Promise<number>): void {
|
||||
this.getMaxConcurrent = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la valeur de maxConcurrent, soit depuis les préférences, soit depuis la valeur fixe
|
||||
*/
|
||||
private async getCurrentMaxConcurrent(): Promise<number> {
|
||||
if (this.getMaxConcurrent) {
|
||||
try {
|
||||
return await this.getMaxConcurrent();
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Error getting maxConcurrent from preferences, using default");
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
}
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
|
||||
async enqueue<T>(execute: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
// Limiter la taille de la queue pour éviter l'accumulation
|
||||
if (this.queue.length >= 50) {
|
||||
reject(new Error("Request queue is full - Komga may be overloaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue.push({ execute, resolve, reject });
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
const maxConcurrent = await this.getCurrentMaxConcurrent();
|
||||
if (this.activeCount >= maxConcurrent || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCount++;
|
||||
const request = this.queue.shift();
|
||||
|
||||
if (!request) {
|
||||
this.activeCount--;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Délai adaptatif : plus long si la queue est pleine
|
||||
// Désactivé en mode debug pour ne pas ralentir les tests
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
if (!isDebug) {
|
||||
const delayMs = this.queue.length > 10 ? 500 : 200;
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
const result = await request.execute();
|
||||
request.resolve(result);
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
} finally {
|
||||
this.activeCount--;
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.activeCount;
|
||||
}
|
||||
|
||||
getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
setMaxConcurrent(max: number): void {
|
||||
this.maxConcurrent = max;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance - Par défaut limite à 5 requêtes simultanées
|
||||
export const RequestQueueService = new RequestQueue(5);
|
||||
@@ -1,50 +1,27 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { KomgaBook, KomgaSeries, TTLConfig } from "@/types/komga";
|
||||
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 { ConfigDBService } from "./config-db.service";
|
||||
import { getServerCacheService } from "./server-cache.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import type { ServerCacheService } from "./server-cache.service";
|
||||
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 {
|
||||
private static async getImageCacheMaxAge(): Promise<number> {
|
||||
try {
|
||||
const ttlConfig: TTLConfig | null = await ConfigDBService.getTTLConfig();
|
||||
const maxAge = ttlConfig?.imageCacheMaxAge ?? 2592000;
|
||||
return maxAge;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "[ImageCache] Error fetching TTL config");
|
||||
return 2592000; // 30 jours par défaut en cas d'erreur
|
||||
}
|
||||
}
|
||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||
try {
|
||||
return this.fetchWithCache<KomgaSeries>(
|
||||
`series-${seriesId}`,
|
||||
async () => this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` }),
|
||||
"SERIES"
|
||||
);
|
||||
return this.fetchFromApi<KomgaSeries>({ path: `series/${seriesId}` });
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async invalidateSeriesCache(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const cacheService = await getServerCacheService();
|
||||
await cacheService.delete(`series-${seriesId}`);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSeriesBooks(
|
||||
seriesId: string,
|
||||
page: number = 0,
|
||||
@@ -96,28 +73,19 @@ export class SeriesService extends BaseApiService {
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
// Clé de cache incluant tous les paramètres
|
||||
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "number,asc",
|
||||
};
|
||||
|
||||
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "number,asc",
|
||||
};
|
||||
|
||||
return this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{ path: "books/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
},
|
||||
"BOOKS"
|
||||
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{ path: "books/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
}
|
||||
);
|
||||
|
||||
// Filtrer uniquement les livres supprimés côté client (léger)
|
||||
@@ -133,36 +101,17 @@ export class SeriesService extends BaseApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static async invalidateSeriesBooksCache(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const cacheService: ServerCacheService = await getServerCacheService();
|
||||
// Invalider toutes les clés de cache pour cette série
|
||||
// Format: series-{id}-books-p{page}-s{size}-u{unread}
|
||||
await cacheService.deleteAll(`series-${seriesId}-books-`);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getFirstBook(seriesId: string): Promise<string> {
|
||||
try {
|
||||
return this.fetchWithCache<string>(
|
||||
`series-first-book-${seriesId}`,
|
||||
async () => {
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<
|
||||
LibraryResponse<KomgaBook>
|
||||
>({
|
||||
path: `series/${seriesId}/books`,
|
||||
params: { page: "0", size: "1" },
|
||||
});
|
||||
if (!data.content || data.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
}
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: `series/${seriesId}/books`,
|
||||
params: { page: "0", size: "1" },
|
||||
});
|
||||
if (!data.content || data.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
}
|
||||
|
||||
return data.content[0].id;
|
||||
},
|
||||
"SERIES"
|
||||
);
|
||||
return data.content[0].id;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
@@ -173,7 +122,6 @@ export class SeriesService extends BaseApiService {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
const maxAge = await this.getImageCacheMaxAge();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature
|
||||
if (preferences.showThumbnails) {
|
||||
@@ -181,7 +129,7 @@ export class SeriesService extends BaseApiService {
|
||||
return new Response(response.buffer.buffer as ArrayBuffer, {
|
||||
headers: {
|
||||
"Content-Type": response.contentType || "image/jpeg",
|
||||
"Cache-Control": `public, max-age=${maxAge}, immutable`,
|
||||
"Cache-Control": `public, max-age=${IMAGE_CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,730 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { getCurrentUser } from "../auth-utils";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export type CacheMode = "file" | "memory";
|
||||
|
||||
interface CacheConfig {
|
||||
mode: CacheMode;
|
||||
}
|
||||
|
||||
class ServerCacheService {
|
||||
private static instance: ServerCacheService;
|
||||
private cacheDir: string;
|
||||
private memoryCache: Map<string, { data: unknown; expiry: number }> = new Map();
|
||||
private config: CacheConfig = {
|
||||
mode: "memory",
|
||||
};
|
||||
|
||||
// Configuration des temps de cache en millisecondes
|
||||
private static readonly oneMinute = 1 * 60 * 1000;
|
||||
private static readonly twoMinutes = 2 * 60 * 1000;
|
||||
private static readonly fiveMinutes = 5 * 60 * 1000;
|
||||
private static readonly tenMinutes = 10 * 60 * 1000;
|
||||
private static readonly twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
private static readonly noCache = 0;
|
||||
|
||||
// Configuration des temps de cache
|
||||
// Optimisé pour la pagination native Komga :
|
||||
// - Listes paginées (SERIES, BOOKS) : TTL court (2 min) car données fraîches + progression utilisateur
|
||||
// - Données agrégées (HOME) : TTL moyen (10 min) car plusieurs sources
|
||||
// - Données statiques (LIBRARIES) : TTL long (24h) car changent rarement
|
||||
// - Images : TTL très long (7 jours) car immuables
|
||||
private static readonly DEFAULT_TTL = {
|
||||
DEFAULT: ServerCacheService.fiveMinutes,
|
||||
HOME: ServerCacheService.tenMinutes,
|
||||
LIBRARIES: ServerCacheService.twentyFourHours,
|
||||
SERIES: ServerCacheService.twoMinutes, // Listes paginées avec progression
|
||||
BOOKS: ServerCacheService.twoMinutes, // Listes paginées avec progression
|
||||
IMAGES: ServerCacheService.oneWeek,
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
this.cacheDir = path.join(process.cwd(), ".cache");
|
||||
this.ensureCacheDirectory();
|
||||
this.cleanExpiredCache();
|
||||
this.initializeCacheMode();
|
||||
}
|
||||
|
||||
private async initializeCacheMode(): Promise<void> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
this.setCacheMode("memory");
|
||||
return;
|
||||
}
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
this.setCacheMode(preferences.cacheMode);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Error initializing cache mode from preferences");
|
||||
// Keep default memory mode if preferences can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCacheDirectory(): void {
|
||||
if (!fs.existsSync(this.cacheDir)) {
|
||||
fs.mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheFilePath(key: string): string {
|
||||
// Nettoyer la clé des caractères spéciaux et des doubles slashes
|
||||
const sanitizedKey = key.replace(/[<>:"|?*]/g, "_").replace(/\/+/g, "/");
|
||||
|
||||
const filePath = path.join(this.cacheDir, `${sanitizedKey}.json`);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private cleanExpiredCache(): void {
|
||||
if (!fs.existsSync(this.cacheDir)) return;
|
||||
|
||||
const cleanDirectory = (dirPath: string): boolean => {
|
||||
if (!fs.existsSync(dirPath)) return true;
|
||||
|
||||
const items = fs.readdirSync(dirPath);
|
||||
let isEmpty = true;
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const isSubDirEmpty = cleanDirectory(itemPath);
|
||||
if (isSubDirEmpty) {
|
||||
try {
|
||||
fs.rmdirSync(itemPath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, path: itemPath },
|
||||
`Could not remove directory ${itemPath}`
|
||||
);
|
||||
isEmpty = false;
|
||||
}
|
||||
} else {
|
||||
isEmpty = false;
|
||||
}
|
||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
||||
try {
|
||||
const content = fs.readFileSync(itemPath, "utf-8");
|
||||
const cached = JSON.parse(content);
|
||||
if (cached.expiry < Date.now()) {
|
||||
fs.unlinkSync(itemPath);
|
||||
} else {
|
||||
isEmpty = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
||||
// Si le fichier est corrompu, on le supprime
|
||||
try {
|
||||
fs.unlinkSync(itemPath);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
|
||||
isEmpty = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEmpty = false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
||||
// En cas d'erreur sur le fichier/dossier, on continue
|
||||
isEmpty = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
cleanDirectory(this.cacheDir);
|
||||
}
|
||||
|
||||
public static async getInstance(): Promise<ServerCacheService> {
|
||||
if (!ServerCacheService.instance) {
|
||||
ServerCacheService.instance = new ServerCacheService();
|
||||
await ServerCacheService.instance.initializeCacheMode();
|
||||
}
|
||||
return ServerCacheService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le TTL pour un type de données spécifique
|
||||
*/
|
||||
public getTTL(type: keyof typeof ServerCacheService.DEFAULT_TTL): number {
|
||||
// Utiliser directement la valeur par défaut
|
||||
return ServerCacheService.DEFAULT_TTL[type];
|
||||
}
|
||||
|
||||
public setCacheMode(mode: CacheMode): void {
|
||||
if (this.config.mode === mode) return;
|
||||
|
||||
// Si on passe de mémoire à fichier, on sauvegarde le cache en mémoire
|
||||
if (mode === "file" && this.config.mode === "memory") {
|
||||
this.memoryCache.forEach((value, key) => {
|
||||
if (value.expiry > Date.now()) {
|
||||
this.saveToFile(key, value);
|
||||
}
|
||||
});
|
||||
this.memoryCache.clear();
|
||||
}
|
||||
// Si on passe de fichier à mémoire, on charge le cache fichier en mémoire
|
||||
else if (mode === "memory" && this.config.mode === "file") {
|
||||
this.loadFileCacheToMemory();
|
||||
}
|
||||
|
||||
this.config.mode = mode;
|
||||
}
|
||||
|
||||
public getCacheMode(): CacheMode {
|
||||
return this.config.mode;
|
||||
}
|
||||
|
||||
private loadFileCacheToMemory(): void {
|
||||
if (!fs.existsSync(this.cacheDir)) return;
|
||||
|
||||
const loadDirectory = (dirPath: string) => {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
loadDirectory(itemPath);
|
||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
||||
try {
|
||||
const content = fs.readFileSync(itemPath, "utf-8");
|
||||
const cached = JSON.parse(content);
|
||||
if (cached.expiry > Date.now()) {
|
||||
const key = path.relative(this.cacheDir, itemPath).slice(0, -5); // Remove .json
|
||||
this.memoryCache.set(key, cached);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
||||
// Ignore les fichiers corrompus
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
||||
// Ignore les erreurs d'accès
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadDirectory(this.cacheDir);
|
||||
}
|
||||
|
||||
private saveToFile(key: string, value: { data: unknown; expiry: number }): void {
|
||||
const filePath = this.getCacheFilePath(key);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(value), "utf-8");
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: filePath }, `Could not write cache file ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en cache des données avec une durée de vie
|
||||
*/
|
||||
set(key: string, data: any, type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"): void {
|
||||
const cacheData = {
|
||||
data,
|
||||
expiry: Date.now() + this.getTTL(type),
|
||||
};
|
||||
|
||||
if (this.config.mode === "memory") {
|
||||
this.memoryCache.set(key, cacheData);
|
||||
} else {
|
||||
const filePath = this.getCacheFilePath(key);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(cacheData), "utf-8");
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: filePath }, `Error writing cache file ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère des données du cache si elles sont valides
|
||||
*/
|
||||
get(key: string): any | null {
|
||||
if (this.config.mode === "memory") {
|
||||
const cached = this.memoryCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
if (cached.expiry > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
this.memoryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = this.getCacheFilePath(key);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const cached = JSON.parse(content);
|
||||
|
||||
if (cached.expiry > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère des données du cache même si elles sont expirées (stale)
|
||||
* Retourne { data, isStale } ou null si pas de cache
|
||||
*/
|
||||
private getStale(key: string): { data: any; isStale: boolean } | null {
|
||||
if (this.config.mode === "memory") {
|
||||
const cached = this.memoryCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
return {
|
||||
data: cached.data,
|
||||
isStale: cached.expiry <= Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = this.getCacheFilePath(key);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const cached = JSON.parse(content);
|
||||
|
||||
return {
|
||||
data: cached.data,
|
||||
isStale: cached.expiry <= Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: filePath }, `Error reading cache file ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une entrée du cache
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
throw new Error("Utilisateur non authentifié");
|
||||
}
|
||||
const cacheKey = `${user.id}-${key}`;
|
||||
|
||||
if (this.config.mode === "memory") {
|
||||
this.memoryCache.delete(cacheKey);
|
||||
} else {
|
||||
const filePath = this.getCacheFilePath(cacheKey);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime toutes les entrées du cache qui commencent par un préfixe
|
||||
*/
|
||||
async deleteAll(prefix: string): Promise<void> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
throw new Error("Utilisateur non authentifié");
|
||||
}
|
||||
const prefixKey = `${user.id}-${prefix}`;
|
||||
|
||||
if (this.config.mode === "memory") {
|
||||
this.memoryCache.forEach((value, key) => {
|
||||
if (key.startsWith(prefixKey)) {
|
||||
this.memoryCache.delete(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// En mode fichier, parcourir récursivement tous les fichiers et supprimer ceux qui correspondent
|
||||
if (!fs.existsSync(this.cacheDir)) return;
|
||||
|
||||
const deleteMatchingFiles = (dirPath: string): void => {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
deleteMatchingFiles(itemPath);
|
||||
// Supprimer le répertoire s'il est vide après suppression des fichiers
|
||||
try {
|
||||
const remainingItems = fs.readdirSync(itemPath);
|
||||
if (remainingItems.length === 0) {
|
||||
fs.rmdirSync(itemPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore les erreurs de suppression de répertoire
|
||||
}
|
||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
||||
// Extraire la clé du chemin relatif (sans l'extension .json)
|
||||
const relativePath = path.relative(this.cacheDir, itemPath);
|
||||
const key = relativePath.slice(0, -5).replace(/\\/g, "/"); // Remove .json and normalize slashes
|
||||
|
||||
if (key.startsWith(prefixKey)) {
|
||||
fs.unlinkSync(itemPath);
|
||||
if (process.env.CACHE_DEBUG === "true") {
|
||||
logger.debug(`🗑️ [CACHE DELETE] ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not delete cache file ${itemPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
deleteMatchingFiles(this.cacheDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide le cache
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.config.mode === "memory") {
|
||||
this.memoryCache.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.cacheDir)) return;
|
||||
|
||||
const removeDirectory = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) return;
|
||||
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
removeDirectory(itemPath);
|
||||
try {
|
||||
fs.rmdirSync(itemPath);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, path: itemPath },
|
||||
`Could not remove directory ${itemPath}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
fs.unlinkSync(itemPath);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not remove file ${itemPath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Error accessing ${itemPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
removeDirectory(this.cacheDir);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Error clearing cache");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère des données du cache ou exécute la fonction si nécessaire
|
||||
* Stratégie stale-while-revalidate:
|
||||
* - Cache valide → retourne immédiatement
|
||||
* - Cache expiré → retourne le cache expiré ET revalide en background
|
||||
* - Pas de cache → fetch normalement
|
||||
*/
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
type: keyof typeof ServerCacheService.DEFAULT_TTL = "DEFAULT"
|
||||
): Promise<T> {
|
||||
const startTime = performance.now();
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
throw new Error("Utilisateur non authentifié");
|
||||
}
|
||||
|
||||
const cacheKey = `${user.id}-${key}`;
|
||||
const cachedResult = this.getStale(cacheKey);
|
||||
|
||||
if (cachedResult !== null) {
|
||||
const { data, isStale } = cachedResult;
|
||||
const endTime = performance.now();
|
||||
|
||||
// Debug logging
|
||||
if (process.env.CACHE_DEBUG === "true") {
|
||||
const icon = isStale ? "⚠️" : "✅";
|
||||
const status = isStale ? "STALE" : "HIT";
|
||||
logger.debug(
|
||||
`${icon} [CACHE ${status}] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
|
||||
);
|
||||
}
|
||||
|
||||
// Si le cache est expiré, revalider en background sans bloquer la réponse
|
||||
if (isStale) {
|
||||
// Fire and forget - revalidate en background
|
||||
this.revalidateInBackground(cacheKey, fetcher, type, key);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// Pas de cache du tout, fetch normalement
|
||||
if (process.env.CACHE_DEBUG === "true") {
|
||||
logger.debug(`❌ [CACHE MISS] ${key} | ${type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetcher();
|
||||
this.set(cacheKey, data, type);
|
||||
|
||||
const endTime = performance.now();
|
||||
if (process.env.CACHE_DEBUG === "true") {
|
||||
logger.debug(`💾 [CACHE SET] ${key} | ${type} | ${(endTime - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalide le cache en background
|
||||
*/
|
||||
private async revalidateInBackground<T>(
|
||||
cacheKey: string,
|
||||
fetcher: () => Promise<T>,
|
||||
type: keyof typeof ServerCacheService.DEFAULT_TTL,
|
||||
debugKey: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const data = await fetcher();
|
||||
this.set(cacheKey, data, type);
|
||||
|
||||
if (process.env.CACHE_DEBUG === "true") {
|
||||
const endTime = performance.now();
|
||||
logger.debug(
|
||||
`🔄 [CACHE REVALIDATE] ${debugKey} | ${type} | ${(endTime - startTime).toFixed(2)}ms`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, key: debugKey }, `🔴 [CACHE REVALIDATE ERROR] ${debugKey}`);
|
||||
// Ne pas relancer l'erreur car c'est en background
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(key: string): void {
|
||||
this.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la taille approximative d'un objet en mémoire
|
||||
*/
|
||||
private calculateObjectSize(obj: unknown): number {
|
||||
if (obj === null || obj === undefined) return 0;
|
||||
|
||||
// Si c'est un Buffer, utiliser sa taille réelle
|
||||
if (Buffer.isBuffer(obj)) {
|
||||
return obj.length;
|
||||
}
|
||||
|
||||
// Si c'est un objet avec une propriété buffer (comme ImageResponse)
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
const objAny = obj as any;
|
||||
if (objAny.buffer && Buffer.isBuffer(objAny.buffer)) {
|
||||
// Taille du buffer + taille approximative des autres propriétés
|
||||
let size = objAny.buffer.length;
|
||||
// Ajouter la taille du contentType si présent
|
||||
if (objAny.contentType && typeof objAny.contentType === "string") {
|
||||
size += objAny.contentType.length * 2; // UTF-16
|
||||
}
|
||||
return size;
|
||||
}
|
||||
}
|
||||
|
||||
// Pour les autres types, utiliser JSON.stringify comme approximation
|
||||
try {
|
||||
return JSON.stringify(obj).length * 2; // x2 pour UTF-16
|
||||
} catch {
|
||||
// Si l'objet n'est pas sérialisable, retourner une estimation
|
||||
return 1000; // 1KB par défaut
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la taille du cache
|
||||
*/
|
||||
async getCacheSize(): Promise<{ sizeInBytes: number; itemCount: number }> {
|
||||
if (this.config.mode === "memory") {
|
||||
// Calculer la taille approximative en mémoire
|
||||
let sizeInBytes = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
this.memoryCache.forEach((value) => {
|
||||
if (value.expiry > Date.now()) {
|
||||
itemCount++;
|
||||
// Calculer la taille du data + expiry (8 bytes pour le timestamp)
|
||||
sizeInBytes += this.calculateObjectSize(value.data) + 8;
|
||||
}
|
||||
});
|
||||
|
||||
return { sizeInBytes, itemCount };
|
||||
}
|
||||
|
||||
// Calculer la taille du cache sur disque
|
||||
let sizeInBytes = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
const calculateDirectorySize = (dirPath: string): void => {
|
||||
if (!fs.existsSync(dirPath)) return;
|
||||
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
calculateDirectorySize(itemPath);
|
||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
||||
sizeInBytes += stats.size;
|
||||
itemCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (fs.existsSync(this.cacheDir)) {
|
||||
calculateDirectorySize(this.cacheDir);
|
||||
}
|
||||
|
||||
return { sizeInBytes, itemCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les entrées du cache avec leurs détails
|
||||
*/
|
||||
async getCacheEntries(): Promise<
|
||||
Array<{
|
||||
key: string;
|
||||
size: number;
|
||||
expiry: number;
|
||||
isExpired: boolean;
|
||||
}>
|
||||
> {
|
||||
const entries: Array<{
|
||||
key: string;
|
||||
size: number;
|
||||
expiry: number;
|
||||
isExpired: boolean;
|
||||
}> = [];
|
||||
|
||||
if (this.config.mode === "memory") {
|
||||
this.memoryCache.forEach((value, key) => {
|
||||
const size = this.calculateObjectSize(value.data) + 8;
|
||||
entries.push({
|
||||
key,
|
||||
size,
|
||||
expiry: value.expiry,
|
||||
isExpired: value.expiry <= Date.now(),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const collectEntries = (dirPath: string): void => {
|
||||
if (!fs.existsSync(dirPath)) return;
|
||||
|
||||
const items = fs.readdirSync(dirPath);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
collectEntries(itemPath);
|
||||
} else if (stats.isFile() && item.endsWith(".json")) {
|
||||
try {
|
||||
const content = fs.readFileSync(itemPath, "utf-8");
|
||||
const cached = JSON.parse(content);
|
||||
const key = path.relative(this.cacheDir, itemPath).slice(0, -5);
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
size: stats.size,
|
||||
expiry: cached.expiry,
|
||||
isExpired: cached.expiry <= Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not parse file ${itemPath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error, path: itemPath }, `Could not access ${itemPath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (fs.existsSync(this.cacheDir)) {
|
||||
collectEntries(this.cacheDir);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => b.expiry - a.expiry);
|
||||
}
|
||||
}
|
||||
|
||||
// Créer une instance initialisée du service
|
||||
let initializedInstance: Promise<ServerCacheService>;
|
||||
|
||||
export const getServerCacheService = async (): Promise<ServerCacheService> => {
|
||||
if (!initializedInstance) {
|
||||
initializedInstance = ServerCacheService.getInstance();
|
||||
}
|
||||
return initializedInstance;
|
||||
};
|
||||
|
||||
// Exporter aussi la classe pour les tests
|
||||
export { ServerCacheService };
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Génère l'URL de base pour une image (sans cache version)
|
||||
* Utilisez useImageUrl() dans les composants pour obtenir l'URL avec cache busting
|
||||
* Génère l'URL pour une image (thumbnail de série ou de livre)
|
||||
*/
|
||||
export function getImageUrl(type: "series" | "book", id: string) {
|
||||
if (type === "series") {
|
||||
|
||||
Reference in New Issue
Block a user