import { BaseApiService } from "./base-api.service"; import type { LibraryResponse } from "@/types/library"; import type { KomgaBook, KomgaSeries, TTLConfig } 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"; export class SeriesService extends BaseApiService { private static async getImageCacheMaxAge(): Promise { 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 { try { return this.fetchWithCache( `series-${seriesId}`, async () => this.fetchFromApi({ path: `series/${seriesId}` }), "SERIES" ); } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); } } static async invalidateSeriesCache(seriesId: string): Promise { try { const cacheService = await getServerCacheService(); await cacheService.delete(`series-${seriesId}`); } catch (error) { throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); } } static async getAllSeriesBooks(seriesId: string): Promise { try { const headers = { "Content-Type": "application/json" }; const searchBody = { condition: { seriesId: { operator: "is", value: seriesId, }, }, }; const cacheKey = `series-${seriesId}-all-books`; const response = await this.fetchWithCache>( cacheKey, async () => this.fetchFromApi>( { path: "books/list", params: { size: "1000", // On récupère un maximum de livres }, }, headers, { method: "POST", body: JSON.stringify(searchBody), } ), "BOOKS" ); if (!response.content.length) { throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND); } return response.content; } catch (error) { if (error instanceof AppError) { throw error; } throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); } } static async getSeriesBooks( seriesId: string, page: number = 0, size: number = 24, unreadOnly: boolean = false ): Promise> { try { const headers = { "Content-Type": "application/json" }; // Construction du body de recherche pour Komga const condition: Record = { seriesId: { operator: "is", value: seriesId, }, }; // Filtre unread natif Komga (readStatus != READ) if (unreadOnly) { condition.readStatus = { operator: "isNot", value: "READ", }; } const searchBody = { condition }; // Pour le filtre unread, on récupère plus d'éléments car on filtre aussi les deleted côté client // Estimation : ~10% des livres sont supprimés, donc on récupère légèrement plus const fetchSize = unreadOnly ? size : size; // Clé de cache incluant tous les paramètres const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`; const response = await this.fetchWithCache>( cacheKey, async () => { const params: Record = { page: String(page), size: String(fetchSize), sort: "number,asc", }; return this.fetchFromApi>( { path: "books/list", params }, headers, { method: "POST", body: JSON.stringify(searchBody), } ); }, "BOOKS" ); // Filtrer les livres supprimés côté client (léger) let filteredContent = response.content.filter((book: KomgaBook) => !book.deleted); // Si on a filtré des livres supprimés, prendre uniquement les `size` premiers if (filteredContent.length > size) { filteredContent = filteredContent.slice(0, size); } // Note: Les totaux (totalElements, totalPages) restent ceux de Komga // Ils sont approximatifs après filtrage côté client mais fonctionnels pour la pagination // Le filtrage côté client est léger (seulement deleted) return { ...response, content: filteredContent, numberOfElements: filteredContent.length, // Garder totalElements et totalPages de Komga pour la pagination // Ils seront légèrement inexacts mais fonctionnels }; } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); } } static async invalidateSeriesBooksCache(seriesId: string): Promise { 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-`); // Invalider aussi l'ancienne clé pour compatibilité await cacheService.delete(`series-${seriesId}-all-books`); } catch (error) { throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); } } static async getFirstBook(seriesId: string): Promise { try { return this.fetchWithCache( `series-first-book-${seriesId}`, async () => { const data: LibraryResponse = await this.fetchFromApi< LibraryResponse >({ 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" ); } 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); } } static async getCover(seriesId: string): Promise { 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) { 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=${maxAge}, immutable`, }, }); } // Sinon, récupérer la première page const firstBookId = await this.getFirstBook(seriesId); const response = await BookService.getPage(firstBookId, 1); return response; } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); } } static getCoverUrl(seriesId: string): string { return `/api/komga/images/series/${seriesId}/thumbnail`; } static async getMultipleSeries(seriesIds: string[]): Promise { try { const seriesPromises: Promise[] = seriesIds.map((id: string) => this.getSeries(id) ); const series: KomgaSeries[] = await Promise.all(seriesPromises); return series.filter(Boolean); } catch (error) { throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); } } }