Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Froidefond
87ac116b9b feat: implement recursive file deletion in ServerCacheService to remove matching JSON cache files based on prefix key 2025-12-07 19:35:12 +01:00
Julien Froidefond
90b213a407 refactor: improve readability of BookGrid and BookList components by formatting props for better clarity 2025-12-07 18:49:31 +01:00
Julien Froidefond
181240cd5f feat: add cache invalidation for series after updating or deleting read progress, and enhance BookGrid and BookList components with refresh functionality 2025-12-07 18:49:16 +01:00
Julien Froidefond
6b6fed34fb feat: integrate user preferences for unread series and books in PaginatedSeriesGrid and PaginatedBookGrid components 2025-12-07 18:49:04 +01:00
Julien Froidefond
feb8444b35 refactor: streamline book and series services by removing deprecated methods and enhancing API calls for fetching books and series data 2025-12-07 13:09:29 +01:00
Julien Froidefond
10b903a136 refactor: update API service parameters to support multiple values and enhance filtering logic for library and series services 2025-12-07 12:58:34 +01:00
Julien Froidefond
e242b919ac Revert "refactor: implement caching for user preferences using ServerCacheService to reduce database calls and improve performance"
This reverts commit 1fa4024f91.
2025-12-07 11:57:20 +01:00
Julien Froidefond
1fa4024f91 refactor: implement caching for user preferences using ServerCacheService to reduce database calls and improve performance 2025-12-07 11:40:10 +01:00
Julien Froidefond
daeb90262a refactor: optimize ServerCacheService TTL settings for paginated lists and static data to enhance caching efficiency 2025-12-07 11:37:20 +01:00
Julien Froidefond
c76d960dca refactor: remove HTTP Cache-Control headers and revalidate settings from API routes to streamline caching strategy and avoid conflicts with server-side caching 2025-12-07 11:36:50 +01:00
16 changed files with 265 additions and 233 deletions

View File

@@ -83,20 +83,21 @@ size: "1000"; // Récupère TOUS les livres d'un coup
- Garder uniquement le cache SW pour : images, static, navigation - Garder uniquement le cache SW pour : images, static, navigation
- Le cache serveur suffit pour les données - Le cache serveur suffit pour les données
- [ ] **2.2 Supprimer les headers HTTP Cache-Control** - [x] **2.2 Supprimer les headers HTTP Cache-Control**
- Retirer `Cache-Control` des NextResponse dans les routes API - Retirer `Cache-Control` des NextResponse dans les routes API
- Évite les conflits avec le cache serveur - Évite les conflits avec le cache serveur
- Note: Conservé pour les images de pages de livres (max-age=31536000)
- [ ] **2.3 Supprimer `revalidate` des routes dynamiques** - [x] **2.3 Supprimer `revalidate` des routes dynamiques**
- Routes API = dynamiques, pas besoin d'ISR - Routes API = dynamiques, pas besoin d'ISR
- Le cache serveur suffit - Le cache serveur suffit
- [ ] **2.4 Optimiser les TTL ServerCacheService** - [x] **2.4 Optimiser les TTL ServerCacheService**
- Réduire TTL des listes paginées (1-2 min) - Réduire TTL des listes paginées (2 min)
- Garder TTL court pour les données avec progression (5 min) - Garder TTL court pour les données avec progression (2 min)
- Garder TTL long pour les images (7 jours) - Garder TTL long pour les images (7 jours)
**Résultat final :** **Résultat final :**

View File

