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

@@ -11,18 +11,20 @@ datasource db {
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
roles Json @default("[\"ROLE_USER\"]")
authenticated Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
email String @unique
password String
roles Json @default("[\"ROLE_USER\"]")
authenticated Boolean @default(true)
activeProvider String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
config KomgaConfig?
preferences Preferences?
favorites Favorite[]
config KomgaConfig?
stripstreamConfig StripstreamConfig?
preferences Preferences?
favorites Favorite[]
@@map("users")
}
@@ -41,6 +43,19 @@ model KomgaConfig {
@@map("komgaconfigs")
}
model StripstreamConfig {
id Int @id @default(autoincrement())
userId Int @unique
url String
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("stripstreamconfigs")
}
model Preferences {
id Int @id @default(autoincrement())
userId Int @unique
@@ -61,12 +76,13 @@ model Favorite {
id Int @id @default(autoincrement())
userId Int
seriesId String
provider String @default("komga")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, seriesId])
@@unique([userId, provider, seriesId])
@@index([userId])
@@map("favorites")
}

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}
/>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
}
interface DownloadedBook {
book: KomgaBook;
book: NormalizedBook;
status: BookDownloadStatus;
}
@@ -112,11 +112,11 @@ export function DownloadManager() {
};
}, [loadDownloadedBooks, updateBookStatuses]);
const handleDeleteBook = async (book: KomgaBook) => {
const handleDeleteBook = async (book: NormalizedBook) => {
try {
const cache = await caches.open("stripstream-books");
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
localStorage.removeItem(getStorageKey(book.id));
@@ -135,7 +135,7 @@ export function DownloadManager() {
}
};
const handleRetryDownload = async (book: KomgaBook) => {
const handleRetryDownload = async (book: NormalizedBook) => {
localStorage.removeItem(getStorageKey(book.id));
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
toast({
@@ -279,7 +279,7 @@ export function DownloadManager() {
}
interface BookDownloadCardProps {
book: KomgaBook;
book: NormalizedBook;
status: BookDownloadStatus;
onDelete: () => void;
onRetry: () => void;
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
<div className="flex items-center gap-4">
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image
src={`/api/komga/images/books/${book.id}/thumbnail`}
alt={t("books.coverAlt", { title: book.metadata?.title })}
src={book.thumbnailUrl}
alt={t("books.coverAlt", { title: book.title })}
className="object-cover"
fill
sizes="64px"
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
className="hover:underline hover:text-primary transition-colors"
>
<h3 className="font-medium truncate">
{book.metadata?.title || t("books.title", { number: book.metadata?.number })}
{book.title || t("books.title", { number: book.number ?? "" })}
</h3>
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatSize(book.sizeBytes)}</span>
<span></span>
<span>
{status.status === "downloading"
? t("downloads.info.pages", {
current: Math.floor((status.progress * book.media.pagesCount) / 100),
total: book.media.pagesCount,
current: Math.floor((status.progress * book.pageCount) / 100),
total: book.pageCount,
})
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
: t("downloads.info.totalPages", { count: book.pageCount })}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@@ -2,39 +2,27 @@
import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaSeries } from "@/types/komga";
interface OptimizedHeroSeries {
id: string;
metadata: {
title: string;
};
}
import type { NormalizedSeries } from "@/lib/providers/types";
interface HeroSectionProps {
series: OptimizedHeroSeries[];
series: NormalizedSeries[];
}
export function HeroSection({ series }: HeroSectionProps) {
const { t } = useTranslate();
// logger.info("HeroSection - Séries reçues:", {
// count: series?.length || 0,
// firstSeries: series?.[0],
// });
return (
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
{/* Grille de couvertures en arrière-plan */}
<div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 sm:gap-4 p-4 opacity-10">
{series?.map((series) => (
{series?.map((s) => (
<div
key={series.id}
key={s.id}
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
>
<SeriesCover
series={series as KomgaSeries}
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
series={s}
alt={t("home.hero.coverAlt", { title: s.name })}
showProgressUi={false}
/>
</div>

View File

@@ -1,39 +1,17 @@
import { MediaRow } from "./MediaRow";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { HomeData } from "@/types/home";
interface HomeContentProps {
data: HomeData;
}
const optimizeSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
export function HomeContent({ data }: HomeContentProps) {
return (
<div className="space-y-10 pb-2">
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
<MediaRow
titleKey="home.sections.continue_reading"
items={optimizeBookData(data.ongoingBooks)}
items={data.ongoingBooks}
iconName="BookOpen"
featuredHeader
/>
@@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.ongoing && data.ongoing.length > 0 && (
<MediaRow
titleKey="home.sections.continue_series"
items={optimizeSeriesData(data.ongoing)}
items={data.ongoing}
iconName="LibraryBig"
/>
)}
@@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.onDeck && data.onDeck.length > 0 && (
<MediaRow
titleKey="home.sections.up_next"
items={optimizeBookData(data.onDeck)}
items={data.onDeck}
iconName="Clock"
/>
)}
@@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow
titleKey="home.sections.latest_series"
items={optimizeSeriesData(data.latestSeries)}
items={data.latestSeries}
iconName="Sparkles"
/>
)}
@@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
titleKey="home.sections.recently_added"
items={optimizeBookData(data.recentlyRead)}
items={data.recentlyRead}
iconName="History"
/>
)}

View File

@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
@@ -12,34 +12,9 @@ import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils";
interface BaseItem {
id: string;
metadata: {
title: string;
};
}
interface OptimizedSeries extends BaseItem {
booksCount: number;
booksReadCount: number;
}
interface OptimizedBook extends BaseItem {
readProgress: {
page: number;
};
media: {
pagesCount: number;
};
metadata: {
title: string;
number?: string;
};
}
interface MediaRowProps {
titleKey: string;
items: (OptimizedSeries | OptimizedBook)[];
items: (NormalizedSeries | NormalizedBook)[];
iconName?: string;
featuredHeader?: boolean;
}
@@ -52,13 +27,17 @@ const iconMap = {
History,
};
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
return "bookCount" in item;
}
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
const router = useRouter();
const { t } = useTranslate();
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
router.push(path);
};
@@ -92,24 +71,24 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
}
interface MediaCardProps {
item: OptimizedSeries | OptimizedBook;
item: NormalizedSeries | NormalizedBook;
onClick?: () => void;
}
function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const isSeries = "booksCount" in item;
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
const isSeriesItem = isSeries(item);
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
const title = isSeries
? item.metadata.title
: item.metadata.title ||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
const title = isSeriesItem
? item.name
: item.title ||
(item.number ? t("navigation.volume", { number: item.number }) : "");
const handleClick = () => {
// Pour les séries, toujours autoriser le clic
// Pour les livres, vérifier si accessible
if (isSeries || isAccessible) {
if (isSeriesItem || isAccessible) {
onClick?.();
}
};
@@ -119,24 +98,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick}
className={cn(
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
!isSeriesItem && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
)}
>
<div className="relative aspect-[2/3] bg-muted">
{isSeries ? (
{isSeriesItem ? (
<>
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1">
{t("series.books", { count: item.booksCount })}
</p>
<SeriesCover series={item} alt={`Couverture de ${title}`} />
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1">
{t("series.books", { count: item.bookCount })}
</p>
</div>
</>
) : (
<>
<BookCover
book={item as KomgaBook}
book={item}
alt={`Couverture de ${title}`}
showControls={false}
overlayVariant="home"

View File

@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext";
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { defaultPreferences } from "@/types/preferences";
import logger from "@/lib/logger";
import { getRandomBookFromLibraries } from "@/app/actions/library";
@@ -20,8 +20,8 @@ const publicRoutes = ["/login", "/register"];
interface ClientLayoutProps {
children: React.ReactNode;
initialLibraries: KomgaLibrary[];
initialFavorites: KomgaSeries[];
initialLibraries: NormalizedLibrary[];
initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean;
}

View File

@@ -6,26 +6,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { getImageUrl } from "@/lib/utils/image-url";
interface SearchSeriesResult {
id: string;
title: string;
href: string;
booksCount: number;
}
interface SearchBookResult {
id: string;
title: string;
seriesTitle: string;
href: string;
}
interface SearchResponse {
series: SearchSeriesResult[];
books: SearchBookResult[];
}
import type { NormalizedSearchResult } from "@/lib/providers/types";
const MIN_QUERY_LENGTH = 2;
@@ -38,21 +19,15 @@ export function GlobalSearch() {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<SearchResponse>({ series: [], books: [] });
const [results, setResults] = useState<NormalizedSearchResult[]>([]);
const hasResults = results.series.length > 0 || results.books.length > 0;
const seriesResults = results.filter((r) => r.type === "series");
const bookResults = results.filter((r) => r.type === "book");
const hasResults = results.length > 0;
const firstResultHref = useMemo(() => {
if (results.series.length > 0) {
return results.series[0].href;
}
if (results.books.length > 0) {
return results.books[0].href;
}
return null;
}, [results.books, results.series]);
return results[0]?.href ?? null;
}, [results]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -77,7 +52,7 @@ export function GlobalSearch() {
const trimmedQuery = query.trim();
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
setResults({ series: [], books: [] });
setResults([]);
setIsLoading(false);
return;
}
@@ -90,7 +65,7 @@ export function GlobalSearch() {
setIsLoading(true);
const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, {
const response = await fetch(`/api/provider/search?q=${encodeURIComponent(trimmedQuery)}`, {
method: "GET",
signal: controller.signal,
cache: "no-store",
@@ -100,12 +75,12 @@ export function GlobalSearch() {
throw new Error("Search request failed");
}
const data = (await response.json()) as SearchResponse;
setResults(data);
const data = (await response.json()) as NormalizedSearchResult[];
setResults(Array.isArray(data) ? data : []);
setIsOpen(true);
} catch (error) {
if ((error as Error).name !== "AbortError") {
setResults({ series: [], books: [] });
setResults([]);
}
} finally {
setIsLoading(false);
@@ -158,12 +133,12 @@ export function GlobalSearch() {
{isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-xl backdrop-blur-xl">
<div className="max-h-[26rem] overflow-y-auto p-2">
{results.series.length > 0 && (
{seriesResults.length > 0 && (
<div className="mb-2">
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.series")}
</div>
{results.series.map((item) => (
{seriesResults.map((item) => (
<Link
key={item.id}
href={item.href}
@@ -172,16 +147,17 @@ export function GlobalSearch() {
aria-label={t("header.search.openSeries", { title: item.title })}
>
<img
src={getImageUrl("series", item.id)}
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 rounded object-cover bg-muted"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
<Library className="h-3 w-3" />
{t("series.books", { count: item.booksCount })}
{item.bookCount !== undefined && t("series.books", { count: item.bookCount })}
</p>
</div>
</Link>
@@ -189,12 +165,12 @@ export function GlobalSearch() {
</div>
)}
{results.books.length > 0 && (
{bookResults.length > 0 && (
<div>
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.books")}
</div>
{results.books.map((item) => (
{bookResults.map((item) => (
<Link
key={item.id}
href={item.href}
@@ -203,10 +179,11 @@ export function GlobalSearch() {
aria-label={t("header.search.openBook", { title: item.title })}
>
<img
src={getImageUrl("book", item.id)}
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 rounded object-cover bg-muted"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>

View File

@@ -15,7 +15,7 @@ import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react";
import { useEffect, useState, useCallback } from "react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { useToast } from "@/components/ui/use-toast";
import { useTranslate } from "@/hooks/useTranslate";
import { NavButton } from "@/components/ui/nav-button";
@@ -25,8 +25,8 @@ import logger from "@/lib/logger";
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
initialLibraries: KomgaLibrary[];
initialFavorites: KomgaSeries[];
initialLibraries: NormalizedLibrary[];
initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean;
}
@@ -40,8 +40,8 @@ export function Sidebar({
const { t } = useTranslate();
const pathname = usePathname();
const router = useRouter();
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<NormalizedSeries[]>(initialFavorites || []);
const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast();
@@ -60,7 +60,7 @@ export function Sidebar({
const customEvent = event as CustomEvent<{
seriesId?: string;
action?: "add" | "remove";
series?: KomgaSeries;
series?: NormalizedSeries;
}>;
// Si on a les détails de l'action, faire une mise à jour optimiste locale
@@ -207,7 +207,7 @@ export function Sidebar({
<NavButton
key={series.id}
icon={Star}
label={series.metadata.title}
label={series.name}
active={pathname === `/series/${series.id}`}
onClick={() => handleLinkClick(`/series/${series.id}`)}
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"

View File

@@ -1,17 +1,17 @@
import { Library } from "lucide-react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { RefreshButton } from "./RefreshButton";
import { ScanButton } from "./ScanButton";
import { StatusBadge } from "@/components/ui/status-badge";
import { SeriesCover } from "@/components/ui/series-cover";
interface LibraryHeaderProps {
library: KomgaLibrary;
library: NormalizedLibrary;
seriesCount: number;
series: KomgaSeries[];
series: NormalizedSeries[];
}
const getHeaderSeries = (series: KomgaSeries[]) => {
const getHeaderSeries = (series: NormalizedSeries[]) => {
if (series.length === 0) {
return { featured: null, background: null };
}
@@ -84,8 +84,6 @@ export function LibraryHeader({
<RefreshButton libraryId={library.id} />
<ScanButton libraryId={library.id} />
</div>
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { SeriesList } from "./SeriesList";
import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate";
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
@@ -15,7 +15,7 @@ import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
interface PaginatedSeriesGridProps {
series: KomgaSeries[];
series: NormalizedSeries[];
currentPage: number;
totalPages: number;
totalElements: number;
@@ -108,19 +108,13 @@ export function PaginatedSeriesGrid({
const handleUnreadFilter = async () => {
const newUnreadState = !showOnlyUnread;
setShowOnlyUnread(newUnreadState);
await updateUrlParams({
page: "1",
unread: newUnreadState ? "true" : "false",
});
await updateUrlParams({ page: "1", unread: newUnreadState ? "true" : "false" });
await persistPreferences({ showOnlyUnread: newUnreadState });
};
const handlePageSizeChange = async (size: number) => {
setCurrentPageSize(size);
await updateUrlParams({
page: "1",
size: size.toString(),
});
await updateUrlParams({ page: "1", size: size.toString() });
await persistPreferences({
displayMode: {

View File

@@ -1,29 +1,29 @@
"use client";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
interface SeriesGridProps {
series: KomgaSeries[];
series: NormalizedSeries[];
isCompact?: boolean;
}
// Utility function to get reading status info
const getReadingStatusInfo = (
series: KomgaSeries,
series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (series.booksCount === 0) {
if (series.bookCount === 0) {
return {
label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (series.booksCount === series.booksReadCount) {
if (series.bookCount === series.booksReadCount) {
return {
label: t("series.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -34,7 +34,7 @@ const getReadingStatusInfo = (
return {
label: t("series.status.progress", {
read: series.booksReadCount,
total: series.booksCount,
total: series.bookCount,
}),
className: "bg-primary/15 text-primary",
};
@@ -67,32 +67,32 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
{series.map((series) => (
{series.map((seriesItem) => (
<button
key={series.id}
onClick={() => router.push(`/series/${series.id}`)}
key={seriesItem.id}
onClick={() => router.push(`/series/${seriesItem.id}`)}
className={cn(
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
series.booksCount === series.booksReadCount && "opacity-50",
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]"
)}
>
<SeriesCover
series={series as KomgaSeries}
alt={t("series.coverAlt", { title: series.metadata.title })}
series={seriesItem}
alt={t("series.coverAlt", { title: seriesItem.name })}
/>
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
<h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(series, t).className
getReadingStatusInfo(seriesItem, t).className
}`}
>
{getReadingStatusInfo(series, t).label}
{getReadingStatusInfo(seriesItem, t).label}
</span>
<span className="text-xs text-white/80">
{t("series.books", { count: series.booksCount })}
{t("series.books", { count: seriesItem.bookCount })}
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { SeriesCover } from "@/components/ui/series-cover";
import { useRouter } from "next/navigation";
import { useTranslate } from "@/hooks/useTranslate";
@@ -10,28 +10,28 @@ import { BookOpen, Calendar, Tag, User } from "lucide-react";
import { formatDate } from "@/lib/utils";
interface SeriesListProps {
series: KomgaSeries[];
series: NormalizedSeries[];
isCompact?: boolean;
}
interface SeriesListItemProps {
series: KomgaSeries;
series: NormalizedSeries;
isCompact?: boolean;
}
// Utility function to get reading status info
const getReadingStatusInfo = (
series: KomgaSeries,
series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (series.booksCount === 0) {
if (series.bookCount === 0) {
return {
label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (series.booksCount === series.booksReadCount) {
if (series.bookCount === series.booksReadCount) {
return {
label: t("series.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -42,7 +42,7 @@ const getReadingStatusInfo = (
return {
label: t("series.status.progress", {
read: series.booksReadCount,
total: series.booksCount,
total: series.bookCount,
}),
className: "bg-primary/15 text-primary",
};
@@ -62,9 +62,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
router.push(`/series/${series.id}`);
};
const isCompleted = series.booksCount === series.booksReadCount;
const isCompleted = series.bookCount === series.booksReadCount;
const progressPercentage =
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
const statusInfo = getReadingStatusInfo(series, t);
@@ -81,7 +81,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover
series={series}
alt={t("series.coverAlt", { title: series.metadata.title })}
alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full"
/>
</div>
@@ -91,7 +91,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{/* Titre et statut */}
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.metadata.title}
{series.name}
</h3>
<span
className={cn(
@@ -108,15 +108,15 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.bookCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
: t("series.books", { count: series.bookCount })}
</span>
</div>
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
{series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
<span className="line-clamp-1">{series.authors[0].name}</span>
</div>
)}
</div>
@@ -137,7 +137,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover
series={series}
alt={t("series.coverAlt", { title: series.metadata.title })}
alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full"
/>
</div>
@@ -148,7 +148,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
{series.metadata.title}
{series.name}
</h3>
</div>
@@ -164,9 +164,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div>
{/* Résumé */}
{series.metadata.summary && (
{series.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{series.metadata.summary}
{series.summary}
</p>
)}
@@ -176,55 +176,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.bookCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
: t("series.books", { count: series.bookCount })}
</span>
</div>
{/* Auteurs */}
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
{series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
{series.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de création */}
{series.created && (
{series.createdAt && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(series.created)}</span>
<span>{formatDate(series.createdAt)}</span>
</div>
)}
{/* Genres */}
{series.metadata.genres && series.metadata.genres.length > 0 && (
{series.genres && series.genres.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{series.metadata.genres.slice(0, 3).join(", ")}
{series.metadata.genres.length > 3 && ` +${series.metadata.genres.length - 3}`}
{series.genres.slice(0, 3).join(", ")}
{series.genres.length > 3 && ` +${series.genres.length - 3}`}
</span>
</div>
)}
{/* Tags */}
{series.metadata.tags && series.metadata.tags.length > 0 && (
{series.tags && series.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{series.metadata.tags.slice(0, 3).join(", ")}
{series.metadata.tags.length > 3 && ` +${series.metadata.tags.length - 3}`}
{series.tags.slice(0, 3).join(", ")}
{series.tags.length > 3 && ` +${series.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
{series.booksCount > 0 && !isCompleted && series.booksReadCount > 0 && (
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
<div className="space-y-1">
<Progress value={progressPercentage} className="h-2" />
<p className="text-xs text-muted-foreground">

View File

@@ -5,16 +5,16 @@ import { ClientBookWrapper } from "./ClientBookWrapper";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
import { getBookData } from "@/app/actions/books";
interface ClientBookPageProps {
bookId: string;
initialData?: {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
};
initialError?: string;
}
@@ -23,9 +23,9 @@ export function ClientBookPage({ bookId, initialData, initialError }: ClientBook
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
} | null>(null);
// Use SSR data if available

View File

@@ -2,12 +2,12 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader";
import { Button } from "@/components/ui/button";
interface ClientBookReaderProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
}

View File

@@ -1,32 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader";
import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface ClientBookWrapperProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
}
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
const router = useRouter();
const [isClosing, setIsClosing] = useState(false);
const [targetPath, setTargetPath] = useState<string | null>(null);
useEffect(() => {
if (!isClosing || !targetPath) return;
router.push(targetPath);
}, [isClosing, targetPath, router]);
const handleCloseReader = (currentPage: number) => {
ClientOfflineBookService.setCurrentPage(book, currentPage);
setTargetPath(`/series/${book.seriesId}`);
setIsClosing(true);
router.back();
};
if (isClosing) {

View File

@@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const lastClickTimeRef = useRef<number>(0);
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Derive page URL builder from book.thumbnailUrl (provider-agnostic)
const bookPageUrlBuilder = useCallback(
(pageNum: number) => book.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`),
[book.thumbnailUrl]
);
const nextBookPageUrlBuilder = useCallback(
(pageNum: number) =>
nextBook ? nextBook.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`) : "",
[nextBook]
);
// Hooks
const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen();
@@ -38,10 +49,10 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
getPageUrl,
prefetchCount,
} = useImageLoader({
bookId: book.id,
pageUrlBuilder: bookPageUrlBuilder,
pages,
prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
nextBook: nextBook ? { getPageUrl: nextBookPageUrlBuilder, pages: [] } : null,
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({

View File

@@ -9,14 +9,14 @@ interface ImageDimensions {
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
interface UseImageLoaderProps {
bookId: string;
pageUrlBuilder: (pageNum: number) => string;
pages: number[];
prefetchCount?: number; // Nombre de pages à précharger (défaut: 5)
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
nextBook?: { getPageUrl: (pageNum: number) => string; pages: number[] } | null; // Livre suivant pour prefetch
}
export function useImageLoader({
bookId,
pageUrlBuilder,
pages: _pages,
prefetchCount = 5,
nextBook,
@@ -73,8 +73,8 @@ export function useImageLoader({
);
const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
[bookId]
(pageNum: number) => pageUrlBuilder(pageNum),
[pageUrlBuilder]
);
// Prefetch image and store dimensions
@@ -216,7 +216,7 @@ export function useImageLoader({
abortControllersRef.current.set(nextBookPageKey, controller);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
const response = await fetch(nextBook.getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
});

View File

@@ -1,17 +1,17 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
import { updateReadProgress } from "@/app/actions/read-progress";
interface UsePageNavigationProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean;
onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null;
nextBook?: NormalizedBook | null;
}
export function usePageNavigation({

View File

@@ -1,8 +1,8 @@
import { useState, useCallback, useEffect } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
interface UseThumbnailsProps {
book: KomgaBook;
book: NormalizedBook;
currentPage: number;
}
@@ -16,9 +16,13 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
const getThumbnailUrl = useCallback(
(pageNumber: number) => {
// Derive page URL from the book's thumbnailUrl provider pattern
if (book.thumbnailUrl.startsWith("/api/stripstream/")) {
return `/api/stripstream/images/books/${book.id}/pages/${pageNumber}`;
}
return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail?zero_based=true`;
},
[book.id]
[book.id, book.thumbnailUrl]
);
// Mettre à jour les thumbnails visibles autour de la page courante

View File

@@ -1,4 +1,4 @@
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
export interface PageCache {
[pageNumber: number]: {
@@ -10,10 +10,10 @@ export interface PageCache {
}
export interface BookReaderProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null;
nextBook?: NormalizedBook | null;
}
export interface ThumbnailProps {
@@ -32,7 +32,7 @@ export interface NavigationBarProps {
onPageChange: (page: number) => void;
showControls: boolean;
showThumbnails: boolean;
book: KomgaBook;
book: NormalizedBook;
}
export interface ControlButtonsProps {
@@ -57,7 +57,7 @@ export interface ControlButtonsProps {
}
export interface UsePageNavigationProps {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
isDoublePage: boolean;
onClose?: () => void;

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -8,16 +8,16 @@ import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
interface BookGridProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
books: NormalizedBook[];
onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean;
onRefresh?: () => void;
}
interface BookCardProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact: boolean;
}
@@ -50,9 +50,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
book={book}
alt={t("books.coverAlt", {
title:
book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: ""),
})}
onSuccess={(book, action) => onSuccess(book, action)}
@@ -84,7 +84,7 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
@@ -93,10 +93,8 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
page: previousBook.pageCount,
lastReadAt: new Date().toISOString(),
},
}
: previousBook

View File

@@ -1,6 +1,6 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -9,22 +9,22 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { formatDate } from "@/lib/utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { Progress } from "@/components/ui/progress";
import { Calendar, FileText, User, Tag } from "lucide-react";
import { FileText } from "lucide-react";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button";
interface BookListProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
books: NormalizedBook[];
onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean;
onRefresh?: () => void;
}
interface BookListItemProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact?: boolean;
}
@@ -40,7 +40,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
const getStatusInfo = () => {
@@ -52,7 +52,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -77,8 +77,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const statusInfo = getStatusInfo();
const title =
book.metadata.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
book.title ||
(book.number ? t("navigation.volume", { number: book.number }) : "");
if (isCompact) {
return (
@@ -130,8 +130,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{/* Métadonnées minimales */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{book.metadata.number && (
<span>{t("navigation.volume", { number: book.metadata.number })}</span>
{book.number && (
<span>{t("navigation.volume", { number: book.number })}</span>
)}
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
@@ -139,12 +139,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div>
</div>
</div>
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
>
{title}
</h3>
{book.metadata.number && (
{book.number && (
<p className="text-sm text-muted-foreground mt-1">
{t("navigation.volume", { number: book.metadata.number })}
{t("navigation.volume", { number: book.number })}
</p>
)}
</div>
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</span>
</div>
{/* Résumé */}
{book.metadata.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{book.metadata.summary}
</p>
)}
{/* Métadonnées */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Pages */}
@@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{/* Auteurs */}
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de sortie */}
{book.metadata.releaseDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(book.metadata.releaseDate)}</span>
</div>
)}
{/* Tags */}
{book.metadata.tags && book.metadata.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.tags.slice(0, 3).join(", ")}
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
@@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => onSuccess(book, "read")}
className="text-xs"
@@ -311,7 +269,7 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
@@ -320,10 +278,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
page: previousBook.pageCount,
lastReadAt: new Date().toISOString(),
},
}
: previousBook

View File

@@ -5,7 +5,7 @@ import { BookList } from "./BookList";
import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext";
@@ -15,7 +15,7 @@ import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
interface PaginatedBookGridProps {
books: KomgaBook[];
books: NormalizedBook[];
currentPage: number;
totalPages: number;
totalElements: number;
@@ -95,13 +95,10 @@ export function PaginatedBookGrid({
};
const handlePageSizeChange = async (size: number) => {
await updateUrlParams({
page: "1",
size: size.toString(),
});
await updateUrlParams({ page: "1", size: size.toString() });
};
const handleBookClick = (book: KomgaBook) => {
const handleBookClick = (book: NormalizedBook) => {
router.push(`/books/${book.id}`);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
import type { KomgaSeries } from "@/types/komga";
import type { NormalizedSeries } from "@/lib/providers/types";
import { useState, useEffect } from "react";
import { useToast } from "@/components/ui/use-toast";
import { RefreshButton } from "@/components/library/RefreshButton";
@@ -16,7 +16,7 @@ import logger from "@/lib/logger";
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
interface SeriesHeaderProps {
series: KomgaSeries;
series: NormalizedSeries;
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
initialIsFavorite: boolean;
}
@@ -48,7 +48,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
window.dispatchEvent(event);
toast({
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
description: series.metadata.title,
description: series.name,
});
} else {
throw new AppError(
@@ -69,10 +69,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
};
const getReadingStatusInfo = () => {
const { booksCount, booksReadCount, booksUnreadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
const { bookCount, booksReadCount } = series;
const booksUnreadCount = bookCount - booksReadCount;
const booksInProgressCount = bookCount - (booksReadCount + booksUnreadCount);
if (booksReadCount === booksCount) {
if (booksReadCount === bookCount) {
return {
label: t("series.header.status.read"),
status: "success" as const,
@@ -80,11 +81,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
};
}
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) {
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < bookCount)) {
return {
label: t("series.header.status.progress", {
read: booksReadCount,
total: booksCount,
total: bookCount,
}),
status: "reading" as const,
icon: BookOpen,
@@ -105,8 +106,8 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Image de fond */}
<div className="absolute inset-0">
<SeriesCover
series={series as KomgaSeries}
alt={t("series.header.coverAlt", { title: series.metadata.title })}
series={series}
alt={t("series.header.coverAlt", { title: series.name })}
className="blur-sm scale-105 brightness-50"
showProgressUi={false}
/>
@@ -118,18 +119,18 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Image principale */}
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0">
<SeriesCover
series={series as KomgaSeries}
alt={t("series.header.coverAlt", { title: series.metadata.title })}
series={series}
alt={t("series.header.coverAlt", { title: series.name })}
showProgressUi={false}
/>
</div>
{/* Informations */}
<div className="flex-1 text-white space-y-2 text-center md:text-left">
<h1 className="text-2xl md:text-3xl font-bold">{series.metadata.title}</h1>
{series.metadata.summary && (
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
{series.summary && (
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
{series.metadata.summary}
{series.summary}
</p>
)}
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
@@ -137,9 +138,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{statusInfo.label}
</StatusBadge>
<span className="text-sm text-white/80">
{series.booksCount === 1
? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount })}
{series.bookCount === 1
? t("series.header.books", { count: series.bookCount })
: t("series.header.books_plural", { count: series.bookCount })}
</span>
<IconButton
variant="ghost"

View File

@@ -14,11 +14,11 @@ import { Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { SliderControl } from "@/components/ui/slider-control";
import type { KomgaLibrary } from "@/types/komga";
import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BackgroundSettingsProps {
initialLibraries: KomgaLibrary[];
initialLibraries: NormalizedLibrary[];
}
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
@@ -27,7 +27,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
const { preferences, updatePreferences } = usePreferences();
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
const [komgaConfigValid, setKomgaConfigValid] = useState(false);
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
preferences.background.komgaLibraries || []
);
@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
htmlFor={`lib-${library.id}`}
className="cursor-pointer font-normal text-sm"
>
{library.name} ({library.booksCount} livres)
{library.name} ({library.bookCount} livres)
</Label>
</div>
))}

View File

@@ -2,10 +2,12 @@
import { useEffect, useState } from "react";
import type { KomgaConfig } from "@/types/komga";
import type { KomgaLibrary } from "@/types/komga";
import type { NormalizedLibrary, ProviderType } from "@/lib/providers/types";
import { useTranslate } from "@/hooks/useTranslate";
import { DisplaySettings } from "./DisplaySettings";
import { KomgaSettings } from "./KomgaSettings";
import { StripstreamSettings } from "./StripstreamSettings";
import { ProviderSelector } from "./ProviderSelector";
import { BackgroundSettings } from "./BackgroundSettings";
import { AdvancedSettings } from "./AdvancedSettings";
import { CacheSettings } from "./CacheSettings";
@@ -14,12 +16,23 @@ import { Monitor, Network } from "lucide-react";
interface ClientSettingsProps {
initialConfig: KomgaConfig | null;
initialLibraries: KomgaLibrary[];
initialLibraries: NormalizedLibrary[];
stripstreamConfig?: { url?: string; hasToken: boolean } | null;
providersStatus?: {
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: ProviderType;
};
}
const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab";
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
export function ClientSettings({
initialConfig,
initialLibraries,
stripstreamConfig,
providersStatus,
}: ClientSettingsProps) {
const { t } = useTranslate();
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
@@ -63,7 +76,18 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
</TabsContent>
<TabsContent value="connection" className="mt-6 space-y-6">
{providersStatus && (
<ProviderSelector
activeProvider={providersStatus.activeProvider}
komgaConfigured={providersStatus.komgaConfigured}
stripstreamConfigured={providersStatus.stripstreamConfigured}
/>
)}
<KomgaSettings initialConfig={initialConfig} />
<StripstreamSettings
initialUrl={stripstreamConfig?.url}
hasToken={stripstreamConfig?.hasToken}
/>
<AdvancedSettings />
<CacheSettings />
</TabsContent>

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { CheckCircle, Circle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { setActiveProvider } from "@/app/actions/stripstream-config";
import type { ProviderType } from "@/lib/providers/types";
interface ProviderSelectorProps {
activeProvider: ProviderType;
komgaConfigured: boolean;
stripstreamConfigured: boolean;
}
const providers: { id: ProviderType; label: string; description: string }[] = [
{
id: "komga",
label: "Komga",
description: "Serveur de gestion de BD / manga (Basic Auth)",
},
{
id: "stripstream",
label: "Stripstream Librarian",
description: "Serveur de gestion de BD / manga (Bearer Token)",
},
];
export function ProviderSelector({
activeProvider,
komgaConfigured,
stripstreamConfigured,
}: ProviderSelectorProps) {
const { toast } = useToast();
const [current, setCurrent] = useState<ProviderType>(activeProvider);
const [isChanging, setIsChanging] = useState(false);
const isConfigured = (id: ProviderType) =>
id === "komga" ? komgaConfigured : stripstreamConfigured;
const handleSelect = async (provider: ProviderType) => {
if (provider === current) return;
setIsChanging(true);
try {
const result = await setActiveProvider(provider);
if (!result.success) {
throw new Error(result.message);
}
setCurrent(provider);
toast({ title: "Provider actif", description: result.message });
window.location.reload();
} catch (error) {
toast({
variant: "destructive",
title: "Erreur",
description: error instanceof Error ? error.message : "Changement de provider échoué",
});
} finally {
setIsChanging(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Provider actif</CardTitle>
<CardDescription>
Choisissez le serveur que l&apos;application doit utiliser. Les deux configurations peuvent coexister.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{providers.map((provider) => {
const active = current === provider.id;
const configured = isConfigured(provider.id);
return (
<button
key={provider.id}
type="button"
disabled={isChanging || !configured}
onClick={() => handleSelect(provider.id)}
className={cn(
"relative flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/50",
(!configured || isChanging) && "cursor-not-allowed opacity-60"
)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{provider.label}</span>
{active ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">{provider.description}</p>
{configured ? (
<span className="text-xs text-green-600 dark:text-green-400"> Configuré</span>
) : (
<span className="text-xs text-muted-foreground">Non configuré</span>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { Network, Loader2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import logger from "@/lib/logger";
import { saveStripstreamConfig, testStripstreamConnection } from "@/app/actions/stripstream-config";
interface StripstreamSettingsProps {
initialUrl?: string;
hasToken?: boolean;
}
export function StripstreamSettings({ initialUrl, hasToken }: StripstreamSettingsProps) {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [url, setUrl] = useState(initialUrl ?? "");
const [token, setToken] = useState("");
const [isEditing, setIsEditing] = useState(!initialUrl);
const isConfigured = !!initialUrl;
const shouldShowForm = !isConfigured || isEditing;
const handleTest = async () => {
if (!url) return;
setIsLoading(true);
try {
const result = await testStripstreamConnection(url.trim(), token);
if (!result.success) {
throw new Error(result.message);
}
toast({ title: "Stripstream Librarian", description: result.message });
} catch (error) {
logger.error({ err: error }, "Erreur test Stripstream:");
toast({
variant: "destructive",
title: "Erreur de connexion",
description: error instanceof Error ? error.message : "Connexion échouée",
});
} finally {
setIsLoading(false);
}
};
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
try {
const result = await saveStripstreamConfig(url.trim(), token);
if (!result.success) {
throw new Error(result.message);
}
setIsEditing(false);
setToken("");
toast({ title: "Stripstream Librarian", description: result.message });
window.location.reload();
} catch (error) {
logger.error({ err: error }, "Erreur sauvegarde Stripstream:");
toast({
variant: "destructive",
title: "Erreur de sauvegarde",
description: error instanceof Error ? error.message : "Erreur lors de la sauvegarde",
});
} finally {
setIsSaving(false);
}
};
const inputClass =
"flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
const btnSecondary =
"flex-1 inline-flex items-center justify-center rounded-md bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
const btnPrimary =
"flex-1 inline-flex items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5" />
Stripstream Librarian
</CardTitle>
<CardDescription>
Connectez votre instance Stripstream Librarian via token API (format{" "}
<code className="text-xs">stl_...</code>).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!shouldShowForm ? (
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium">URL du serveur</label>
<p className="text-sm text-muted-foreground">{url}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Token API</label>
<p className="text-sm text-muted-foreground">{hasToken ? "••••••••" : "Non configuré"}</p>
</div>
</div>
<button
type="button"
onClick={() => setIsEditing(true)}
className="inline-flex items-center justify-center rounded-md bg-secondary/80 backdrop-blur-md px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Modifier
</button>
</div>
) : (
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="stripstream-url" className="text-sm font-medium">
URL du serveur
</label>
<input
type="url"
id="stripstream-url"
name="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://librarian.example.com"
className={inputClass}
/>
</div>
<div className="space-y-2">
<label htmlFor="stripstream-token" className="text-sm font-medium">
Token API
{isConfigured && (
<span className="ml-2 text-xs text-muted-foreground">(laisser vide pour conserver l&apos;actuel)</span>
)}
</label>
<input
type="password"
id="stripstream-token"
name="token"
required={!isConfigured}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={isConfigured ? "••••••••" : "stl_xxxx_xxxxxxxx"}
className={inputClass}
/>
</div>
</div>
<div className="flex gap-3">
<button type="submit" disabled={isSaving} className={btnPrimary}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sauvegarde...
</>
) : (
"Sauvegarder"
)}
</button>
<button
type="button"
onClick={handleTest}
disabled={isLoading || !url}
className={btnSecondary}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Test...
</>
) : (
"Tester"
)}
</button>
{isConfigured && (
<button
type="button"
onClick={() => {
setIsEditing(false);
setToken("");
}}
className={btnSecondary}
>
Annuler
</button>
)}
</div>
</form>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,20 +2,18 @@
import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { MarkAsReadButton } from "./mark-as-read-button";
import { MarkAsUnreadButton } from "./mark-as-unread-button";
import { BookOfflineButton } from "./book-offline-button";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaBook } from "@/types/komga";
import { formatDate } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { WifiOff } from "lucide-react";
// Fonction utilitaire pour obtenir les informations de statut de lecture
const getReadingStatusInfo = (
book: KomgaBook,
book: BookCoverProps["book"],
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (!book.readProgress) {
@@ -26,7 +24,7 @@ const getReadingStatusInfo = (
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -39,7 +37,7 @@ const getReadingStatusInfo = (
return {
label: t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
}),
className: "bg-blue-500/10 text-blue-500",
};
@@ -64,11 +62,10 @@ export function BookCover({
const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id);
const imageUrl = getImageUrl("book", book.id);
const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
const statusInfo = getReadingStatusInfo(book, t);
@@ -90,7 +87,7 @@ export function BookCover({
<>
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<img
src={imageUrl.trim()}
src={book.thumbnailUrl.trim()}
alt={alt || t("books.defaultCoverAlt")}
loading="lazy"
className={[
@@ -121,7 +118,7 @@ export function BookCover({
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => handleMarkAsRead()}
className="bg-white/90 hover:bg-white text-black shadow-sm"
@@ -143,9 +140,9 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</p>
<div className="flex items-center gap-2">
@@ -160,15 +157,15 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && (
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</h3>
<p className="text-xs text-white/80 mt-1">
{t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
})}
</p>
</div>

View File

@@ -4,11 +4,11 @@ import { useState, useEffect, useCallback } from "react";
import { Download, Check, Loader2 } from "lucide-react";
import { Button } from "./button";
import { useToast } from "./use-toast";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookOfflineButtonProps {
book: KomgaBook;
book: NormalizedBook;
className?: string;
}
@@ -57,7 +57,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Marque le début du téléchargement
setBookStatus(book.id, {
status: "downloading",
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
progress: ((startFromPage - 1) / book.pageCount) * 100,
timestamp: Date.now(),
lastDownloadedPage: startFromPage - 1,
});
@@ -71,7 +71,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Cache chaque page avec retry
let failedPages = 0;
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
for (let i = startFromPage; i <= book.pageCount; i++) {
let retryCount = 0;
const maxRetries = 3;
@@ -105,7 +105,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
}
// Mise à jour du statut
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
setBookStatus(book.id, {
status: "downloading",
@@ -125,7 +125,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
if (failedPages > 0) {
// Si des pages ont échoué, on supprime tout le cache pour ce livre
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
setIsAvailableOffline(false);
@@ -159,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setDownloadProgress(0);
}
},
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
[book.id, book.pageCount, getBookStatus, setBookStatus, toast]
);
const checkOfflineAvailability = useCallback(async () => {
@@ -177,7 +177,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Vérifie que toutes les pages sont dans le cache
let allPagesAvailable = true;
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
const page = await cache.match(`/api/komga/images/books/${book.id}/pages/${i}`);
if (!page) {
allPagesAvailable = false;
@@ -195,7 +195,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
logger.error({ err: error }, "Erreur lors de la vérification du cache:");
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
}
}, [book.id, book.media.pagesCount, setBookStatus]);
}, [book.id, book.pageCount, setBookStatus]);
useEffect(() => {
const checkStatus = async () => {
@@ -242,9 +242,9 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
// Supprime le livre du cache
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
}
setIsAvailableOffline(false);

View File

@@ -1,4 +1,4 @@
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface BaseCoverProps {
alt?: string;
@@ -9,13 +9,13 @@ export interface BaseCoverProps {
}
export interface BookCoverProps extends BaseCoverProps {
book: KomgaBook;
onSuccess?: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onSuccess?: (book: NormalizedBook, action: "read" | "unread") => void;
showControls?: boolean;
showOverlay?: boolean;
overlayVariant?: "default" | "home";
}
export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries;
series: NormalizedSeries;
}

View File

@@ -1,6 +1,5 @@
import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
export function SeriesCover({
series,
@@ -8,17 +7,16 @@ export function SeriesCover({
className,
showProgressUi = true,
}: SeriesCoverProps) {
const imageUrl = getImageUrl("series", series.id);
const isCompleted = series.booksCount === series.booksReadCount;
const isCompleted = series.bookCount === series.booksReadCount;
const readBooks = series.booksReadCount;
const totalBooks = series.booksCount;
const totalBooks = series.bookCount;
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
return (
<div className="relative w-full h-full">
<img
src={imageUrl}
src={series.thumbnailUrl}
alt={alt}
loading="lazy"
className={[

View File

@@ -0,0 +1,3 @@
export const HOME_CACHE_TAG = "home-data";
export const LIBRARY_SERIES_CACHE_TAG = "library-series";
export const SERIES_BOOKS_CACHE_TAG = "series-books";

View File

@@ -27,6 +27,11 @@ export const ERROR_CODES = {
HTTP_ERROR: "KOMGA_HTTP_ERROR",
SERVER_UNREACHABLE: "KOMGA_SERVER_UNREACHABLE",
},
STRIPSTREAM: {
MISSING_CONFIG: "STRIPSTREAM_MISSING_CONFIG",
CONNECTION_ERROR: "STRIPSTREAM_CONNECTION_ERROR",
HTTP_ERROR: "STRIPSTREAM_HTTP_ERROR",
},
CONFIG: {
SAVE_ERROR: "CONFIG_SAVE_ERROR",
FETCH_ERROR: "CONFIG_FETCH_ERROR",

View File

@@ -21,6 +21,11 @@ export const ERROR_MESSAGES: Record<string, string> = {
[ERROR_CODES.AUTH.LOGOUT_ERROR]: "🚪 Error during logout",
[ERROR_CODES.AUTH.REGISTRATION_FAILED]: "❌ Registration failed",
// Stripstream
[ERROR_CODES.STRIPSTREAM.MISSING_CONFIG]: "⚙️ Stripstream Librarian configuration not found",
[ERROR_CODES.STRIPSTREAM.CONNECTION_ERROR]: "🌐 Stripstream connection error",
[ERROR_CODES.STRIPSTREAM.HTTP_ERROR]: "🌍 Stripstream HTTP Error: {status} {statusText}",
// Komga
[ERROR_CODES.KOMGA.MISSING_CONFIG]: "⚙️ Komga configuration not found",
[ERROR_CODES.KOMGA.MISSING_CREDENTIALS]: "🔑 Missing Komga credentials",

View File

@@ -377,6 +377,9 @@
"KOMGA_CONNECTION_ERROR": "Error connecting to Komga server",
"KOMGA_HTTP_ERROR": "HTTP error while communicating with Komga",
"KOMGA_SERVER_UNREACHABLE": "Komga server unreachable",
"STRIPSTREAM_MISSING_CONFIG": "Stripstream Librarian configuration missing",
"STRIPSTREAM_CONNECTION_ERROR": "Error connecting to Stripstream Librarian",
"STRIPSTREAM_HTTP_ERROR": "HTTP error while communicating with Stripstream Librarian",
"CONFIG_SAVE_ERROR": "Error saving configuration",
"CONFIG_FETCH_ERROR": "Error fetching configuration",

View File

@@ -375,6 +375,9 @@
"KOMGA_CONNECTION_ERROR": "Erreur de connexion au serveur Komga",
"KOMGA_HTTP_ERROR": "Erreur HTTP lors de la communication avec Komga",
"KOMGA_SERVER_UNREACHABLE": "Serveur Komga inaccessible",
"STRIPSTREAM_MISSING_CONFIG": "Configuration Stripstream Librarian manquante",
"STRIPSTREAM_CONNECTION_ERROR": "Erreur de connexion à Stripstream Librarian",
"STRIPSTREAM_HTTP_ERROR": "Erreur HTTP lors de la communication avec Stripstream Librarian",
"CONFIG_SAVE_ERROR": "Erreur lors de la sauvegarde de la configuration",
"CONFIG_FETCH_ERROR": "Erreur lors de la récupération de la configuration",

View File

@@ -0,0 +1,55 @@
import type { KomgaBook, KomgaSeries, KomgaLibrary, ReadProgress } from "@/types/komga";
import type {
NormalizedBook,
NormalizedSeries,
NormalizedLibrary,
NormalizedReadProgress,
} from "../types";
export class KomgaAdapter {
static toNormalizedReadProgress(rp: ReadProgress | null): NormalizedReadProgress | null {
if (!rp) return null;
return {
page: rp.page ?? null,
completed: rp.completed,
lastReadAt: rp.readDate ?? null,
};
}
static toNormalizedBook(book: KomgaBook): NormalizedBook {
return {
id: book.id,
libraryId: book.libraryId,
title: book.metadata?.title || book.name,
number: book.metadata?.number ?? null,
seriesId: book.seriesId ?? null,
volume: typeof book.number === "number" ? book.number : null,
pageCount: book.media?.pagesCount ?? 0,
thumbnailUrl: `/api/komga/images/books/${book.id}/thumbnail`,
readProgress: KomgaAdapter.toNormalizedReadProgress(book.readProgress),
};
}
static toNormalizedSeries(series: KomgaSeries): NormalizedSeries {
return {
id: series.id,
name: series.metadata?.title ?? series.name,
bookCount: series.booksCount,
booksReadCount: series.booksReadCount,
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
summary: series.metadata?.summary ?? null,
authors: series.booksMetadata?.authors ?? [],
genres: series.metadata?.genres ?? [],
tags: series.metadata?.tags ?? [],
createdAt: series.created ?? null,
};
}
static toNormalizedLibrary(library: KomgaLibrary): NormalizedLibrary {
return {
id: library.id,
name: library.name,
bookCount: library.booksCount,
};
}
}

View File

@@ -0,0 +1,507 @@
import type { IMediaProvider, BookListFilter } from "../provider.interface";
import type {
NormalizedLibrary,
NormalizedSeries,
NormalizedBook,
NormalizedReadProgress,
NormalizedSearchResult,
NormalizedSeriesPage,
NormalizedBooksPage,
} from "../types";
import type { HomeData } from "@/types/home";
import { KomgaAdapter } from "./komga.adapter";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import type { KomgaBook, KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { LibraryResponse } from "@/types/library";
import type { AuthConfig } from "@/types/auth";
import logger from "@/lib/logger";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
type KomgaCondition = Record<string, unknown>;
const CACHE_TTL_LONG = 300;
const CACHE_TTL_MED = 120;
const CACHE_TTL_SHORT = 30;
const TIMEOUT_MS = 15000;
export class KomgaProvider implements IMediaProvider {
private config: AuthConfig;
constructor(url: string, authHeader: string) {
this.config = { serverUrl: url, authHeader };
}
private buildUrl(path: string, params?: Record<string, string | string[]>): string {
const url = new URL(`${this.config.serverUrl}/api/v1/${path}`);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (Array.isArray(v)) {
v.forEach((val) => url.searchParams.append(k, val));
} else {
url.searchParams.append(k, v);
}
});
}
return url.toString();
}
private getHeaders(extra: Record<string, string> = {}): Headers {
return new Headers({
Authorization: `Basic ${this.config.authHeader}`,
Accept: "application/json",
...extra,
});
}
private async fetch<T>(
path: string,
params?: Record<string, string | string[]>,
options: RequestInit & { revalidate?: number; tags?: string[] } = {}
): Promise<T> {
const url = this.buildUrl(path, params);
const headers = this.getHeaders(options.body ? { "Content-Type": "application/json" } : {});
const isDebug = process.env.KOMGA_DEBUG === "true";
const isCacheDebug = process.env.CACHE_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
if (isDebug) {
logger.info(
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
"🔵 Komga Request"
);
}
if (isCacheDebug && options.revalidate) {
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
}
const nextOptions = options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined;
const fetchOptions = {
headers,
...options,
next: nextOptions,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
interface FetchErrorLike {
code?: string;
cause?: { code?: string };
}
const doFetch = async () => {
try {
return await fetch(url, { ...fetchOptions, signal: controller.signal });
} catch (err: unknown) {
const e = err as FetchErrorLike;
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
logger.error(`DNS resolution failed for ${url}, retrying...`);
return fetch(url, { ...fetchOptions, signal: controller.signal });
}
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
return fetch(url, { ...fetchOptions, signal: controller.signal });
}
throw err;
}
};
try {
const response = await doFetch();
clearTimeout(timeoutId);
if (isDebug) {
const duration = Date.now() - startTime;
logger.info(
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
"🟢 Komga Response"
);
}
if (isCacheDebug && options.revalidate) {
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
}
if (!response.ok) {
if (isDebug) {
logger.error(
{ url, status: response.status, statusText: response.statusText },
"🔴 Komga Error Response"
);
}
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
status: response.status,
statusText: response.statusText,
});
}
return response.json();
} catch (error) {
if (isDebug) {
logger.error(
{
url,
error: error instanceof Error ? error.message : String(error),
duration: `${Date.now() - startTime}ms`,
},
"🔴 Komga Request Failed"
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
async getLibraries(): Promise<NormalizedLibrary[]> {
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
revalidate: CACHE_TTL_LONG,
});
// Enrich with book counts
const enriched = await Promise.all(
raw.map(async (lib) => {
try {
const resp = await this.fetch<{ totalElements: number }>(
"books",
{
library_id: lib.id,
size: "0",
},
{ revalidate: CACHE_TTL_LONG }
);
return { ...lib, booksCount: resp.totalElements, booksReadCount: 0 } as KomgaLibrary;
} catch {
return { ...lib, booksCount: 0, booksReadCount: 0 } as KomgaLibrary;
}
})
);
return enriched.map(KomgaAdapter.toNormalizedLibrary);
}
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
const page = cursor ? parseInt(cursor, 10) - 1 : 0;
let condition: KomgaCondition;
if (unreadOnly) {
condition = {
allOf: [
{ libraryId: { operator: "is", value: libraryId } },
{
anyOf: [
{ readStatus: { operator: "is", value: "UNREAD" } },
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
],
},
],
};
} else {
condition = { libraryId: { operator: "is", value: libraryId } };
}
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
if (search) searchBody.fullTextSearch = search;
const response = await this.fetch<LibraryResponse<KomgaSeries>>(
"series/list",
{ page: String(page), size: String(limit), sort: "metadata.titleSort,asc" },
{
method: "POST",
body: JSON.stringify(searchBody),
revalidate: CACHE_TTL_MED,
tags: [LIBRARY_SERIES_CACHE_TAG],
}
);
const filtered = response.content.filter((s) => !s.deleted);
const sorted = [...filtered].sort((a, b) => {
const ta = a.metadata?.titleSort ?? "";
const tb = b.metadata?.titleSort ?? "";
const cmp = ta.localeCompare(tb);
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
});
return {
items: sorted.map(KomgaAdapter.toNormalizedSeries),
nextCursor: response.last ? null : String(page + 1),
totalPages: response.totalPages,
totalElements: response.totalElements,
};
}
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
const page = filter.cursor ? parseInt(filter.cursor, 10) - 1 : 0;
const limit = filter.limit ?? 24;
let condition: KomgaCondition;
if (filter.seriesName && filter.unreadOnly) {
condition = {
allOf: [
{ seriesId: { operator: "is", value: filter.seriesName } },
{
anyOf: [
{ readStatus: { operator: "is", value: "UNREAD" } },
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
],
},
],
};
} else if (filter.seriesName) {
condition = { seriesId: { operator: "is", value: filter.seriesName } };
} else if (filter.libraryId) {
condition = { libraryId: { operator: "is", value: filter.libraryId } };
} else {
condition = {};
}
const response = await this.fetch<LibraryResponse<KomgaBook>>(
"books/list",
{ page: String(page), size: String(limit), sort: "metadata.numberSort,asc" },
{ method: "POST", body: JSON.stringify({ condition }), revalidate: CACHE_TTL_MED, tags: [SERIES_BOOKS_CACHE_TAG] }
);
const items = response.content.filter((b) => !b.deleted).map(KomgaAdapter.toNormalizedBook);
return {
items,
nextCursor: response.last ? null : String(page + 1),
totalPages: response.totalPages,
totalElements: response.totalElements,
};
}
async getBook(bookId: string): Promise<NormalizedBook> {
const [book, pages] = await Promise.all([
this.fetch<KomgaBook>(`books/${bookId}`, undefined, { revalidate: CACHE_TTL_SHORT }),
this.fetch<{ number: number }[]>(`books/${bookId}/pages`, undefined, {
revalidate: CACHE_TTL_SHORT,
}),
]);
const normalized = KomgaAdapter.toNormalizedBook(book);
return { ...normalized, pageCount: pages.length };
}
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
const series = await this.fetch<KomgaSeries>(`series/${seriesId}`, undefined, {
revalidate: CACHE_TTL_MED,
});
return KomgaAdapter.toNormalizedSeries(series);
}
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
const book = await this.fetch<KomgaBook>(`books/${bookId}`, undefined, {
revalidate: CACHE_TTL_SHORT,
});
return KomgaAdapter.toNormalizedReadProgress(book.readProgress);
}
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
const url = this.buildUrl(`books/${bookId}/read-progress`);
const headers = this.getHeaders({ "Content-Type": "application/json" });
const response = await fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify({ page: page ?? 0, completed }),
});
if (!response.ok) {
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
}
}
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
const trimmed = query.trim();
if (!trimmed) return [];
const body = { fullTextSearch: trimmed };
const [seriesResp, booksResp] = await Promise.all([
this.fetch<LibraryResponse<KomgaSeries>>(
"series/list",
{ page: "0", size: String(limit) },
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
),
this.fetch<LibraryResponse<KomgaBook>>(
"books/list",
{ page: "0", size: String(limit) },
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
),
]);
const results: NormalizedSearchResult[] = [
...seriesResp.content
.filter((s) => !s.deleted)
.map((s) => ({
id: s.id,
title: s.metadata?.title ?? s.name,
href: `/series/${s.id}`,
coverUrl: `/api/komga/images/series/${s.id}/thumbnail`,
type: "series" as const,
bookCount: s.booksCount,
})),
...booksResp.content
.filter((b) => !b.deleted)
.map((b) => ({
id: b.id,
title: b.metadata?.title ?? b.name,
seriesTitle: b.seriesTitle,
seriesId: b.seriesId,
href: `/books/${b.id}`,
coverUrl: `/api/komga/images/books/${b.id}/thumbnail`,
type: "book" as const,
})),
];
return results;
}
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
try {
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, {
revalidate: CACHE_TTL_LONG,
});
try {
const resp = await this.fetch<{ totalElements: number }>(
"books",
{
library_id: lib.id,
size: "0",
},
{ revalidate: CACHE_TTL_LONG }
);
return KomgaAdapter.toNormalizedLibrary({
...lib,
booksCount: resp.totalElements,
booksReadCount: 0,
});
} catch {
return KomgaAdapter.toNormalizedLibrary({ ...lib, booksCount: 0, booksReadCount: 0 });
}
} catch {
return null;
}
}
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
try {
const book = await this.fetch<KomgaBook>(`books/${bookId}/next`);
return KomgaAdapter.toNormalizedBook(book);
} catch (error) {
if (
error instanceof AppError &&
(error as AppError & { params?: { status?: number } }).params?.status === 404
) {
return null;
}
return null;
}
}
async getHomeData(): Promise<HomeData> {
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetch<LibraryResponse<KomgaSeries>>(
"series/list",
{ page: "0", size: "10", sort: "readDate,desc" },
{
method: "POST",
body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}),
...homeOpts,
}
).catch(() => ({ content: [] as KomgaSeries[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/list",
{ page: "0", size: "10", sort: "readProgress.readDate,desc" },
{
method: "POST",
body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}),
...homeOpts,
}
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/latest",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/ondeck",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaSeries>>(
"series/latest",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaSeries[] })),
]);
return {
ongoing: (ongoing.content || []).map(KomgaAdapter.toNormalizedSeries),
ongoingBooks: (ongoingBooks.content || []).map(KomgaAdapter.toNormalizedBook),
recentlyRead: (recentlyRead.content || []).map(KomgaAdapter.toNormalizedBook),
onDeck: (onDeck.content || []).map(KomgaAdapter.toNormalizedBook),
latestSeries: (latestSeries.content || []).map(KomgaAdapter.toNormalizedSeries),
};
}
async resetReadProgress(bookId: string): Promise<void> {
const url = this.buildUrl(`books/${bookId}/read-progress`);
const headers = this.getHeaders();
const response = await fetch(url, { method: "DELETE", headers });
if (!response.ok) {
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
}
}
async scanLibrary(libraryId: string): Promise<void> {
const url = this.buildUrl(`libraries/${libraryId}/scan`);
const headers = this.getHeaders();
await fetch(url, { method: "POST", headers });
}
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
try {
const libraryId = libraryIds?.length
? libraryIds[Math.floor(Math.random() * libraryIds.length)]
: undefined;
const condition: KomgaCondition = libraryId
? { libraryId: { operator: "is", value: libraryId } }
: {};
const randomPage = Math.floor(Math.random() * 5);
const response = await this.fetch<LibraryResponse<KomgaBook>>(
"books/list",
{ page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
{ method: "POST", body: JSON.stringify({ condition }) }
);
const books = response.content.filter((b) => !b.deleted);
if (!books.length) return null;
return books[Math.floor(Math.random() * books.length)].id;
} catch {
return null;
}
}
async testConnection(): Promise<{ ok: boolean; error?: string }> {
try {
await this.fetch<KomgaLibrary[]>("libraries");
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
}
}
getBookThumbnailUrl(bookId: string): string {
return `/api/komga/images/books/${bookId}/thumbnail`;
}
getSeriesThumbnailUrl(seriesId: string): string {
return `/api/komga/images/series/${seriesId}/thumbnail`;
}
getBookPageUrl(bookId: string, pageNumber: number): string {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
}
}

View File

@@ -0,0 +1,52 @@
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import type { IMediaProvider } from "./provider.interface";
export async function getProvider(): Promise<IMediaProvider | null> {
const user = await getCurrentUser();
if (!user) return null;
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: {
activeProvider: true,
config: { select: { url: true, authHeader: true } },
stripstreamConfig: { select: { url: true, token: true } },
},
});
if (!dbUser) return null;
const activeProvider = dbUser.activeProvider ?? "komga";
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
return new StripstreamProvider(
dbUser.stripstreamConfig.url,
dbUser.stripstreamConfig.token
);
}
if (activeProvider === "komga" || !dbUser.activeProvider) {
if (!dbUser.config) return null;
const { KomgaProvider } = await import("./komga/komga.provider");
return new KomgaProvider(dbUser.config.url, dbUser.config.authHeader);
}
return null;
}
export async function getActiveProviderType(): Promise<string | null> {
const user = await getCurrentUser();
if (!user) return null;
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { activeProvider: true },
});
return dbUser?.activeProvider ?? "komga";
}

View File

@@ -0,0 +1,54 @@
import type {
NormalizedLibrary,
NormalizedSeries,
NormalizedBook,
NormalizedReadProgress,
NormalizedSearchResult,
NormalizedSeriesPage,
NormalizedBooksPage,
} from "./types";
import type { HomeData } from "@/types/home";
export interface BookListFilter {
libraryId?: string;
seriesName?: string;
cursor?: string;
limit?: number;
unreadOnly?: boolean;
}
export interface IMediaProvider {
// ── Collections ─────────────────────────────────────────────────────────
getLibraries(): Promise<NormalizedLibrary[]>;
getLibraryById(libraryId: string): Promise<NormalizedLibrary | null>;
getSeries(libraryId: string, cursor?: string, limit?: number, unreadOnly?: boolean, search?: string): Promise<NormalizedSeriesPage>;
getSeriesById(seriesId: string): Promise<NormalizedSeries | null>;
getBooks(filter: BookListFilter): Promise<NormalizedBooksPage>;
getBook(bookId: string): Promise<NormalizedBook>;
getNextBook(bookId: string): Promise<NormalizedBook | null>;
// ── Home ─────────────────────────────────────────────────────────────────
getHomeData(): Promise<HomeData>;
// ── Read progress ────────────────────────────────────────────────────────
getReadProgress(bookId: string): Promise<NormalizedReadProgress | null>;
saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void>;
resetReadProgress(bookId: string): Promise<void>;
// ── Admin / utility ──────────────────────────────────────────────────────
scanLibrary(libraryId: string): Promise<void>;
getRandomBook(libraryIds?: string[]): Promise<string | null>;
// ── Search ───────────────────────────────────────────────────────────────
search(query: string, limit?: number): Promise<NormalizedSearchResult[]>;
// ── Connection ───────────────────────────────────────────────────────────
testConnection(): Promise<{ ok: boolean; error?: string }>;
// ── URL builders (return local proxy URLs) ───────────────────────────────
getBookThumbnailUrl(bookId: string): string;
getSeriesThumbnailUrl(seriesId: string): string;
getBookPageUrl(bookId: string, pageNumber: number): string;
}

View File

@@ -0,0 +1,85 @@
import type {
StripstreamBookItem,
StripstreamBookDetails,
StripstreamSeriesItem,
StripstreamLibraryResponse,
StripstreamReadingProgressResponse,
} from "@/types/stripstream";
import type {
NormalizedBook,
NormalizedSeries,
NormalizedLibrary,
NormalizedReadProgress,
} from "../types";
export class StripstreamAdapter {
static toNormalizedReadProgress(
rp: StripstreamReadingProgressResponse | null
): NormalizedReadProgress | null {
if (!rp) return null;
return {
page: rp.current_page ?? null,
completed: rp.status === "read",
lastReadAt: rp.last_read_at ?? null,
};
}
static toNormalizedBook(book: StripstreamBookItem): NormalizedBook {
return {
id: book.id,
libraryId: book.library_id,
title: book.title,
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
seriesId: book.series ?? null,
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: {
page: book.reading_current_page ?? null,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
},
};
}
static toNormalizedBookDetails(book: StripstreamBookDetails): NormalizedBook {
return {
id: book.id,
libraryId: book.library_id,
title: book.title,
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
seriesId: book.series ?? null,
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: {
page: book.reading_current_page ?? null,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
},
};
}
static toNormalizedSeries(series: StripstreamSeriesItem): NormalizedSeries {
return {
id: series.first_book_id,
name: series.name,
bookCount: series.book_count,
booksReadCount: series.books_read_count,
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
};
}
static toNormalizedLibrary(library: StripstreamLibraryResponse): NormalizedLibrary {
return {
id: library.id,
name: library.name,
bookCount: library.book_count,
};
}
}

View File

@@ -0,0 +1,161 @@
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
const TIMEOUT_MS = 15000;
interface FetchErrorLike { code?: string; cause?: { code?: string } }
interface FetchOptions extends RequestInit {
revalidate?: number;
tags?: string[];
}
export class StripstreamClient {
private baseUrl: string;
private token: string;
constructor(url: string, token: string) {
// Trim trailing slash
this.baseUrl = url.replace(/\/$/, "");
this.token = token;
}
private getHeaders(extra: Record<string, string> = {}): Headers {
return new Headers({
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
...extra,
});
}
buildUrl(path: string, params?: Record<string, string | undefined>): string {
const url = new URL(`${this.baseUrl}/${path}`);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined) url.searchParams.append(k, v);
});
}
return url.toString();
}
async fetch<T>(
path: string,
params?: Record<string, string | undefined>,
options: FetchOptions = {}
): Promise<T> {
const url = this.buildUrl(path, params);
const headers = this.getHeaders(
options.body ? { "Content-Type": "application/json" } : {}
);
const isDebug = process.env.STRIPSTREAM_DEBUG === "true";
const isCacheDebug = process.env.CACHE_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
if (isDebug) {
logger.info(
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
"🔵 Stripstream Request"
);
}
if (isCacheDebug && options.revalidate) {
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
}
const nextOptions = options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined;
const fetchOptions = {
headers,
...options,
next: nextOptions,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const doFetch = async () => {
try {
return await fetch(url, { ...fetchOptions, signal: controller.signal });
} catch (err: unknown) {
const e = err as FetchErrorLike;
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
logger.error(`DNS resolution failed for ${url}, retrying...`);
return fetch(url, { ...fetchOptions, signal: controller.signal });
}
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
return fetch(url, { ...fetchOptions, signal: controller.signal });
}
throw err;
}
};
try {
const response = await doFetch();
clearTimeout(timeoutId);
if (isDebug) {
const duration = Date.now() - startTime;
logger.info(
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
"🟢 Stripstream Response"
);
}
if (isCacheDebug && options.revalidate) {
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
}
if (!response.ok) {
if (isDebug) {
logger.error(
{ url, status: response.status, statusText: response.statusText },
"🔴 Stripstream Error Response"
);
}
throw new AppError(ERROR_CODES.STRIPSTREAM.HTTP_ERROR, {
status: response.status,
statusText: response.statusText,
});
}
return response.json();
} catch (error) {
if (isDebug) {
logger.error(
{ url, error: error instanceof Error ? error.message : String(error), duration: `${Date.now() - startTime}ms` },
"🔴 Stripstream Request Failed"
);
}
if (error instanceof AppError) throw error;
logger.error({ err: error, url }, "Stripstream request failed");
throw new AppError(ERROR_CODES.STRIPSTREAM.CONNECTION_ERROR, {}, error);
} finally {
clearTimeout(timeoutId);
}
}
async fetchImage(path: string): Promise<Response> {
const url = this.buildUrl(path);
const headers = new Headers({
Authorization: `Bearer ${this.token}`,
Accept: "image/webp, image/jpeg, image/png, */*",
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(url, { headers, signal: controller.signal });
if (!response.ok) {
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
}
return response;
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@@ -0,0 +1,327 @@
import type { IMediaProvider, BookListFilter } from "../provider.interface";
import logger from "@/lib/logger";
import type {
NormalizedLibrary,
NormalizedSeries,
NormalizedBook,
NormalizedReadProgress,
NormalizedSearchResult,
NormalizedSeriesPage,
NormalizedBooksPage,
} from "../types";
import type { HomeData } from "@/types/home";
import { StripstreamClient } from "./stripstream.client";
import { StripstreamAdapter } from "./stripstream.adapter";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import type {
StripstreamLibraryResponse,
StripstreamBooksPage,
StripstreamSeriesPage,
StripstreamBookDetails,
StripstreamReadingProgressResponse,
StripstreamSearchResponse,
} from "@/types/stripstream";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
const CACHE_TTL_LONG = 300;
const CACHE_TTL_MED = 120;
const CACHE_TTL_SHORT = 30;
export class StripstreamProvider implements IMediaProvider {
private client: StripstreamClient;
constructor(url: string, token: string) {
this.client = new StripstreamClient(url, token);
}
async getLibraries(): Promise<NormalizedLibrary[]> {
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
revalidate: CACHE_TTL_LONG,
});
return libraries.map(StripstreamAdapter.toNormalizedLibrary);
}
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
try {
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
revalidate: CACHE_TTL_LONG,
});
const lib = libraries.find((l) => l.id === libraryId);
return lib ? StripstreamAdapter.toNormalizedLibrary(lib) : null;
} catch {
return null;
}
}
// Stripstream series endpoint: GET /libraries/{library_id}/series
async getSeries(libraryId: string, page?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
const pageNumber = page ? parseInt(page) : 1;
const params: Record<string, string | undefined> = { limit: String(limit), page: String(pageNumber) };
if (unreadOnly) params.reading_status = "unread,reading";
if (search?.trim()) params.q = search.trim();
const response = await this.client.fetch<StripstreamSeriesPage>(
`libraries/${libraryId}/series`,
params,
{ revalidate: CACHE_TTL_MED, tags: [LIBRARY_SERIES_CACHE_TAG] }
);
const totalPages = Math.ceil(response.total / limit);
return {
items: response.items.map(StripstreamAdapter.toNormalizedSeries),
nextCursor: null,
totalElements: response.total,
totalPages,
};
}
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
// seriesId can be either a first_book_id (from series cards) or a series name (from book.seriesId).
// Try first_book_id first; fall back to series name search.
try {
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
revalidate: CACHE_TTL_MED,
});
if (!book.series) return null;
return {
id: seriesId,
name: book.series,
bookCount: 0,
booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
};
} catch {
// Fall back: treat seriesId as a series name, find its first book
try {
const page = await this.client.fetch<StripstreamBooksPage>(
"books",
{ series: seriesId, limit: "1" },
{ revalidate: CACHE_TTL_MED }
);
if (!page.items.length) return null;
const firstBook = page.items[0];
return {
id: firstBook.id,
name: seriesId,
bookCount: 0,
booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
};
} catch {
return null;
}
}
}
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
const limit = filter.limit ?? 24;
const params: Record<string, string | undefined> = { limit: String(limit) };
if (filter.seriesName) {
// seriesName is first_book_id for Stripstream — resolve to actual series name
try {
const book = await this.client.fetch<StripstreamBookDetails>(
`books/${filter.seriesName}`,
undefined,
{ revalidate: CACHE_TTL_MED }
);
params.series = book.series ?? filter.seriesName;
} catch {
params.series = filter.seriesName;
}
} else if (filter.libraryId) {
params.library_id = filter.libraryId;
}
if (filter.unreadOnly) params.reading_status = "unread,reading";
const pageNumber = filter.cursor ? parseInt(filter.cursor) : 1;
params.page = String(pageNumber);
const response = await this.client.fetch<StripstreamBooksPage>("books", params, {
revalidate: CACHE_TTL_MED,
tags: [SERIES_BOOKS_CACHE_TAG],
});
const pageSize = filter.limit ?? 24;
const totalPages = Math.ceil(response.total / pageSize);
return {
items: response.items.map(StripstreamAdapter.toNormalizedBook),
nextCursor: null,
totalElements: response.total,
totalPages,
};
}
async getBook(bookId: string): Promise<NormalizedBook> {
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
revalidate: CACHE_TTL_SHORT,
});
return StripstreamAdapter.toNormalizedBookDetails(book);
}
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
try {
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
revalidate: CACHE_TTL_SHORT,
});
if (!book.series || book.volume == null) return null;
const response = await this.client.fetch<StripstreamBooksPage>("books", {
series: book.series,
limit: "200",
}, { revalidate: CACHE_TTL_SHORT });
const sorted = response.items
.filter((b) => b.volume != null)
.sort((a, b) => (a.volume ?? 0) - (b.volume ?? 0));
const idx = sorted.findIndex((b) => b.id === bookId);
if (idx === -1 || idx === sorted.length - 1) return null;
return StripstreamAdapter.toNormalizedBook(sorted[idx + 1]);
} catch {
return null;
}
}
async getHomeData(): Promise<HomeData> {
// Stripstream has no "in-progress" filter — show recent books and first library's series
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
const [booksPage, libraries] = await Promise.allSettled([
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts),
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }),
]);
const books = booksPage.status === "fulfilled"
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
: [];
let latestSeries: NormalizedSeries[] = [];
if (libraries.status === "fulfilled" && libraries.value.length > 0) {
try {
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
`libraries/${libraries.value[0].id}/series`,
{ limit: "10" },
homeOpts
);
latestSeries = seriesPage.items.map(StripstreamAdapter.toNormalizedSeries);
} catch {
latestSeries = [];
}
}
return {
ongoing: latestSeries,
ongoingBooks: books,
recentlyRead: books,
onDeck: [],
latestSeries,
};
}
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
const progress = await this.client.fetch<StripstreamReadingProgressResponse>(
`books/${bookId}/progress`,
undefined,
{ revalidate: CACHE_TTL_SHORT }
);
return StripstreamAdapter.toNormalizedReadProgress(progress);
}
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
const status = completed ? "read" : page !== null && page > 0 ? "reading" : "unread";
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
method: "PATCH",
body: JSON.stringify({ status, current_page: page }),
});
}
async resetReadProgress(bookId: string): Promise<void> {
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
method: "PATCH",
body: JSON.stringify({ status: "unread", current_page: null }),
});
}
async scanLibrary(libraryId: string): Promise<void> {
await this.client.fetch<unknown>(`libraries/${libraryId}/scan`, undefined, {
method: "POST",
});
}
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
try {
const params: Record<string, string | undefined> = { limit: "50" };
if (libraryIds?.length) {
params.library_id = libraryIds[Math.floor(Math.random() * libraryIds.length)];
}
const response = await this.client.fetch<StripstreamBooksPage>("books", params);
if (!response.items.length) return null;
return response.items[Math.floor(Math.random() * response.items.length)].id;
} catch {
return null;
}
}
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
const trimmed = query.trim();
if (!trimmed) return [];
const response = await this.client.fetch<StripstreamSearchResponse>("search", {
q: trimmed,
limit: String(limit),
}, { revalidate: CACHE_TTL_SHORT });
const seriesResults: NormalizedSearchResult[] = response.series_hits.map((s) => ({
id: s.first_book_id,
title: s.name,
href: `/series/${s.first_book_id}`,
coverUrl: `/api/stripstream/images/books/${s.first_book_id}/thumbnail`,
type: "series" as const,
bookCount: s.book_count,
}));
const bookResults: NormalizedSearchResult[] = response.hits.map((hit) => ({
id: hit.id,
title: hit.title,
seriesTitle: hit.series ?? null,
seriesId: hit.series ?? null,
href: `/books/${hit.id}`,
coverUrl: `/api/stripstream/images/books/${hit.id}/thumbnail`,
type: "book" as const,
}));
return [...seriesResults, ...bookResults];
}
async testConnection(): Promise<{ ok: boolean; error?: string }> {
try {
await this.client.fetch<StripstreamLibraryResponse[]>("libraries");
return { ok: true };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
}
}
getBookThumbnailUrl(bookId: string): string {
return `/api/stripstream/images/books/${bookId}/thumbnail`;
}
getSeriesThumbnailUrl(seriesId: string): string {
return `/api/stripstream/images/books/${seriesId}/thumbnail`;
}
getBookPageUrl(bookId: string, pageNumber: number): string {
return `/api/stripstream/images/books/${bookId}/pages/${pageNumber}`;
}
}

View File

@@ -0,0 +1,64 @@
export type ProviderType = "komga" | "stripstream";
export interface NormalizedReadProgress {
page: number | null;
completed: boolean;
lastReadAt: string | null;
}
export interface NormalizedLibrary {
id: string;
name: string;
bookCount: number;
}
export interface NormalizedSeries {
id: string;
name: string;
bookCount: number;
booksReadCount: number;
thumbnailUrl: string;
// Optional metadata (Komga-rich, Stripstream-sparse)
summary?: string | null;
authors?: Array<{ name: string; role: string }>;
genres?: string[];
tags?: string[];
createdAt?: string | null;
}
export interface NormalizedBook {
id: string;
libraryId: string;
title: string;
number: string | null;
seriesId: string | null;
volume: number | null;
pageCount: number;
thumbnailUrl: string;
readProgress: NormalizedReadProgress | null;
}
export interface NormalizedSearchResult {
id: string;
title: string;
seriesTitle?: string | null;
seriesId?: string | null;
href: string;
coverUrl: string;
type: "series" | "book";
bookCount?: number;
}
export interface NormalizedSeriesPage {
items: NormalizedSeries[];
nextCursor: string | null;
totalPages?: number;
totalElements?: number;
}
export interface NormalizedBooksPage {
items: NormalizedBook[];
nextCursor: string | null;
totalPages?: number;
totalElements?: number;
}

View File

@@ -1,272 +0,0 @@
import type { AuthConfig } from "@/types/auth";
import { ConfigDBService } from "./config-db.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { KomgaConfig } from "@/types/komga";
import logger from "@/lib/logger";
interface KomgaRequestInit extends RequestInit {
isImage?: boolean;
noJson?: boolean;
/** Next.js cache duration in seconds. Use false to disable cache, number for TTL */
revalidate?: number | false;
/** Cache tags for targeted invalidation */
tags?: string[];
}
interface KomgaUrlBuilder {
path: string;
params?: Record<string, string | string[]>;
}
interface FetchErrorLike {
code?: string;
cause?: {
code?: string;
};
}
export abstract class BaseApiService {
protected static async getKomgaConfig(): Promise<AuthConfig> {
try {
const config: KomgaConfig | null = await ConfigDBService.getConfig();
if (!config) {
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
}
return {
serverUrl: config.url,
authHeader: config.authHeader,
};
} catch (error) {
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
throw error;
}
logger.error({ err: error }, "Erreur lors de la récupération de la configuration");
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG, {}, error);
}
}
protected static getAuthHeaders(config: AuthConfig): Headers {
if (!config.authHeader) {
throw new AppError(ERROR_CODES.KOMGA.MISSING_CREDENTIALS);
}
return new Headers({
Authorization: `Basic ${config.authHeader}`,
Accept: "application/json",
});
}
protected static buildUrl(
config: AuthConfig,
path: string,
params?: Record<string, string | string[]>
): string {
const url = new URL(`${config.serverUrl}/api/v1/${path}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach((v) => {
if (v !== undefined) {
url.searchParams.append(key, v);
}
});
} else {
url.searchParams.append(key, value);
}
}
});
}
return url.toString();
}
protected static async fetchFromApi<T>(
urlBuilder: KomgaUrlBuilder,
headersOptions = {},
options: KomgaRequestInit = {}
): Promise<T> {
const config: AuthConfig = await this.getKomgaConfig();
const { path, params } = urlBuilder;
const url = this.buildUrl(config, path, params);
const headers: Headers = this.getAuthHeaders(config);
if (headersOptions) {
for (const [key, value] of Object.entries(headersOptions)) {
headers.set(key as string, value as string);
}
}
const isDebug = process.env.KOMGA_DEBUG === "true";
const isCacheDebug = process.env.CACHE_DEBUG === "true";
const startTime = isDebug || isCacheDebug ? Date.now() : 0;
if (isDebug) {
logger.info(
{
url,
method: options.method || "GET",
params,
isImage: options.isImage,
noJson: options.noJson,
revalidate: options.revalidate,
},
"🔵 Komga Request"
);
}
if (isCacheDebug && options.revalidate) {
logger.info(
{
url,
cache: "enabled",
ttl: options.revalidate,
},
"💾 Cache enabled"
);
}
// Timeout de 15 secondes pour éviter les blocages longs
const timeoutMs = 15000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
let response: Response;
try {
response = await fetch(url, {
headers,
...options,
signal: controller.signal,
// @ts-expect-error - undici-specific options not in standard fetch types
connectTimeout: timeoutMs,
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
// Next.js cache with tags support
next: options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined,
});
} catch (fetchError: unknown) {
const normalizedError = fetchError as FetchErrorLike;
// Gestion spécifique des erreurs DNS
if (normalizedError.cause?.code === "EAI_AGAIN" || normalizedError.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-expect-error - undici-specific options
connectTimeout: timeoutMs,
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
// Force IPv4 si IPv6 pose problème
family: 4,
// Next.js cache with tags support
next: options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined,
});
} else if (normalizedError.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-expect-error - undici-specific options
connectTimeout: timeoutMs,
bodyTimeout: timeoutMs,
headersTimeout: timeoutMs,
// Next.js cache with tags support
next: options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined,
});
} else {
throw fetchError;
}
}
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
if (isDebug) {
logger.info(
{
url,
status: response.status,
duration: `${duration}ms`,
ok: response.ok,
},
"🟢 Komga Response"
);
}
// Log potential cache hit/miss based on response time
if (isCacheDebug && options.revalidate) {
// Fast response (< 50ms) is likely a cache hit
if (duration < 50) {
logger.info({ url, duration: `${duration}ms` }, "⚡ Cache HIT (fast response)");
} else {
logger.info({ url, duration: `${duration}ms` }, "🔄 Cache MISS (slow response)");
}
}
if (!response.ok) {
if (isDebug) {
logger.error(
{
url,
status: response.status,
statusText: response.statusText,
},
"🔴 Komga Error Response"
);
}
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
status: response.status,
statusText: response.statusText,
});
}
if (options.isImage) {
return response as T;
}
if (options.noJson) {
return undefined as T;
}
return response.json();
} catch (error) {
if (isDebug) {
const duration = Date.now() - startTime;
logger.error(
{
url,
error: error instanceof Error ? error.message : String(error),
duration: `${duration}ms`,
},
"🔴 Komga Request Failed"
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
}

View File

@@ -1,128 +1,12 @@
import { BaseApiService } from "./base-api.service";
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
type ErrorWithStatusParams = AppError & { params?: { status?: number } };
export class BookService extends BaseApiService {
private static readonly CACHE_TTL = 60; // 1 minute
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
try {
// 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}` },
{},
{ revalidate: this.CACHE_TTL }
),
this.fetchFromApi<{ number: number }[]>(
{ path: `books/${bookId}/pages` },
{},
{ revalidate: this.CACHE_TTL }
),
]);
return {
book,
pages: pages.map((page) => 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
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 ErrorWithStatusParams).params?.status === 404
) {
return null;
}
// Pour les autres erreurs, on les propage
throw error;
}
}
static async getBookSeriesId(bookId: string): Promise<string> {
try {
const book = await this.fetchFromApi<KomgaBook>(
{ path: `books/${bookId}` },
{},
{ revalidate: this.CACHE_TTL }
);
return book.seriesId;
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
}
}
static async updateReadProgress(
bookId: string,
page: number,
completed: boolean = false
): Promise<void> {
try {
const config = await this.getKomgaConfig();
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
const headers = this.getAuthHeaders(config);
headers.set("Content-Type", "application/json");
const response = await fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify({ page, completed }),
});
if (!response.ok) {
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR, {}, error);
}
}
static async deleteReadProgress(bookId: string): Promise<void> {
try {
const config = await this.getKomgaConfig();
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
const headers = this.getAuthHeaders(config);
headers.set("Content-Type", "application/json");
const response = await fetch(url, {
method: "DELETE",
headers,
});
if (!response.ok) {
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR, {}, error);
}
}
export class BookService {
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
try {
// Ajuster le numéro de page pour l'API Komga (zero-based)
const adjustedPageNumber = pageNumber - 1;
// Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
);
@@ -133,32 +17,18 @@ export class BookService extends BaseApiService {
static async getCover(bookId: string): Promise<Response> {
try {
// Récupérer les préférences de l'utilisateur
const preferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
if (preferences.showThumbnails) {
return ImageService.streamImage(`books/${bookId}/thumbnail`);
}
// Sinon, récupérer la première page (streaming)
return this.getPage(bookId, 1);
} catch (error) {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
}
}
static getPageUrl(bookId: string, pageNumber: number): string {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
}
static getPageThumbnailUrl(bookId: string, pageNumber: number): string {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
}
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
try {
// Stream directement sans buffer en mémoire
return ImageService.streamImage(
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
);
@@ -166,78 +36,4 @@ export class BookService extends BaseApiService {
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
}
}
static getCoverUrl(bookId: string): string {
return `/api/komga/images/books/${bookId}/thumbnail`;
}
static async getRandomBookFromLibraries(libraryIds: string[]): Promise<string> {
try {
if (libraryIds.length === 0) {
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, {
message: "Aucune bibliothèque sélectionnée",
});
}
// Use books/list directly with library filter to avoid extra series/list call
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
const randomLibraryId = libraryIds[randomLibraryIndex];
// Random page offset for variety (assuming most libraries have at least 100 books)
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
const searchBody = {
condition: {
libraryId: {
operator: "is",
value: randomLibraryId,
},
},
};
const booksResponse = await this.fetchFromApi<{
content: KomgaBook[];
totalElements: number;
}>(
{
path: "books/list",
params: { page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
},
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (booksResponse.content.length === 0) {
// Fallback to page 0 if random page was empty
const fallbackResponse = await this.fetchFromApi<{
content: KomgaBook[];
totalElements: number;
}>(
{
path: "books/list",
params: { page: "0", size: "20", sort: "metadata.numberSort,asc" },
},
{ "Content-Type": "application/json" },
{ method: "POST", body: JSON.stringify(searchBody) }
);
if (fallbackResponse.content.length === 0) {
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
});
}
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
return fallbackResponse.content[randomBookIndex].id;
}
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
return booksResponse.content[randomBookIndex].id;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
}

View File

@@ -1,7 +1,7 @@
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
export class ClientOfflineBookService {
static setCurrentPage(book: KomgaBook, page: number) {
static setCurrentPage(book: NormalizedBook, page: number) {
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
try {
localStorage.setItem(`${book.id}-page`, page.toString());
@@ -11,7 +11,7 @@ export class ClientOfflineBookService {
}
}
static getCurrentPage(book: KomgaBook) {
static getCurrentPage(book: NormalizedBook) {
const readProgressPage = book.readProgress?.page || 0;
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
try {
@@ -31,7 +31,7 @@ export class ClientOfflineBookService {
}
}
static removeCurrentPage(book: KomgaBook) {
static removeCurrentPage(book: NormalizedBook) {
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
try {
localStorage.removeItem(`${book.id}-page`);

View File

@@ -9,7 +9,6 @@ export class FavoriteService {
private static readonly FAVORITES_CHANGE_EVENT = "favoritesChanged";
private static dispatchFavoritesChanged() {
// Dispatch l'événement pour notifier les changements
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(FavoriteService.FAVORITES_CHANGE_EVENT));
}
@@ -23,19 +22,26 @@ export class FavoriteService {
return user;
}
private static async getCurrentUserWithProvider(): Promise<{ userId: number; provider: string }> {
const user = await FavoriteService.getCurrentUser();
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { activeProvider: true },
});
const provider = dbUser?.activeProvider ?? "komga";
return { userId, provider };
}
/**
* Vérifie si une série est dans les favoris
* Vérifie si une série est dans les favoris (pour le provider actif)
*/
static async isFavorite(seriesId: string): Promise<boolean> {
try {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const { userId, provider } = await this.getCurrentUserWithProvider();
const favorite = await prisma.favorite.findFirst({
where: {
userId,
seriesId: seriesId,
},
where: { userId, seriesId, provider },
});
return !!favorite;
} catch (error) {
@@ -45,25 +51,18 @@ export class FavoriteService {
}
/**
* Ajoute une série aux favoris
* Ajoute une série aux favoris (pour le provider actif)
*/
static async addToFavorites(seriesId: string): Promise<void> {
try {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const { userId, provider } = await this.getCurrentUserWithProvider();
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId,
seriesId,
},
userId_provider_seriesId: { userId, provider, seriesId },
},
update: {},
create: {
userId,
seriesId,
},
create: { userId, provider, seriesId },
});
this.dispatchFavoritesChanged();
@@ -73,18 +72,14 @@ export class FavoriteService {
}
/**
* Retire une série des favoris
* Retire une série des favoris (pour le provider actif)
*/
static async removeFromFavorites(seriesId: string): Promise<void> {
try {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const { userId, provider } = await this.getCurrentUserWithProvider();
await prisma.favorite.deleteMany({
where: {
userId,
seriesId,
},
where: { userId, seriesId, provider },
});
this.dispatchFavoritesChanged();
@@ -94,49 +89,16 @@ export class FavoriteService {
}
/**
* Récupère tous les IDs des séries favorites
* Récupère tous les IDs des séries favorites (pour le provider actif)
*/
static async getAllFavoriteIds(): Promise<string[]> {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const { userId, provider } = await this.getCurrentUserWithProvider();
const favorites = await prisma.favorite.findMany({
where: { userId },
where: { userId, provider },
select: { seriesId: true },
});
return favorites.map((favorite) => favorite.seriesId);
}
static async addFavorite(seriesId: string) {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const favorite = await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId,
seriesId,
},
},
update: {},
create: {
userId,
seriesId,
},
});
return favorite;
}
static async removeFavorite(seriesId: string): Promise<boolean> {
const user = await this.getCurrentUser();
const userId = parseInt(user.id, 10);
const result = await prisma.favorite.deleteMany({
where: {
userId,
seriesId,
},
});
return result.count > 0;
}
}

View File

@@ -1,24 +1,26 @@
import { FavoriteService } from "./favorite.service";
import { SeriesService } from "./series.service";
import type { KomgaSeries } from "@/types/komga";
import { getProvider } from "@/lib/providers/provider.factory";
import type { NormalizedSeries } from "@/lib/providers/types";
import logger from "@/lib/logger";
export class FavoritesService {
static async getFavorites(context?: {
requestPath?: string;
requestPathname?: string;
}): Promise<KomgaSeries[]> {
}): Promise<NormalizedSeries[]> {
try {
const favoriteIds = await FavoriteService.getAllFavoriteIds();
const [favoriteIds, provider] = await Promise.all([
FavoriteService.getAllFavoriteIds(),
getProvider(),
]);
if (favoriteIds.length === 0) {
if (favoriteIds.length === 0 || !provider) {
return [];
}
// Fetch toutes les séries en parallèle
const promises = favoriteIds.map(async (id: string) => {
try {
return await SeriesService.getSeries(id);
return await provider.getSeriesById(id);
} catch (error) {
logger.error(
{
@@ -40,7 +42,7 @@ export class FavoritesService {
});
const results = await Promise.all(promises);
return results.filter((series): series is KomgaSeries => series !== null);
return results.filter((series): series is NormalizedSeries => series !== null);
} catch (error) {
logger.error(
{

View File

@@ -1,100 +0,0 @@
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 { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
export type { HomeData };
// Cache tag pour invalidation ciblée
const HOME_CACHE_TAG = "home-data";
export class HomeService extends BaseApiService {
private static readonly CACHE_TTL = 120; // 2 minutes fallback
private static readonly CACHE_TAG = HOME_CACHE_TAG;
static async getHomeData(): Promise<HomeData> {
try {
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
{
path: "series",
params: {
read_status: "IN_PROGRESS",
sort: "readDate,desc",
page: "0",
size: "10",
media_status: "READY",
},
},
{},
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
),
this.fetchFromApi<LibraryResponse<KomgaBook>>(
{
path: "books",
params: {
read_status: "IN_PROGRESS",
sort: "readProgress.readDate,desc",
page: "0",
size: "10",
media_status: "READY",
},
},
{},
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
),
this.fetchFromApi<LibraryResponse<KomgaBook>>(
{
path: "books/latest",
params: {
page: "0",
size: "10",
media_status: "READY",
},
},
{},
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
),
this.fetchFromApi<LibraryResponse<KomgaBook>>(
{
path: "books/ondeck",
params: {
page: "0",
size: "10",
media_status: "READY",
},
},
{},
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
),
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
{
path: "series/latest",
params: {
page: "0",
size: "10",
media_status: "READY",
},
},
{},
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
),
]);
return {
ongoing: ongoing.content || [],
ongoingBooks: ongoingBooks.content || [],
recentlyRead: recentlyRead.content || [],
onDeck: onDeck.content || [],
latestSeries: latestSeries.content || [],
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
}
}
}

View File

@@ -1,26 +1,28 @@
import { BaseApiService } from "./base-api.service";
import { ConfigDBService } from "./config-db.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 thumbnails ne changent pas)
const IMAGE_CACHE_MAX_AGE = 2592000;
export class ImageService extends BaseApiService {
/**
* Stream an image directly from Komga without buffering in memory
* Returns a Response that can be directly returned to the client
*/
export class ImageService {
static async streamImage(
path: string,
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
): Promise<Response> {
try {
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
const config = await ConfigDBService.getConfig();
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
const url = new URL(`${config.url}/api/v1/${path}`).toString();
const headers = new Headers({
Authorization: `Basic ${config.authHeader}`,
Accept: "image/jpeg, image/png, image/gif, image/webp, */*",
});
const response = await fetch(url, { headers });
if (!response.ok) throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
// Stream the response body directly without buffering
return new Response(response.body, {
status: response.status,
headers: {
@@ -31,23 +33,8 @@ export class ImageService extends BaseApiService {
});
} catch (error) {
logger.error({ err: error }, "Erreur lors du streaming de l'image");
if (error instanceof AppError) throw error;
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
}
}
static getSeriesThumbnailUrl(seriesId: string): string {
return `/api/komga/images/series/${seriesId}/thumbnail`;
}
static getBookThumbnailUrl(bookId: string): string {
return `/api/komga/images/books/${bookId}/thumbnail`;
}
static getBookPageUrl(bookId: string, pageNumber: number): string {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
}
static getBookPageThumbnailUrl(bookId: string, pageNumber: number): string {
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
}
}

View File

@@ -1,171 +0,0 @@
import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { KomgaLibrary } from "@/types/komga";
// Raw library type from Komga API (without booksCount)
interface KomgaLibraryRaw {
id: string;
name: string;
root: string;
unavailable: boolean;
}
type KomgaCondition = Record<string, unknown>;
const sortSeriesDeterministically = <T extends { id: string; metadata?: { titleSort?: string } }>(
items: T[]
): T[] => {
return [...items].sort((a, b) => {
const titleA = a.metadata?.titleSort ?? "";
const titleB = b.metadata?.titleSort ?? "";
const titleComparison = titleA.localeCompare(titleB);
if (titleComparison !== 0) {
return titleComparison;
}
return a.id.localeCompare(b.id);
});
};
export const LIBRARY_SERIES_CACHE_TAG = "library-series";
export class LibraryService extends BaseApiService {
private static readonly CACHE_TTL = 300; // 5 minutes
static async getLibraries(): Promise<KomgaLibrary[]> {
try {
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>(
{ path: "libraries" },
{},
{ revalidate: this.CACHE_TTL }
);
// Enrich each library with book counts (parallel requests)
const enrichedLibraries = await Promise.all(
libraries.map(async (library) => {
try {
const booksResponse = await this.fetchFromApi<{ totalElements: number }>(
{
path: "books",
params: { library_id: library.id, size: "0" },
},
{},
{ revalidate: this.CACHE_TTL }
);
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: booksResponse.totalElements,
booksReadCount: 0,
} as KomgaLibrary;
} catch {
return {
...library,
importLastModified: "",
lastModified: "",
booksCount: 0,
booksReadCount: 0,
} as KomgaLibrary;
}
})
);
return enrichedLibraries;
} catch (error) {
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
}
}
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
try {
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
}
}
static async getLibrarySeries(
libraryId: string,
page: number = 0,
size: number = 20,
unreadOnly: boolean = false,
search?: string
): Promise<LibraryResponse<Series>> {
try {
const headers = { "Content-Type": "application/json" };
// Construction du body de recherche pour Komga
let condition: KomgaCondition;
if (unreadOnly) {
condition = {
allOf: [
{ libraryId: { operator: "is", value: libraryId } },
{
anyOf: [
{ readStatus: { operator: "is", value: "UNREAD" } },
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
],
},
],
};
} else {
condition = { libraryId: { operator: "is", value: libraryId } };
}
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
const params: Record<string, string | string[]> = {
page: String(page),
size: String(size),
sort: "metadata.titleSort,asc",
};
if (search) {
searchBody.fullTextSearch = search;
}
const response = await this.fetchFromApi<LibraryResponse<Series>>(
{ path: "series/list", params },
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
revalidate: this.CACHE_TTL,
tags: [LIBRARY_SERIES_CACHE_TAG],
}
);
const filteredContent = response.content.filter((series) => !series.deleted);
const sortedContent = sortSeriesDeterministically(filteredContent);
return {
...response,
content: sortedContent,
numberOfElements: sortedContent.length,
};
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
try {
await this.fetchFromApi(
{ path: `libraries/${libraryId}/scan`, params: { deep: String(deep) } },
{},
{ method: "POST", noJson: true, revalidate: 0 } // bypass cache on mutations
);
} catch (error) {
throw new AppError(ERROR_CODES.LIBRARY.SCAN_ERROR, { libraryId }, error);
}
}
}

View File

@@ -1,63 +0,0 @@
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import { BaseApiService } from "./base-api.service";
interface SearchResponse<T> {
content: T[];
}
export interface GlobalSearchResult {
series: KomgaSeries[];
books: KomgaBook[];
}
export class SearchService extends BaseApiService {
private static readonly CACHE_TTL = 30;
static async globalSearch(query: string, limit: number = 6): Promise<GlobalSearchResult> {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return { series: [], books: [] };
}
const headers = { "Content-Type": "application/json" };
const searchBody = {
fullTextSearch: trimmedQuery,
};
try {
const [seriesResponse, booksResponse] = await Promise.all([
this.fetchFromApi<SearchResponse<KomgaSeries>>(
{ path: "series/list", params: { page: "0", size: String(limit) } },
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
revalidate: this.CACHE_TTL,
}
),
this.fetchFromApi<SearchResponse<KomgaBook>>(
{ path: "books/list", params: { page: "0", size: String(limit) } },
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
revalidate: this.CACHE_TTL,
}
),
]);
return {
series: seriesResponse.content.filter((item) => !item.deleted),
books: booksResponse.content.filter((item) => !item.deleted),
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
}

View File

@@ -1,158 +1,45 @@
import { BaseApiService } from "./base-api.service";
import type { LibraryResponse } from "@/types/library";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { KomgaBook } from "@/types/komga";
import { BookService } from "./book.service";
import { ImageService } from "./image.service";
import { PreferencesService } from "./preferences.service";
import { ConfigDBService } from "./config-db.service";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences";
import logger from "@/lib/logger";
type KomgaCondition = Record<string, unknown>;
export class SeriesService {
private static async getFirstBook(seriesId: string): Promise<string> {
const config = await ConfigDBService.getConfig();
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
export class SeriesService extends BaseApiService {
private static readonly CACHE_TTL = 120; // 2 minutes
const url = new URL(`${config.url}/api/v1/series/${seriesId}/books`);
url.searchParams.set("page", "0");
url.searchParams.set("size", "1");
static async getSeries(seriesId: string): Promise<KomgaSeries> {
try {
return this.fetchFromApi<KomgaSeries>(
{ path: `series/${seriesId}` },
{},
{ revalidate: this.CACHE_TTL }
);
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
const headers = new Headers({
Authorization: `Basic ${config.authHeader}`,
Accept: "application/json",
});
static async getSeriesBooks(
seriesId: string,
page: number = 0,
size: number = 24,
unreadOnly: boolean = false
): Promise<LibraryResponse<KomgaBook>> {
try {
const headers = { "Content-Type": "application/json" };
const response = await fetch(url.toString(), { headers });
if (!response.ok) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
// Construction du body de recherche pour Komga
let condition: KomgaCondition;
const data: { content: KomgaBook[] } = await response.json();
if (!data.content?.length) throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
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: {
operator: "is",
value: seriesId,
},
};
}
const searchBody = { condition };
const params: Record<string, string | string[]> = {
page: String(page),
size: String(size),
sort: "metadata.numberSort,asc",
};
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
{ path: "books/list", params },
headers,
{
method: "POST",
body: JSON.stringify(searchBody),
revalidate: this.CACHE_TTL,
}
);
// Filtrer uniquement les livres supprimés côté client (léger)
const filteredContent = response.content.filter((book: KomgaBook) => !book.deleted);
return {
...response,
content: filteredContent,
numberOfElements: filteredContent.length,
};
} catch (error) {
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}
static async getFirstBook(seriesId: string): Promise<string> {
try {
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;
} 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);
}
return data.content[0].id;
}
static async getCover(seriesId: string): Promise<Response> {
try {
// Récupérer les préférences de l'utilisateur
const preferences: UserPreferences = await PreferencesService.getPreferences();
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
const preferences = await PreferencesService.getPreferences();
if (preferences.showThumbnails) {
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
}
// Sinon, récupérer la première page (streaming)
const firstBookId = await this.getFirstBook(seriesId);
const firstBookId = await SeriesService.getFirstBook(seriesId);
return BookService.getPage(firstBookId, 1);
} 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<KomgaSeries[]> {
try {
const seriesPromises: Promise<KomgaSeries>[] = seriesIds.map((id: string) =>
this.getSeries(id)
);
const series: KomgaSeries[] = await Promise.all(seriesPromises);
return series.filter(Boolean);
} catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série");
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
}
}

View File

@@ -1,34 +0,0 @@
import { BaseApiService } from "./base-api.service";
import type { AuthConfig } from "@/types/auth";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { KomgaLibrary } from "@/types/komga";
import logger from "@/lib/logger";
export class TestService extends BaseApiService {
static async testConnection(config: AuthConfig): Promise<{ libraries: KomgaLibrary[] }> {
try {
const url = this.buildUrl(config, "libraries");
const headers = this.getAuthHeaders(config);
const response = await fetch(url, { headers });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR, { message: errorData.message });
}
const libraries = await response.json();
return { libraries };
} catch (error) {
logger.error({ err: error }, "Erreur lors du test de connexion");
if (error instanceof AppError) {
throw error;
}
if (error instanceof Error && error.message.includes("fetch")) {
throw new AppError(ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
}
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR, {}, error);
}
}
}

View File

@@ -1,9 +1,9 @@
import type { KomgaBook, KomgaSeries } from "./komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface HomeData {
ongoing: KomgaSeries[];
ongoingBooks: KomgaBook[];
recentlyRead: KomgaBook[];
onDeck: KomgaBook[];
latestSeries: KomgaSeries[];
ongoing: NormalizedSeries[];
ongoingBooks: NormalizedBook[];
recentlyRead: NormalizedBook[];
onDeck: NormalizedBook[];
latestSeries: NormalizedSeries[];
}

105
src/types/stripstream.ts Normal file
View File

@@ -0,0 +1,105 @@
// Types natifs de l'API Stripstream Librarian
export interface StripstreamBookItem {
id: string;
library_id: string;
kind: string;
title: string;
updated_at: string;
reading_status: "unread" | "reading" | "read";
author?: string | null;
language?: string | null;
page_count?: number | null;
reading_current_page?: number | null;
reading_last_read_at?: string | null;
series?: string | null;
thumbnail_url?: string | null;
volume?: number | null;
}
export interface StripstreamBookDetails {
id: string;
library_id: string;
kind: string;
title: string;
reading_status: "unread" | "reading" | "read";
author?: string | null;
file_format?: string | null;
file_parse_status?: string | null;
file_path?: string | null;
language?: string | null;
page_count?: number | null;
reading_current_page?: number | null;
reading_last_read_at?: string | null;
series?: string | null;
thumbnail_url?: string | null;
volume?: number | null;
}
export interface StripstreamBooksPage {
items: StripstreamBookItem[];
total: number;
page: number;
limit: number;
}
export interface StripstreamSeriesItem {
name: string;
book_count: number;
books_read_count: number;
first_book_id: string;
}
export interface StripstreamSeriesPage {
items: StripstreamSeriesItem[];
total: number;
page: number;
limit: number;
}
export interface StripstreamLibraryResponse {
id: string;
name: string;
root_path: string;
enabled: boolean;
book_count: number;
monitor_enabled: boolean;
scan_mode: string;
watcher_enabled: boolean;
next_scan_at?: string | null;
}
export interface StripstreamReadingProgressResponse {
status: "unread" | "reading" | "read";
current_page?: number | null;
last_read_at?: string | null;
}
export interface StripstreamUpdateReadingProgressRequest {
status: "unread" | "reading" | "read";
current_page?: number | null;
}
export interface StripstreamSearchResponse {
hits: StripstreamSearchHit[];
series_hits: StripstreamSeriesHit[];
estimated_total_hits?: number | null;
processing_time_ms?: number | null;
}
export interface StripstreamSearchHit {
id: string;
title: string;
series?: string | null;
library_id: string;
thumbnail_url?: string | null;
volume?: number | null;
}
export interface StripstreamSeriesHit {
name: string;
book_count: number;
books_read_count: number;
first_book_id: string;
library_id: string;
}