feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

- Introduce provider abstraction layer (IMediaProvider, KomgaProvider, StripstreamProvider)
- Add Stripstream Librarian as second media provider with full feature parity
- Migrate all pages and components from direct Komga services to provider factory
- Remove dead service code (BaseApiService, HomeService, LibraryService, SearchService, TestService)
- Fix library/series page-based pagination for both providers (Komga 0-indexed, Stripstream 1-indexed)
- Fix unread filter and search on library page for both providers
- Fix read progress display for Stripstream (reading_status mapping)
- Fix series read status (books_read_count) for Stripstream
- Add global search with series results for Stripstream (series_hits from Meilisearch)
- Fix thumbnail proxy to return 404 gracefully instead of JSON on upstream error
- Replace duration-based cache debug detection with x-nextjs-cache header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:48:17 +01:00
parent a1a95775db
commit 7d0f1c4457
77 changed files with 2695 additions and 1705 deletions

View File

@@ -1,36 +1,40 @@
"use server";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookDataResult {
success: boolean;
data?: {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
};
message?: string;
}
export async function getBookData(bookId: string): Promise<BookDataResult> {
try {
const data = await BookService.getBook(bookId);
let nextBook = null;
const provider = await getProvider();
if (!provider) {
return { success: false, message: "KOMGA_MISSING_CONFIG" };
}
const book = await provider.getBook(bookId);
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
let nextBook: NormalizedBook | null = null;
try {
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
nextBook = await provider.getNextBook(bookId);
} catch (error) {
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
}
return {
success: true,
data: {
...data,
nextBook,
},
data: { book, pages, nextBook },
};
} catch (error) {
if (error instanceof AppError) {

View File

@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { ConfigDBService } from "@/lib/services/config-db.service";
import { TestService } from "@/lib/services/test.service";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
interface SaveConfigInput {
@@ -13,9 +13,6 @@ interface SaveConfigInput {
authHeader?: string;
}
/**
* Teste la connexion à Komga
*/
export async function testKomgaConnection(
serverUrl: string,
username: string,
@@ -23,12 +20,19 @@ export async function testKomgaConnection(
): Promise<{ success: boolean; message: string }> {
try {
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({
serverUrl,
authHeader,
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
const headers = new Headers({
Authorization: `Basic ${authHeader}`,
Accept: "application/json",
});
const response = await fetch(url, { headers });
if (!response.ok) {
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR);
}
const libraries: KomgaLibrary[] = await response.json();
return {
success: true,
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
@@ -41,9 +45,6 @@ export async function testKomgaConnection(
}
}
/**
* Sauvegarde la configuration Komga
*/
export async function saveKomgaConfig(
config: SaveConfigInput
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
@@ -55,15 +56,8 @@ export async function saveKomgaConfig(
authHeader: config.authHeader || "",
};
const mongoConfig = await ConfigDBService.saveConfig(configData);
// Invalider le cache
revalidatePath("/settings");
return {
success: true,
message: "Configuration sauvegardée",
data: mongoConfig,
};
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };

View File

@@ -1,20 +1,18 @@
"use server";
import { revalidatePath } from "next/cache";
import { LibraryService } from "@/lib/services/library.service";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
/**
* Lance un scan de bibliothèque
*/
export async function scanLibrary(
libraryId: string
): Promise<{ success: boolean; message: string }> {
try {
await LibraryService.scanLibrary(libraryId, false);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.scanLibrary(libraryId);
// Invalider le cache de la bibliothèque
revalidatePath(`/libraries/${libraryId}`);
revalidatePath("/libraries");
@@ -27,9 +25,6 @@ export async function scanLibrary(
}
}
/**
* Retourne un livre aléatoire depuis les bibliothèques sélectionnées
*/
export async function getRandomBookFromLibraries(
libraryIds: string[]
): Promise<{ success: boolean; bookId?: string; message?: string }> {
@@ -38,13 +33,15 @@ export async function getRandomBookFromLibraries(
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
}
const bookId = await BookService.getRandomBookFromLibraries(libraryIds);
return { success: true, bookId };
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
const bookId = await provider.getRandomBook(libraryIds);
return { success: true, bookId: bookId ?? undefined };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
}
}

View File

@@ -1,27 +1,27 @@
"use server";
import { revalidateTag } from "next/cache";
import { BookService } from "@/lib/services/book.service";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
import { AppError } from "@/utils/errors";
const HOME_CACHE_TAG = "home-data";
function revalidateReadCaches() {
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
revalidateTag(SERIES_BOOKS_CACHE_TAG, "max");
}
/**
* Met à jour la progression de lecture d'un livre
* Note: ne pas utiliser "use server" avec redirect - on gère manuellement
*/
export async function updateReadProgress(
bookId: string,
page: number,
completed: boolean = false
): Promise<{ success: boolean; message: string }> {
try {
await BookService.updateReadProgress(bookId, page, completed);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
await provider.saveReadProgress(bookId, page, completed);
revalidateReadCaches();
return { success: true, message: "Progression mise à jour" };
} catch (error) {
@@ -32,18 +32,15 @@ export async function updateReadProgress(
}
}
/**
* Supprime la progression de lecture d'un livre
*/
export async function deleteReadProgress(
bookId: string
): Promise<{ success: boolean; message: string }> {
try {
await BookService.deleteReadProgress(bookId);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
await provider.resetReadProgress(bookId);
revalidateReadCaches();
return { success: true, message: "Progression supprimée" };
} catch (error) {

View File

@@ -1,9 +1,7 @@
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
const HOME_CACHE_TAG = "home-data";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
export type RefreshScope = "home" | "library" | "series";

View File

@@ -0,0 +1,183 @@
"use server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types";
/**
* Sauvegarde la configuration Stripstream
*/
export async function saveStripstreamConfig(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
await prisma.stripstreamConfig.upsert({
where: { userId },
update: { url, token },
create: { userId, url, token },
});
revalidatePath("/settings");
return { success: true, message: "Configuration Stripstream sauvegardée" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };
}
}
/**
* Teste la connexion à Stripstream Librarian
*/
export async function testStripstreamConnection(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const provider = new StripstreamProvider(url, token);
const result = await provider.testConnection();
if (!result.ok) {
return { success: false, message: result.error ?? "Connexion échouée" };
}
return { success: true, message: "Connexion Stripstream réussie !" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du test de connexion" };
}
}
/**
* Définit le provider actif de l'utilisateur
*/
export async function setActiveProvider(
provider: ProviderType
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
// Vérifier que le provider est configuré avant de l'activer
if (provider === "komga") {
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
if (!config) {
return { success: false, message: "Komga n'est pas encore configuré" };
}
} else if (provider === "stripstream") {
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" };
}
}
await prisma.user.update({
where: { id: userId },
data: { activeProvider: provider },
});
revalidatePath("/");
revalidatePath("/settings");
return {
success: true,
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
};
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du changement de provider" };
}
}
/**
* Récupère la configuration Stripstream de l'utilisateur
*/
export async function getStripstreamConfig(): Promise<{
url?: string;
hasToken: boolean;
} | null> {
try {
const user = await getCurrentUser();
if (!user) return null;
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({
where: { userId },
select: { url: true },
});
if (!config) return null;
return { url: config.url, hasToken: true };
} catch {
return null;
}
}
/**
* Récupère le provider actif de l'utilisateur
*/
export async function getActiveProvider(): Promise<ProviderType> {
try {
const user = await getCurrentUser();
if (!user) return "komga";
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { activeProvider: true },
});
return (dbUser?.activeProvider as ProviderType) ?? "komga";
} catch {
return "komga";
}
}
/**
* Vérifie quels providers sont configurés
*/
export async function getProvidersStatus(): Promise<{
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: ProviderType;
}> {
try {
const user = await getCurrentUser();
if (!user) {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
const userId = parseInt(user.id, 10);
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
]);
return {
komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamConfig,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
};
} catch {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
}

View File

@@ -1,72 +0,0 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { SearchService } from "@/lib/services/search.service";
import { AppError, getErrorMessage } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 10;
export async function GET(request: NextRequest) {
try {
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const limitParam = request.nextUrl.searchParams.get("limit");
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
const limit = Number.isFinite(parsedLimit)
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
: DEFAULT_LIMIT;
if (query.length < MIN_QUERY_LENGTH) {
return NextResponse.json({ series: [], books: [] }, { headers: { "Cache-Control": "no-store" } });
}
const results = await SearchService.globalSearch(query, limit);
return NextResponse.json(
{
series: results.series.map((series) => ({
id: series.id,
title: series.metadata.title,
libraryId: series.libraryId,
booksCount: series.booksCount,
href: `/series/${series.id}`,
coverUrl: `/api/komga/images/series/${series.id}/thumbnail`,
})),
books: results.books.map((book) => ({
id: book.id,
title: book.metadata.title || book.name,
seriesTitle: book.seriesTitle,
seriesId: book.seriesId,
href: `/books/${book.id}`,
coverUrl: `/api/komga/images/books/${book.id}/thumbnail`,
})),
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
name: "Search fetch error",
message: getErrorMessage(error.code),
},
},
{ status: 500 }
);
}
return NextResponse.json(
{
error: {
code: ERROR_CODES.SERIES.FETCH_ERROR,
name: "Search fetch error",
message: getErrorMessage(ERROR_CODES.SERIES.FETCH_ERROR),
},
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError, getErrorMessage } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 10;
export async function GET(request: NextRequest) {
try {
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const limitParam = request.nextUrl.searchParams.get("limit");
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
const limit = Number.isFinite(parsedLimit)
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
: DEFAULT_LIMIT;
if (query.length < MIN_QUERY_LENGTH) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const provider = await getProvider();
if (!provider) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const results = await provider.search(query, limit);
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import prisma from "@/lib/prisma";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import logger from "@/lib/logger";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ bookId: string; pageNumber: string }> }
) {
try {
const { bookId, pageNumber } = await params;
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
}
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const queryString = request.nextUrl.search.slice(1); // strip leading '?'
const path = `books/${bookId}/pages/${pageNumber}${queryString ? `?${queryString}` : ""}`;
const client = new StripstreamClient(config.url, config.token);
const response = await client.fetchImage(path);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
} catch (error) {
logger.error({ err: error }, "Stripstream page fetch error");
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.IMAGE.FETCH_ERROR, message: "Image fetch error" } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import prisma from "@/lib/prisma";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ bookId: string }> }
) {
try {
const { bookId } = await params;
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
}
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const client = new StripstreamClient(config.url, config.token);
const response = await client.fetchImage(`books/${bookId}/thumbnail`);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=2592000, immutable",
},
});
} catch (error) {
logger.error({ err: error }, "Stripstream thumbnail fetch error");
return new NextResponse(null, { status: 404 });
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError, getErrorMessage } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 10;
export async function GET(request: NextRequest) {
try {
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const limitParam = request.nextUrl.searchParams.get("limit");
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
const limit = Number.isFinite(parsedLimit)
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
: DEFAULT_LIMIT;
if (query.length < MIN_QUERY_LENGTH) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const provider = await getProvider();
if (!provider) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const results = await provider.search(query, limit);
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react";
import { ClientBookPage } from "@/components/reader/ClientBookPage";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { redirect } from "next/navigation";
@@ -11,23 +11,30 @@ export default async function BookPage({ params }: { params: Promise<{ bookId: s
const { bookId } = await params;
try {
// SSR: Fetch directly on server instead of client-side XHR
const data = await BookService.getBook(bookId);
const provider = await getProvider();
if (!provider) redirect("/settings");
const book = await provider.getBook(bookId);
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
let nextBook = null;
try {
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
nextBook = await provider.getNextBook(bookId);
} catch (error) {
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
}
return (
<Suspense fallback={<BookSkeleton />}>
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} />
<ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
</Suspense>
);
} catch (error) {
// If config is missing, redirect to settings
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
if (error instanceof AppError && (
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings");
}

View File

@@ -10,7 +10,7 @@ import { AuthProvider } from "@/components/providers/AuthProvider";
import { cookies, headers } from "next/headers";
import { defaultPreferences } from "@/types/preferences";
import type { UserPreferences } from "@/types/preferences";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import logger from "@/lib/logger";
const inter = Inter({
@@ -77,8 +77,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let preferences: UserPreferences = defaultPreferences;
let userIsAdmin = false;
let libraries: KomgaLibrary[] = [];
let favorites: KomgaSeries[] = [];
let libraries: NormalizedLibrary[] = [];
let favorites: NormalizedSeries[] = [];
try {
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
@@ -86,7 +86,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
if (currentUser) {
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(),
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
import("@/lib/providers/provider.factory")
.then((m) => m.getProvider())
.then((provider) => provider?.getLibraries() ?? []),
import("@/lib/services/favorites.service").then((m) =>
m.FavoritesService.getFavorites({ requestPath, requestPathname })
),

View File

@@ -1,14 +1,12 @@
import { LibraryHeader } from "@/components/library/LibraryHeader";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { Container } from "@/components/ui/container";
import type { KomgaLibrary } from "@/types/komga";
import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series";
import type { NormalizedLibrary, NormalizedSeriesPage } from "@/lib/providers/types";
import type { UserPreferences } from "@/types/preferences";
interface LibraryContentProps {
library: KomgaLibrary;
series: LibraryResponse<Series>;
library: NormalizedLibrary;
series: NormalizedSeriesPage;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
@@ -28,15 +26,15 @@ export function LibraryContent({
<>
<LibraryHeader
library={library}
seriesCount={series.totalElements}
series={series.content || []}
seriesCount={series.totalElements ?? series.items.length}
series={series.items}
/>
<Container>
<PaginatedSeriesGrid
series={series.content || []}
series={series.items}
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
totalPages={series.totalPages ?? 1}
totalElements={series.totalElements ?? series.items.length}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
pageSize={pageSize}

View File

@@ -1,11 +1,12 @@
import { PreferencesService } from "@/lib/services/preferences.service";
import { LibraryService } from "@/lib/services/library.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { LibraryClientWrapper } from "./LibraryClientWrapper";
import { LibraryContent } from "./LibraryContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences";
import { redirect } from "next/navigation";
interface PageProps {
params: Promise<{ libraryId: string }>;
@@ -17,9 +18,9 @@ const DEFAULT_PAGE_SIZE = 20;
export default async function LibraryPage({ params, searchParams }: PageProps) {
const libraryId = (await params).libraryId;
const unread = (await searchParams).unread;
const search = (await searchParams).search;
const page = (await searchParams).page;
const size = (await searchParams).size;
const search = (await searchParams).search;
const currentPage = page ? parseInt(page) : 1;
const preferences: UserPreferences = await PreferencesService.getPreferences();
@@ -31,31 +32,36 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
try {
const [series, library] = await Promise.all([
LibraryService.getLibrarySeries(
libraryId,
currentPage - 1,
effectivePageSize,
unreadOnly,
search
),
LibraryService.getLibrary(libraryId),
const provider = await getProvider();
if (!provider) redirect("/settings");
const [seriesPage, library] = await Promise.all([
provider.getSeries(libraryId, String(currentPage), effectivePageSize, unreadOnly, search),
provider.getLibraryById(libraryId),
]);
if (!library) throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND);
return (
<LibraryClientWrapper libraryId={libraryId}>
<LibraryContent
library={library}
series={series}
series={seriesPage}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize}
/>
</LibraryClientWrapper>
);
} catch (error) {
if (error instanceof AppError && (
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings");
}
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
return (

View File

@@ -1,4 +1,4 @@
import { HomeService } from "@/lib/services/home.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { HomeContent } from "@/components/home/HomeContent";
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
@@ -8,7 +8,10 @@ import { redirect } from "next/navigation";
export default async function HomePage() {
try {
const data = await HomeService.getHomeData();
const provider = await getProvider();
if (!provider) redirect("/settings");
const data = await provider.getHomeData();
return (
<HomeClientWrapper>
@@ -16,13 +19,14 @@ export default async function HomePage() {
</HomeClientWrapper>
);
} catch (error) {
// Si la config Komga est manquante, rediriger vers les settings
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
if (error instanceof AppError && (
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings");
}
// Afficher une erreur pour les autres cas
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">

View File

@@ -4,13 +4,12 @@ import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBooksPage, NormalizedSeries } from "@/lib/providers/types";
import type { UserPreferences } from "@/types/preferences";
interface SeriesContentProps {
series: KomgaSeries;
books: LibraryResponse<KomgaBook>;
series: NormalizedSeries;
books: NormalizedBooksPage;
currentPage: number;
preferences: UserPreferences;
unreadOnly: boolean;
@@ -37,10 +36,10 @@ export function SeriesContent({
/>
<Container>
<PaginatedBookGrid
books={books.content || []}
books={books.items}
currentPage={currentPage}
totalPages={books.totalPages}
totalElements={books.totalElements}
totalPages={books.totalPages ?? 1}
totalElements={books.totalElements ?? books.items.length}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>

View File

@@ -1,5 +1,6 @@
import { PreferencesService } from "@/lib/services/preferences.service";
import { SeriesService } from "@/lib/services/series.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { FavoriteService } from "@/lib/services/favorite.service";
import { SeriesClientWrapper } from "./SeriesClientWrapper";
import { SeriesContent } from "./SeriesContent";
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences";
import { redirect } from "next/navigation";
interface PageProps {
params: Promise<{ seriesId: string }>;
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
export default async function SeriesPage({ params, searchParams }: PageProps) {
const seriesId = (await params).seriesId;
const page = (await searchParams).page;
const unread = (await searchParams).unread;
const size = (await searchParams).size;
const unread = (await searchParams).unread;
const currentPage = page ? parseInt(page) : 1;
const preferences: UserPreferences = await PreferencesService.getPreferences();
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
const effectivePageSize = size
? parseInt(size)
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
try {
const [books, series, isFavorite] = await Promise.all([
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
SeriesService.getSeries(seriesId),
const provider = await getProvider();
if (!provider) redirect("/settings");
const [booksPage, series, isFavorite] = await Promise.all([
provider.getBooks({
seriesName: seriesId,
cursor: String(currentPage),
limit: effectivePageSize,
unreadOnly,
}),
provider.getSeriesById(seriesId),
FavoriteService.isFavorite(seriesId),
]);
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
return (
<SeriesClientWrapper seriesId={seriesId}>
<SeriesContent
series={series}
books={books}
books={booksPage}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
@@ -49,10 +61,16 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
</SeriesClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError
? error.code
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
if (
error instanceof AppError &&
(error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG)
) {
redirect("/settings");
}
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">
<ErrorMessage errorCode={errorCode} />

View File

@@ -1,8 +1,10 @@
import { ConfigDBService } from "@/lib/services/config-db.service";
import { LibraryService } from "@/lib/services/library.service";
import { ClientSettings } from "@/components/settings/ClientSettings";
import { getProvider } from "@/lib/providers/provider.factory";
import { getStripstreamConfig, getProvidersStatus } from "@/app/actions/stripstream-config";
import type { Metadata } from "next";
import type { KomgaConfig, KomgaLibrary } from "@/types/komga";
import type { KomgaConfig } from "@/types/komga";
import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger";
export const dynamic = "force-dynamic";
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
export default async function SettingsPage() {
let config: KomgaConfig | null = null;
let libraries: KomgaLibrary[] = [];
let libraries: NormalizedLibrary[] = [];
let stripstreamConfig: { url?: string; hasToken: boolean } | null = null;
let providersStatus: {
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: "komga" | "stripstream";
} | undefined = undefined;
try {
// Récupérer la configuration Komga
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
if (mongoConfig) {
config = {
@@ -29,11 +36,31 @@ export default async function SettingsPage() {
};
}
libraries = await LibraryService.getLibraries();
const [provider, stConfig, status] = await Promise.allSettled([
getProvider().then((p) => p?.getLibraries() ?? []),
getStripstreamConfig(),
getProvidersStatus(),
]);
if (provider.status === "fulfilled") {
libraries = provider.value;
}
if (stConfig.status === "fulfilled") {
stripstreamConfig = stConfig.value;
}
if (status.status === "fulfilled") {
providersStatus = status.value;
}
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
}
return <ClientSettings initialConfig={config} initialLibraries={libraries} />;
return (
<ClientSettings
initialConfig={config}
initialLibraries={libraries}
stripstreamConfig={stripstreamConfig}
providersStatus={providersStatus}
/>
);
}