@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { BookService } from "@/lib/services/book.service"; import { BookService } from "@/lib/services/book.service";
import { SeriesService } from "@/lib/services/series.service";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -28,6 +29,17 @@ export async function PATCH(
} }
await BookService.updateReadProgress(bookId, page, completed); await BookService.updateReadProgress(bookId, page, completed);
// Invalider le cache de la série après avoir mis à jour la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "📖 Progression mise à jour avec succès" }); return NextResponse.json({ message: "📖 Progression mise à jour avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:"); logger.error({ err: error }, "Erreur lors de la mise à jour de la progression:");
@@ -64,6 +76,17 @@ export async function DELETE(
const bookId: string = (await params).bookId; const bookId: string = (await params).bookId;
await BookService.deleteReadProgress(bookId); await BookService.deleteReadProgress(bookId);
// Invalider le cache de la série après avoir supprimé la progression
try {
const seriesId = await BookService.getBookSeriesId(bookId);
await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId);
} catch (cacheError) {
// Ne pas faire échouer la requête si l'invalidation du cache échoue
logger.error({ err: cacheError }, "Erreur lors de l'invalidation du cache de la série:");
}
return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" }); return NextResponse.json({ message: "🗑️ Progression supprimée avec succès" });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la suppression de la progression:"); logger.error({ err: error }, "Erreur lors de la suppression de la progression:");

View File

