refactor: enhance library and series services to improve API data fetching and caching mechanisms, including client-side filtering for deleted items and unread status

This commit is contained in:
Julien Froidefond
2025-12-07 11:12:47 +01:00
parent 4c4ebf2b06
commit 0bbc92b0e4
3 changed files with 531 additions and 103 deletions

View File

@@ -82,69 +82,74 @@ export class LibraryService extends BaseApiService {
search?: string
): Promise<LibraryResponse<Series>> {
try {
// Récupérer toutes les séries depuis le cache
const allSeries = await this.getAllLibrarySeries(libraryId);
const headers = { "Content-Type": "application/json" };
// Filtrer les séries
let filteredSeries = allSeries;
// Construction du body de recherche pour Komga
const condition: Record<string, any> = {
libraryId: {
operator: "is",
value: libraryId,
},
};
// Filtrer les séries supprimées (fichiers manquants sur le filesystem)
filteredSeries = filteredSeries.filter((series) => !series.deleted);
const searchBody = { condition };
// Pour le filtre unread, on récupère plus d'éléments car on filtre côté client
// Estimation : ~50% des séries sont unread, donc on récupère 2x pour être sûr
const fetchSize = unreadOnly ? size * 2 : size;
// Clé de cache incluant tous les paramètres
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${
search || ""
}`;
const response = await this.fetchWithCache<LibraryResponse<Series>>(
cacheKey,
async () => {
const params: Record<string, string> = {
page: String(page),
size: String(fetchSize),
sort: "metadata.titleSort,asc",
};
// 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"
);
// Filtrer les séries supprimées côté client (léger)
let filteredContent = response.content.filter((series) => !series.deleted);
// Filtre unread côté client (Komga n'a pas de filtre natif pour booksReadCount < booksCount)
if (unreadOnly) {
filteredSeries = filteredSeries.filter(
filteredContent = filteredContent.filter(
(series) => series.booksReadCount < series.booksCount
);
// Prendre uniquement les `size` premiers après filtrage
filteredContent = filteredContent.slice(0, size);
}
if (search) {
const searchLower = search.toLowerCase();
filteredSeries = filteredSeries.filter(
(series) =>
series.metadata.title.toLowerCase().includes(searchLower) ||
series.id.toLowerCase().includes(searchLower)
);
}
// Trier les séries
filteredSeries.sort((a, b) => a.metadata.titleSort.localeCompare(b.metadata.titleSort));
// Calculer la pagination
const totalElements = filteredSeries.length;
const totalPages = Math.ceil(totalElements / size);
const startIndex = page * size;
const endIndex = Math.min(startIndex + size, totalElements);
const paginatedSeries = filteredSeries.slice(startIndex, endIndex);
// Construire la réponse
// 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 + unread)
return {
content: paginatedSeries,
empty: paginatedSeries.length === 0,
first: page === 0,
last: page >= totalPages - 1,
number: page,
numberOfElements: paginatedSeries.length,
pageable: {
offset: startIndex,
pageNumber: page,
pageSize: size,
paged: true,
sort: {
empty: false,
sorted: true,
unsorted: false,
},
unpaged: false,
},
size,
sort: {
empty: false,
sorted: true,
unsorted: false,
},
totalElements,
totalPages,
...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);
@@ -154,8 +159,11 @@ export class LibraryService extends BaseApiService {
static async invalidateLibrarySeriesCache(libraryId: string): Promise<void> {
try {
const cacheService = await getServerCacheService();
const cacheKey = `library-${libraryId}-all-series`;
await cacheService.delete(cacheKey);
// 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-`);
// Invalider aussi l'ancienne clé pour compatibilité
await cacheService.delete(`library-${libraryId}-all-series`);
} catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
}

View File

@@ -98,59 +98,71 @@ export class SeriesService extends BaseApiService {
unreadOnly: boolean = false
): Promise<LibraryResponse<KomgaBook>> {
try {
// Récupérer tous les livres depuis le cache
const allBooks: KomgaBook[] = await this.getAllSeriesBooks(seriesId);
const headers = { "Content-Type": "application/json" };
// Filtrer les livres
let filteredBooks = allBooks;
// Filtrer les livres supprimés (fichiers manquants sur le filesystem)
filteredBooks = filteredBooks.filter((book: KomgaBook) => !book.deleted);
// Construction du body de recherche pour Komga
const condition: Record<string, any> = {
seriesId: {
operator: "is",
value: seriesId,
},
};
// Filtre unread natif Komga (readStatus != READ)
if (unreadOnly) {
filteredBooks = filteredBooks.filter(
(book: KomgaBook) => !book.readProgress || !book.readProgress.completed
);
condition.readStatus = {
operator: "isNot",
value: "READ",
};
}
// Trier les livres par numéro
filteredBooks.sort((a: KomgaBook, b: KomgaBook) => a.number - b.number);
const searchBody = { condition };
// Calculer la pagination
const totalElements = filteredBooks.length;
const totalPages = Math.ceil(totalElements / size);
const startIndex = page * size;
const endIndex = Math.min(startIndex + size, totalElements);
const paginatedBooks = filteredBooks.slice(startIndex, endIndex);
// 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;
// Construire la réponse
// Clé de cache incluant tous les paramètres
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
cacheKey,
async () => {
const params: Record<string, string> = {
page: String(page),
size: String(fetchSize),
sort: "number,asc",
};
return this.fetchFromApi<LibraryResponse<KomgaBook>>(
{ 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 {
content: paginatedBooks,
empty: paginatedBooks.length === 0,
first: page === 0,
last: page >= totalPages - 1,
number: page,
numberOfElements: paginatedBooks.length,
pageable: {
offset: startIndex,
pageNumber: page,
pageSize: size,
paged: true,
sort: {
empty: false,
sorted: true,
unsorted: false,
},
unpaged: false,
},
size,
sort: {
empty: false,
sorted: true,
unsorted: false,
},
totalElements,
totalPages,
...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);
@@ -158,8 +170,16 @@ export class SeriesService extends BaseApiService {
}
static async invalidateSeriesBooksCache(seriesId: string): Promise<void> {
const cacheService: ServerCacheService = await getServerCacheService();
await cacheService.delete(`series-${seriesId}-all-books`);
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<string> {