@@ -4,7 +4,6 @@ import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const revalidate = 60;
export async function GET() { export async function GET() {
try { try {

View File

@@ -5,7 +5,6 @@ import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const revalidate = 60;
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
@@ -27,14 +26,7 @@ export async function GET(
LibraryService.getLibrary(libraryId), LibraryService.getLibrary(libraryId),
]); ]);
return NextResponse.json( return NextResponse.json({ series, library });
{ series, library },
{
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
}
);
} catch (error) { } catch (error) {
logger.error({ err: error }, "API Library Series - Erreur:"); logger.error({ err: error }, "API Library Series - Erreur:");
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -5,7 +5,6 @@ import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const revalidate = 60;
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
@@ -26,14 +25,7 @@ export async function GET(
SeriesService.getSeries(seriesId), SeriesService.getSeries(seriesId),
]); ]);
return NextResponse.json( return NextResponse.json({ books, series });
{ books, series },
{
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
}
);
} catch (error) { } catch (error) {
logger.error({ err: error }, "API Series Books - Erreur:"); logger.error({ err: error }, "API Series Books - Erreur:");
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -6,7 +6,6 @@ import type { KomgaSeries } from "@/types/komga";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const revalidate = 60;
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -16,11 +15,7 @@ export async function GET(
const seriesId: string = (await params).seriesId; const seriesId: string = (await params).seriesId;
const series: KomgaSeries = await SeriesService.getSeries(seriesId); const series: KomgaSeries = await SeriesService.getSeries(seriesId);
return NextResponse.json(series, { return NextResponse.json(series);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) { } catch (error) {
logger.error({ err: error }, "API Series - Erreur:"); logger.error({ err: error }, "API Series - Erreur:");
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -199,6 +199,7 @@ export function ClientSeriesPage({
totalElements={books.totalElements} totalElements={books.totalElements}
defaultShowOnlyUnread={preferences.showOnlyUnread} defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly} showOnlyUnread={unreadOnly}
onRefresh={() => handleRefresh(seriesId)}
/> />
</div> </div>
</> </>

View File

@@ -9,6 +9,7 @@ import type { KomgaSeries } from "@/types/komga";
import { SearchInput } from "./SearchInput"; import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences"; import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
import { PageSizeSelect } from "@/components/common/PageSizeSelect"; import { PageSizeSelect } from "@/components/common/PageSizeSelect";
import { CompactModeButton } from "@/components/common/CompactModeButton"; import { CompactModeButton } from "@/components/common/CompactModeButton";
import { ViewModeButton } from "@/components/common/ViewModeButton"; import { ViewModeButton } from "@/components/common/ViewModeButton";
@@ -38,6 +39,7 @@ export function PaginatedSeriesGrid({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread); const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences(); const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
const { updatePreferences } = usePreferences();
// Utiliser la taille de page effective (depuis l'URL ou les préférences) // Utiliser la taille de page effective (depuis l'URL ou les préférences)
const effectivePageSize = pageSize || displayItemsPerPage; const effectivePageSize = pageSize || displayItemsPerPage;
@@ -87,6 +89,13 @@ export function PaginatedSeriesGrid({
page: "1", page: "1",
unread: newUnreadState ? "true" : "false", unread: newUnreadState ? "true" : "false",
}); });
// Sauvegarder la préférence dans la base de données
try {
await updatePreferences({ showOnlyUnread: newUnreadState });
} catch (error) {
// Log l'erreur mais ne bloque pas l'utilisateur
console.error("Erreur lors de la sauvegarde de la préférence:", error);
}
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {

View File

@@ -2,7 +2,7 @@
import type { KomgaBook } from "@/types/komga"; import type { KomgaBook } from "@/types/komga";
import { BookCover } from "@/components/ui/book-cover"; import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
@@ -11,6 +11,7 @@ interface BookGridProps {
books: KomgaBook[]; books: KomgaBook[];
onBookClick: (book: KomgaBook) => void; onBookClick: (book: KomgaBook) => void;
isCompact?: boolean; isCompact?: boolean;
onRefresh?: () => void;
} }
interface BookCardProps { interface BookCardProps {
@@ -61,12 +62,18 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
); );
} }
export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProps) { export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: BookGridProps) {
const [localBooks, setLocalBooks] = useState(books); const [localBooks, setLocalBooks] = useState(books);
const { t } = useTranslate(); const { t } = useTranslate();
const previousBookIdsRef = useRef<string>(books.map((b) => b.id).join(","));
useEffect(() => { useEffect(() => {
// Ne réinitialiser que si les IDs des livres ont changé (nouvelle page, nouveau filtre, etc.)
const newIds = books.map((b) => b.id).join(",");
if (previousBookIdsRef.current !== newIds) {
setLocalBooks(books); setLocalBooks(books);
previousBookIdsRef.current = newIds;
}
}, [books]); }, [books]);
if (!localBooks.length) { if (!localBooks.length) {
@@ -107,6 +114,8 @@ export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProp
) )
); );
} }
// Rafraîchir les données après avoir marqué comme lu/non lu
onRefresh?.();
}; };
return ( return (

View File

@@ -2,7 +2,7 @@
import type { KomgaBook } from "@/types/komga"; import type { KomgaBook } from "@/types/komga";
import { BookCover } from "@/components/ui/book-cover"; import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
@@ -18,6 +18,7 @@ interface BookListProps {
books: KomgaBook[]; books: KomgaBook[];
onBookClick: (book: KomgaBook) => void; onBookClick: (book: KomgaBook) => void;
isCompact?: boolean; isCompact?: boolean;
onRefresh?: () => void;
} }
interface BookListItemProps { interface BookListItemProps {
@@ -288,12 +289,18 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
); );
} }
export function BookList({ books, onBookClick, isCompact = false }: BookListProps) { export function BookList({ books, onBookClick, isCompact = false, onRefresh }: BookListProps) {
const [localBooks, setLocalBooks] = useState(books); const [localBooks, setLocalBooks] = useState(books);
const { t } = useTranslate(); const { t } = useTranslate();
const previousBookIdsRef = useRef<string>(books.map((b) => b.id).join(","));
useEffect(() => { useEffect(() => {
// Ne réinitialiser que si les IDs des livres ont changé (nouvelle page, nouveau filtre, etc.)
const newIds = books.map((b) => b.id).join(",");
if (previousBookIdsRef.current !== newIds) {
setLocalBooks(books); setLocalBooks(books);
previousBookIdsRef.current = newIds;
}
}, [books]); }, [books]);
if (!localBooks.length) { if (!localBooks.length) {
@@ -334,6 +341,8 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
) )
); );
} }
// Rafraîchir les données après avoir marqué comme lu/non lu
onRefresh?.();
}; };
return ( return (

View File

@@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from "react";
import type { KomgaBook } from "@/types/komga"; import type { KomgaBook } from "@/types/komga";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences"; import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
import { PageSizeSelect } from "@/components/common/PageSizeSelect"; import { PageSizeSelect } from "@/components/common/PageSizeSelect";
import { CompactModeButton } from "@/components/common/CompactModeButton"; import { CompactModeButton } from "@/components/common/CompactModeButton";
import { ViewModeButton } from "@/components/common/ViewModeButton"; import { ViewModeButton } from "@/components/common/ViewModeButton";
@@ -20,6 +21,7 @@ interface PaginatedBookGridProps {
totalElements: number; totalElements: number;
defaultShowOnlyUnread: boolean; defaultShowOnlyUnread: boolean;
showOnlyUnread: boolean; showOnlyUnread: boolean;
onRefresh?: () => void;
} }
export function PaginatedBookGrid({ export function PaginatedBookGrid({
@@ -29,12 +31,14 @@ export function PaginatedBookGrid({
totalElements, totalElements,
defaultShowOnlyUnread, defaultShowOnlyUnread,
showOnlyUnread: initialShowOnlyUnread, showOnlyUnread: initialShowOnlyUnread,
onRefresh,
}: PaginatedBookGridProps) { }: PaginatedBookGridProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread); const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences(); const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences();
const { updatePreferences } = usePreferences();
const { t } = useTranslate(); const { t } = useTranslate();
const updateUrlParams = useCallback( const updateUrlParams = useCallback(
@@ -81,6 +85,13 @@ export function PaginatedBookGrid({
page: "1", page: "1",
unread: newUnreadState ? "true" : "false", unread: newUnreadState ? "true" : "false",
}); });
// Sauvegarder la préférence dans la base de données
try {
await updatePreferences({ showOnlyUnread: newUnreadState });
} catch (error) {
// Log l'erreur mais ne bloque pas l'utilisateur
console.error("Erreur lors de la sauvegarde de la préférence:", error);
}
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
@@ -121,9 +132,19 @@ export function PaginatedBookGrid({
</div> </div>
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<BookGrid books={books} onBookClick={handleBookClick} isCompact={isCompact} /> <BookGrid
books={books}
onBookClick={handleBookClick}
isCompact={isCompact}
onRefresh={onRefresh}
/>
) : ( ) : (
<BookList books={books} onBookClick={handleBookClick} isCompact={isCompact} /> <BookList
books={books}
onBookClick={handleBookClick}
isCompact={isCompact}
onRefresh={onRefresh}
/>
)} )}
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between"> <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">

View File

@@ -21,7 +21,7 @@ interface KomgaRequestInit extends RequestInit {
interface KomgaUrlBuilder { interface KomgaUrlBuilder {
path: string; path: string;
params?: Record<string, string>; params?: Record<string, string | string[]>;
} }
export abstract class BaseApiService { export abstract class BaseApiService {
@@ -136,15 +136,23 @@ export abstract class BaseApiService {
protected static buildUrl( protected static buildUrl(
config: AuthConfig, config: AuthConfig,
path: string, path: string,
params?: Record<string, string> params?: Record<string, string | string[]>
): string { ): string {
const url = new URL(`${config.serverUrl}/api/v1/${path}`); const url = new URL(`${config.serverUrl}/api/v1/${path}`);
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach((v) => {
if (v !== undefined) {
url.searchParams.append(key, v);
}
});
} else {
url.searchParams.append(key, value); url.searchParams.append(key, value);
} }
}
}); });
} }

View File

@@ -4,10 +4,9 @@ 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 { ConfigDBService } from "./config-db.service"; import { ConfigDBService } from "./config-db.service";
import { SeriesService } from "./series.service";
import { ERROR_CODES } from "../../constants/errorCodes"; import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors"; import { AppError } from "../../utils/errors";
import { SeriesService } from "./series.service";
import type { Series } from "@/types/series";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export class BookService extends BaseApiService { export class BookService extends BaseApiService {
@@ -43,10 +42,33 @@ export class BookService extends BaseApiService {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error); throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
} }
} }
public static async getNextBook(bookId: string, seriesId: string): Promise<KomgaBook | null> { public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
const books = await SeriesService.getAllSeriesBooks(seriesId); try {
const currentIndex = books.findIndex((book) => book.id === bookId); // Utiliser l'endpoint natif Komga pour obtenir le livre suivant
return books[currentIndex + 1] || null; return await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}/next` });
} catch (error) {
// Si le livre suivant n'existe pas, Komga retourne 404
// On retourne null dans ce cas
if (
error instanceof AppError &&
error.code === ERROR_CODES.KOMGA.HTTP_ERROR &&
(error as any).context?.status === 404
) {
return null;
}
// Pour les autres erreurs, on les propage
throw error;
}
}
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) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
}
} }
static async updateReadProgress( static async updateReadProgress(
@@ -191,34 +213,7 @@ export class BookService extends BaseApiService {
const { LibraryService } = await import("./library.service"); const { LibraryService } = await import("./library.service");
// Essayer d'abord d'utiliser le cache des bibliothèques // Faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
const allSeriesFromCache: Series[] = [];
for (const libraryId of libraryIds) {
try {
// Essayer de récupérer les séries depuis le cache (rapide si en cache)
const series = await LibraryService.getAllLibrarySeries(libraryId);
allSeriesFromCache.push(...series);
} catch {
// Si erreur, on continue avec les autres bibliothèques
}
}
if (allSeriesFromCache.length > 0) {
// Choisir une série au hasard parmi toutes celles trouvées
const randomSeriesIndex = Math.floor(Math.random() * allSeriesFromCache.length);
const randomSeries = allSeriesFromCache[randomSeriesIndex];
// Récupérer les books de cette série
const books = await SeriesService.getAllSeriesBooks(randomSeries.id);
if (books.length > 0) {
const randomBookIndex = Math.floor(Math.random() * books.length);
return books[randomBookIndex].id;
}
}
// Si pas de cache, faire une requête légère : prendre une page de séries d'une bibliothèque au hasard
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length); const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
const randomLibraryId = libraryIds[randomLibraryIndex]; const randomLibraryId = libraryIds[randomLibraryIndex];
@@ -235,17 +230,17 @@ export class BookService extends BaseApiService {
const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length); const randomSeriesIndex = Math.floor(Math.random() * seriesResponse.content.length);
const randomSeries = seriesResponse.content[randomSeriesIndex]; const randomSeries = seriesResponse.content[randomSeriesIndex];
// Récupérer les books de cette série // Récupérer les books de cette série avec pagination
const books = await SeriesService.getAllSeriesBooks(randomSeries.id); const booksResponse = await SeriesService.getSeriesBooks(randomSeries.id, 0, 100);
if (books.length === 0) { if (booksResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, { throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans la série", message: "Aucun livre trouvé dans la série",
}); });
} }
const randomBookIndex = Math.floor(Math.random() * books.length); const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
return books[randomBookIndex].id; return booksResponse.content[randomBookIndex].id;
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
throw error; throw error;

View File

@@ -35,45 +35,6 @@ export class LibraryService extends BaseApiService {
} }
} }
static async getAllLibrarySeries(libraryId: string): Promise<Series[]> {
try {
const headers = { "Content-Type": "application/json" };
const searchBody = {
condition: {
libraryId: {
operator: "is",
value: libraryId,
},
},
};
const cacheKey = `library-${libraryId}-all-series`;
const response = await this.fetchWithCache<LibraryResponse<Series>>(
cacheKey,
async () =>
this.fetchFromApi<LibraryResponse<Series>>(
{
path: "series/list",
params: {
size: "5000", // On récupère un maximum de livres
},
},
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
}
),
"SERIES"
);
return response.content;
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
static async getLibrarySeries( static async getLibrarySeries(
libraryId: string, libraryId: string,
page: number = 0, page: number = 0,
@@ -85,19 +46,47 @@ export class LibraryService extends BaseApiService {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
// Construction du body de recherche pour Komga // Construction du body de recherche pour Komga
const condition: Record<string, any> = { let condition: any;
if (unreadOnly) {
// Utiliser allOf pour combiner libraryId avec anyOf pour UNREAD ou IN_PROGRESS
condition = {
allOf: [
{
libraryId: {
operator: "is",
value: libraryId,
},
},
{
anyOf: [
{
readStatus: {
operator: "is",
value: "UNREAD",
},
},
{
readStatus: {
operator: "is",
value: "IN_PROGRESS",
},
},
],
},
],
};
} else {
condition = {
libraryId: { libraryId: {
operator: "is", operator: "is",
value: libraryId, value: libraryId,
}, },
}; };
}
const searchBody = { condition }; 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 // Clé de cache incluant tous les paramètres
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${ const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${
search || "" search || ""
@@ -106,9 +95,9 @@ export class LibraryService extends BaseApiService {
const response = await this.fetchWithCache<LibraryResponse<Series>>( const response = await this.fetchWithCache<LibraryResponse<Series>>(
cacheKey, cacheKey,
async () => { async () => {
const params: Record<string, string> = { const params: Record<string, string | string[]> = {
page: String(page), page: String(page),
size: String(fetchSize), size: String(size),
sort: "metadata.titleSort,asc", sort: "metadata.titleSort,asc",
}; };
@@ -129,27 +118,13 @@ export class LibraryService extends BaseApiService {
"SERIES" "SERIES"
); );
// Filtrer les séries supprimées côté client (léger) // Filtrer uniquement les séries supprimées côté client (léger)
let filteredContent = response.content.filter((series) => !series.deleted); const filteredContent = response.content.filter((series) => !series.deleted);
// Filtre unread côté client (Komga n'a pas de filtre natif pour booksReadCount < booksCount)
if (unreadOnly) {
filteredContent = filteredContent.filter(
(series) => series.booksReadCount < series.booksCount
);
// Prendre uniquement les `size` premiers après filtrage
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 + unread)
return { return {
...response, ...response,
content: filteredContent, content: filteredContent,
numberOfElements: filteredContent.length, numberOfElements: filteredContent.length,
// Garder totalElements et totalPages de Komga pour la pagination
// Ils seront légèrement inexacts mais fonctionnels
}; };
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
@@ -162,8 +137,6 @@ export class LibraryService extends BaseApiService {
// Invalider toutes les clés de cache pour cette bibliothèque // Invalider toutes les clés de cache pour cette bibliothèque
// Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search} // Format: library-{id}-series-p{page}-s{size}-u{unread}-q{search}
await cacheService.deleteAll(`library-${libraryId}-series-`); await cacheService.deleteAll(`library-${libraryId}-series-`);
// Invalider aussi l'ancienne clé pour compatibilité
await cacheService.delete(`library-${libraryId}-all-series`);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
} }

View File

@@ -45,52 +45,6 @@ export class SeriesService extends BaseApiService {
} }
} }
static async getAllSeriesBooks(seriesId: string): Promise<KomgaBook[]> {
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<LibraryResponse<KomgaBook>>(
cacheKey,
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>(
{
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( static async getSeriesBooks(
seriesId: string, seriesId: string,
page: number = 0, page: number = 0,
@@ -101,36 +55,56 @@ export class SeriesService extends BaseApiService {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
// Construction du body de recherche pour Komga // Construction du body de recherche pour Komga
const condition: Record<string, any> = { let condition: any;
if (unreadOnly) {
// Utiliser allOf pour combiner seriesId avec anyOf pour UNREAD ou IN_PROGRESS
condition = {
allOf: [
{
seriesId: {
operator: "is",
value: seriesId,
},
},
{
anyOf: [
{
readStatus: {
operator: "is",
value: "UNREAD",
},
},
{
readStatus: {
operator: "is",
value: "IN_PROGRESS",
},
},
],
},
],
};
} else {
condition = {
seriesId: { seriesId: {
operator: "is", operator: "is",
value: seriesId, value: seriesId,
}, },
}; };
// Filtre unread natif Komga (readStatus != READ)
if (unreadOnly) {
condition.readStatus = {
operator: "isNot",
value: "READ",
};
} }
const searchBody = { condition }; 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 // Clé de cache incluant tous les paramètres
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`; const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>( const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
cacheKey, cacheKey,
async () => { async () => {
const params: Record<string, string> = { const params: Record<string, string | string[]> = {
page: String(page), page: String(page),
size: String(fetchSize), size: String(size),
sort: "number,asc", sort: "number,asc",
}; };
@@ -146,23 +120,13 @@ export class SeriesService extends BaseApiService {
"BOOKS" "BOOKS"
); );
// Filtrer les livres supprimés côté client (léger) // Filtrer uniquement les livres supprimés côté client (léger)
let filteredContent = response.content.filter((book: KomgaBook) => !book.deleted); const 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 { return {
...response, ...response,
content: filteredContent, content: filteredContent,
numberOfElements: filteredContent.length, numberOfElements: filteredContent.length,
// Garder totalElements et totalPages de Komga pour la pagination
// Ils seront légèrement inexacts mais fonctionnels
}; };
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error); throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
@@ -175,8 +139,6 @@ export class SeriesService extends BaseApiService {
// Invalider toutes les clés de cache pour cette série // Invalider toutes les clés de cache pour cette série
// Format: series-{id}-books-p{page}-s{size}-u{unread} // Format: series-{id}-books-p{page}-s{size}-u{unread}
await cacheService.deleteAll(`series-${seriesId}-books-`); await cacheService.deleteAll(`series-${seriesId}-books-`);
// Invalider aussi l'ancienne clé pour compatibilité
await cacheService.delete(`series-${seriesId}-all-books`);
} catch (error) { } catch (error) {
throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error); throw new AppError(ERROR_CODES.CACHE.DELETE_ERROR, {}, error);
} }

View File

@@ -19,20 +19,26 @@ class ServerCacheService {
}; };
// Configuration des temps de cache en millisecondes // 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 fiveMinutes = 5 * 60 * 1000;
private static readonly tenMinutes = 10 * 60 * 1000; private static readonly tenMinutes = 10 * 60 * 1000;
private static readonly twentyFourHours = 24 * 60 * 60 * 1000; private static readonly twentyFourHours = 24 * 60 * 60 * 1000;
private static readonly oneMinute = 1 * 60 * 1000;
private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000; private static readonly oneWeek = 7 * 24 * 60 * 60 * 1000;
private static readonly noCache = 0; private static readonly noCache = 0;
// Configuration des temps de cache // 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 = { private static readonly DEFAULT_TTL = {
DEFAULT: ServerCacheService.fiveMinutes, DEFAULT: ServerCacheService.fiveMinutes,
HOME: ServerCacheService.tenMinutes, HOME: ServerCacheService.tenMinutes,
LIBRARIES: ServerCacheService.twentyFourHours, LIBRARIES: ServerCacheService.twentyFourHours,
SERIES: ServerCacheService.fiveMinutes, SERIES: ServerCacheService.twoMinutes, // Listes paginées avec progression
BOOKS: ServerCacheService.fiveMinutes, BOOKS: ServerCacheService.twoMinutes, // Listes paginées avec progression
IMAGES: ServerCacheService.oneWeek, IMAGES: ServerCacheService.oneWeek,
}; };
@@ -363,10 +369,47 @@ class ServerCacheService {
} }
}); });
} else { } else {
const cacheDir = path.join(this.cacheDir, prefixKey); // En mode fichier, parcourir récursivement tous les fichiers et supprimer ceux qui correspondent
if (fs.existsSync(cacheDir)) { if (!fs.existsSync(this.cacheDir)) return;
fs.rmdirSync(cacheDir, { recursive: true });
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);
} }
} }