diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9529a2..7d05ba2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/actions/books.ts b/src/app/actions/books.ts index ed6303c..202a4ad 100644 --- a/src/app/actions/books.ts +++ b/src/app/actions/books.ts @@ -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 { 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) { diff --git a/src/app/actions/config.ts b/src/app/actions/config.ts index df3b471..9f39aa9 100644 --- a/src/app/actions/config.ts +++ b/src/app/actions/config.ts @@ -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 }; diff --git a/src/app/actions/library.ts b/src/app/actions/library.ts index fdbc8af..3c4aaeb 100644 --- a/src/app/actions/library.ts +++ b/src/app/actions/library.ts @@ -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" }; } } diff --git a/src/app/actions/read-progress.ts b/src/app/actions/read-progress.ts index 35a1508..27c462f 100644 --- a/src/app/actions/read-progress.ts +++ b/src/app/actions/read-progress.ts @@ -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) { diff --git a/src/app/actions/refresh.ts b/src/app/actions/refresh.ts index be5b224..20359dc 100644 --- a/src/app/actions/refresh.ts +++ b/src/app/actions/refresh.ts @@ -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"; diff --git a/src/app/actions/stripstream-config.ts b/src/app/actions/stripstream-config.ts new file mode 100644 index 0000000..80600a8 --- /dev/null +++ b/src/app/actions/stripstream-config.ts @@ -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 { + 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" }; + } +} diff --git a/src/app/api/komga/search/route.ts b/src/app/api/komga/search/route.ts deleted file mode 100644 index 2271ec6..0000000 --- a/src/app/api/komga/search/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/src/app/api/provider/search/route.ts b/src/app/api/provider/search/route.ts new file mode 100644 index 0000000..69f9dcf --- /dev/null +++ b/src/app/api/provider/search/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts new file mode 100644 index 0000000..422d7cd --- /dev/null +++ b/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts b/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts new file mode 100644 index 0000000..4b557a2 --- /dev/null +++ b/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts @@ -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 }); + } +} diff --git a/src/app/api/stripstream/search/route.ts b/src/app/api/stripstream/search/route.ts new file mode 100644 index 0000000..d7285ad --- /dev/null +++ b/src/app/api/stripstream/search/route.ts @@ -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 } + ); + } +} diff --git a/src/app/books/[bookId]/page.tsx b/src/app/books/[bookId]/page.tsx index 7a806db..7d6d9a5 100644 --- a/src/app/books/[bookId]/page.tsx +++ b/src/app/books/[bookId]/page.tsx @@ -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 ( }> - + ); } 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"); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 096f19c..d83de86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 }) ), diff --git a/src/app/libraries/[libraryId]/LibraryContent.tsx b/src/app/libraries/[libraryId]/LibraryContent.tsx index 6598eb0..f5fc1fd 100644 --- a/src/app/libraries/[libraryId]/LibraryContent.tsx +++ b/src/app/libraries/[libraryId]/LibraryContent.tsx @@ -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; + library: NormalizedLibrary; + series: NormalizedSeriesPage; currentPage: number; preferences: UserPreferences; unreadOnly: boolean; @@ -28,15 +26,15 @@ export function LibraryContent({ <> ; @@ -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 ( ); } 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 ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 930446e..0dd7e83 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( @@ -16,13 +19,14 @@ export default async function HomePage() { ); } 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 (
diff --git a/src/app/series/[seriesId]/SeriesContent.tsx b/src/app/series/[seriesId]/SeriesContent.tsx index d109949..5bbc3cd 100644 --- a/src/app/series/[seriesId]/SeriesContent.tsx +++ b/src/app/series/[seriesId]/SeriesContent.tsx @@ -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; + series: NormalizedSeries; + books: NormalizedBooksPage; currentPage: number; preferences: UserPreferences; unreadOnly: boolean; @@ -37,10 +36,10 @@ export function SeriesContent({ /> diff --git a/src/app/series/[seriesId]/page.tsx b/src/app/series/[seriesId]/page.tsx index fd99bb2..0e7f39a 100644 --- a/src/app/series/[seriesId]/page.tsx +++ b/src/app/series/[seriesId]/page.tsx @@ -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 ( ); } 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 (
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index fbbbce4..6a10949 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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 ; + return ( + + ); } diff --git a/src/components/downloads/DownloadManager.tsx b/src/components/downloads/DownloadManager.tsx index 749ab9f..4c3a950 100644 --- a/src/components/downloads/DownloadManager.tsx +++ b/src/components/downloads/DownloadManager.tsx @@ -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
{t("books.coverAlt",

- {book.metadata?.title || t("books.title", { number: book.metadata?.number })} + {book.title || t("books.title", { number: book.number ?? "" })}

- {formatSize(book.sizeBytes)} - {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 })}
diff --git a/src/components/home/HeroSection.tsx b/src/components/home/HeroSection.tsx index 89f3f78..f6e8e07 100644 --- a/src/components/home/HeroSection.tsx +++ b/src/components/home/HeroSection.tsx @@ -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 (
{/* Grille de couvertures en arrière-plan */}
- {series?.map((series) => ( + {series?.map((s) => (
diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index e2ce7b2..0d6af62 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -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 (
{data.ongoingBooks && data.ongoingBooks.length > 0 && ( @@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) { {data.ongoing && data.ongoing.length > 0 && ( )} @@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) { {data.onDeck && data.onDeck.length > 0 && ( )} @@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) { {data.latestSeries && data.latestSeries.length > 0 && ( )} @@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) { {data.recentlyRead && data.recentlyRead.length > 0 && ( )} diff --git a/src/components/home/MediaRow.tsx b/src/components/home/MediaRow.tsx index 4f13863..21a2cb1 100644 --- a/src/components/home/MediaRow.tsx +++ b/src/components/home/MediaRow.tsx @@ -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" )} >
- {isSeries ? ( + {isSeriesItem ? ( <> - -
-

{title}

-

- {t("series.books", { count: item.booksCount })} -

+ +
+

{title}

+

+ {t("series.books", { count: item.bookCount })} +

) : ( <> ({ series: [], books: [] }); + const [results, setResults] = useState([]); - 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 && (
- {results.series.length > 0 && ( + {seriesResults.length > 0 && (
{t("header.search.series")}
- {results.series.map((item) => ( + {seriesResults.map((item) => ( {item.title} { e.currentTarget.style.display = "none"; }} />

{item.title}

- {t("series.books", { count: item.booksCount })} + {item.bookCount !== undefined && t("series.books", { count: item.bookCount })}

@@ -189,12 +165,12 @@ export function GlobalSearch() {
)} - {results.books.length > 0 && ( + {bookResults.length > 0 && (
{t("header.search.books")}
- {results.books.map((item) => ( + {bookResults.map((item) => ( {item.title} { e.currentTarget.style.display = "none"; }} />

{item.title}

diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 46d3d5f..4abb58e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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(initialLibraries || []); - const [favorites, setFavorites] = useState(initialFavorites || []); + const [libraries, setLibraries] = useState(initialLibraries || []); + const [favorites, setFavorites] = useState(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({ handleLinkClick(`/series/${series.id}`)} className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400" diff --git a/src/components/library/LibraryHeader.tsx b/src/components/library/LibraryHeader.tsx index 6b71825..0042558 100644 --- a/src/components/library/LibraryHeader.tsx +++ b/src/components/library/LibraryHeader.tsx @@ -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({
- - {library.unavailable &&

Bibliotheque indisponible

}
diff --git a/src/components/library/PaginatedSeriesGrid.tsx b/src/components/library/PaginatedSeriesGrid.tsx index 53f5285..5c90a59 100644 --- a/src/components/library/PaginatedSeriesGrid.tsx +++ b/src/components/library/PaginatedSeriesGrid.tsx @@ -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: { diff --git a/src/components/library/SeriesGrid.tsx b/src/components/library/SeriesGrid.tsx index b7497a1..d683b47 100644 --- a/src/components/library/SeriesGrid.tsx +++ b/src/components/library/SeriesGrid.tsx @@ -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) => (
@@ -137,7 +137,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
@@ -148,7 +148,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {

- {series.metadata.title} + {series.name}

@@ -164,9 +164,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{/* Résumé */} - {series.metadata.summary && ( + {series.summary && (

- {series.metadata.summary} + {series.summary}

)} @@ -176,55 +176,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
- {series.booksCount === 1 + {series.bookCount === 1 ? t("series.book", { count: 1 }) - : t("series.books", { count: series.booksCount })} + : t("series.books", { count: series.bookCount })}
{/* Auteurs */} - {series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && ( + {series.authors && series.authors.length > 0 && (
- {series.booksMetadata.authors.map((a) => a.name).join(", ")} + {series.authors.map((a) => a.name).join(", ")}
)} {/* Date de création */} - {series.created && ( + {series.createdAt && (
- {formatDate(series.created)} + {formatDate(series.createdAt)}
)} {/* Genres */} - {series.metadata.genres && series.metadata.genres.length > 0 && ( + {series.genres && series.genres.length > 0 && (
- {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}`}
)} {/* Tags */} - {series.metadata.tags && series.metadata.tags.length > 0 && ( + {series.tags && series.tags.length > 0 && (
- {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}`}
)}
{/* Barre de progression */} - {series.booksCount > 0 && !isCompleted && series.booksReadCount > 0 && ( + {series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (

diff --git a/src/components/reader/ClientBookPage.tsx b/src/components/reader/ClientBookPage.tsx index 454e2ee..9b585c5 100644 --- a/src/components/reader/ClientBookPage.tsx +++ b/src/components/reader/ClientBookPage.tsx @@ -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(null); const [data, setData] = useState<{ - book: KomgaBook; + book: NormalizedBook; pages: number[]; - nextBook: KomgaBook | null; + nextBook: NormalizedBook | null; } | null>(null); // Use SSR data if available diff --git a/src/components/reader/ClientBookReader.tsx b/src/components/reader/ClientBookReader.tsx index a826cba..2ff0a85 100644 --- a/src/components/reader/ClientBookReader.tsx +++ b/src/components/reader/ClientBookReader.tsx @@ -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[]; } diff --git a/src/components/reader/ClientBookWrapper.tsx b/src/components/reader/ClientBookWrapper.tsx index 5fe683d..2c3791a 100644 --- a/src/components/reader/ClientBookWrapper.tsx +++ b/src/components/reader/ClientBookWrapper.tsx @@ -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(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) { diff --git a/src/components/reader/PhotoswipeReader.tsx b/src/components/reader/PhotoswipeReader.tsx index 88302cf..2d04142 100644 --- a/src/components/reader/PhotoswipeReader.tsx +++ b/src/components/reader/PhotoswipeReader.tsx @@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP const lastClickTimeRef = useRef(0); const clickTimeoutRef = useRef(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({ diff --git a/src/components/reader/hooks/useImageLoader.ts b/src/components/reader/hooks/useImageLoader.ts index e020286..4649f71 100644 --- a/src/components/reader/hooks/useImageLoader.ts +++ b/src/components/reader/hooks/useImageLoader.ts @@ -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, }); diff --git a/src/components/reader/hooks/usePageNavigation.ts b/src/components/reader/hooks/usePageNavigation.ts index f28a67c..35161ba 100644 --- a/src/components/reader/hooks/usePageNavigation.ts +++ b/src/components/reader/hooks/usePageNavigation.ts @@ -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({ diff --git a/src/components/reader/hooks/useThumbnails.ts b/src/components/reader/hooks/useThumbnails.ts index cc0a312..ddf49d2 100644 --- a/src/components/reader/hooks/useThumbnails.ts +++ b/src/components/reader/hooks/useThumbnails.ts @@ -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 diff --git a/src/components/reader/types.ts b/src/components/reader/types.ts index 58a34dd..6c03644 100644 --- a/src/components/reader/types.ts +++ b/src/components/reader/types.ts @@ -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; diff --git a/src/components/series/BookGrid.tsx b/src/components/series/BookGrid.tsx index be4e7c6..210ea15 100644 --- a/src/components/series/BookGrid.tsx +++ b/src/components/series/BookGrid.tsx @@ -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 diff --git a/src/components/series/BookList.tsx b/src/components/series/BookList.tsx index c3a1fbb..da47bb9 100644 --- a/src/components/series/BookList.tsx +++ b/src/components/series/BookList.tsx @@ -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 */}

- {book.metadata.number && ( - {t("navigation.volume", { number: book.metadata.number })} + {book.number && ( + {t("navigation.volume", { number: book.number })} )}
@@ -139,12 +139,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL {totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
- {book.metadata.authors && book.metadata.authors.length > 0 && ( -
- - {book.metadata.authors[0].name} -
- )}
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL > {title} - {book.metadata.number && ( + {book.number && (

- {t("navigation.volume", { number: book.metadata.number })} + {t("navigation.volume", { number: book.number })}

)}
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
- {/* Résumé */} - {book.metadata.summary && ( -

- {book.metadata.summary} -

- )} - {/* Métadonnées */}
{/* Pages */} @@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL {totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
- - {/* Auteurs */} - {book.metadata.authors && book.metadata.authors.length > 0 && ( -
- - - {book.metadata.authors.map((a) => a.name).join(", ")} - -
- )} - - {/* Date de sortie */} - {book.metadata.releaseDate && ( -
- - {formatDate(book.metadata.releaseDate)} -
- )} - - {/* Tags */} - {book.metadata.tags && book.metadata.tags.length > 0 && ( -
- - - {book.metadata.tags.slice(0, 3).join(", ")} - {book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`} - -
- )}
{/* Barre de progression */} @@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL {!isRead && ( 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 diff --git a/src/components/series/PaginatedBookGrid.tsx b/src/components/series/PaginatedBookGrid.tsx index c10dd7a..c6c2a82 100644 --- a/src/components/series/PaginatedBookGrid.tsx +++ b/src/components/series/PaginatedBookGrid.tsx @@ -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}`); }; diff --git a/src/components/series/SeriesHeader.tsx b/src/components/series/SeriesHeader.tsx index 50439a4..7fe481a 100644 --- a/src/components/series/SeriesHeader.tsx +++ b/src/components/series/SeriesHeader.tsx @@ -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 */}
@@ -118,18 +119,18 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie {/* Image principale */}
{/* Informations */}
-

{series.metadata.title}

- {series.metadata.summary && ( +

{series.name}

+ {series.summary && (

- {series.metadata.summary} + {series.summary}

)}
@@ -137,9 +138,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie {statusInfo.label} - {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 })} (initialLibraries || []); + const [libraries, setLibraries] = useState(initialLibraries || []); const [selectedLibraries, setSelectedLibraries] = useState( 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)
))} diff --git a/src/components/settings/ClientSettings.tsx b/src/components/settings/ClientSettings.tsx index 4b1de07..0942af3 100644 --- a/src/components/settings/ClientSettings.tsx +++ b/src/components/settings/ClientSettings.tsx @@ -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 + {providersStatus && ( + + )} + diff --git a/src/components/settings/ProviderSelector.tsx b/src/components/settings/ProviderSelector.tsx new file mode 100644 index 0000000..a449dca --- /dev/null +++ b/src/components/settings/ProviderSelector.tsx @@ -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(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 ( + + + Provider actif + + Choisissez le serveur que l'application doit utiliser. Les deux configurations peuvent coexister. + + + +
+ {providers.map((provider) => { + const active = current === provider.id; + const configured = isConfigured(provider.id); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/settings/StripstreamSettings.tsx b/src/components/settings/StripstreamSettings.tsx new file mode 100644 index 0000000..c7e2ed3 --- /dev/null +++ b/src/components/settings/StripstreamSettings.tsx @@ -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) => { + 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 ( + + + + + Stripstream Librarian + + + Connectez votre instance Stripstream Librarian via token API (format{" "} + stl_...). + + + + {!shouldShowForm ? ( +
+
+
+ +

{url}

+
+
+ +

{hasToken ? "••••••••" : "Non configuré"}

+
+
+ +
+ ) : ( +
+
+
+ + setUrl(e.target.value)} + placeholder="https://librarian.example.com" + className={inputClass} + /> +
+
+ + setToken(e.target.value)} + placeholder={isConfigured ? "••••••••" : "stl_xxxx_xxxxxxxx"} + className={inputClass} + /> +
+
+
+ + + {isConfigured && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/src/components/ui/book-cover.tsx b/src/components/ui/book-cover.tsx index ee50b6d..64b0f87 100644 --- a/src/components/ui/book-cover.tsx +++ b/src/components/ui/book-cover.tsx @@ -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({ <>
{alt handleMarkAsRead()} className="bg-white/90 hover:bg-white text-black shadow-sm" @@ -143,9 +140,9 @@ export function BookCover({ {showOverlay && overlayVariant === "default" && (

- {book.metadata.title || - (book.metadata.number - ? t("navigation.volume", { number: book.metadata.number }) + {book.title || + (book.number + ? t("navigation.volume", { number: book.number }) : "")}

@@ -160,15 +157,15 @@ export function BookCover({ {showOverlay && overlayVariant === "home" && (

- {book.metadata.title || - (book.metadata.number - ? t("navigation.volume", { number: book.metadata.number }) + {book.title || + (book.number + ? t("navigation.volume", { number: book.number }) : "")}

{t("books.status.progress", { current: currentPage, - total: book.media.pagesCount, + total: book.pageCount, })}

diff --git a/src/components/ui/book-offline-button.tsx b/src/components/ui/book-offline-button.tsx index 2d27bd3..2fb7757 100644 --- a/src/components/ui/book-offline-button.tsx +++ b/src/components/ui/book-offline-button.tsx @@ -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); diff --git a/src/components/ui/cover-utils.tsx b/src/components/ui/cover-utils.tsx index 1b06c8b..0f7f44a 100644 --- a/src/components/ui/cover-utils.tsx +++ b/src/components/ui/cover-utils.tsx @@ -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; } diff --git a/src/components/ui/series-cover.tsx b/src/components/ui/series-cover.tsx index d797707..fe8cb97 100644 --- a/src/components/ui/series-cover.tsx +++ b/src/components/ui/series-cover.tsx @@ -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 (
{alt} = { [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", diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 41c6239..5e0b7a4 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -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", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 604639b..1ee1e0c 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -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", diff --git a/src/lib/providers/komga/komga.adapter.ts b/src/lib/providers/komga/komga.adapter.ts new file mode 100644 index 0000000..71d1075 --- /dev/null +++ b/src/lib/providers/komga/komga.adapter.ts @@ -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, + }; + } +} diff --git a/src/lib/providers/komga/komga.provider.ts b/src/lib/providers/komga/komga.provider.ts new file mode 100644 index 0000000..8a3e006 --- /dev/null +++ b/src/lib/providers/komga/komga.provider.ts @@ -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; + +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 { + 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 = {}): Headers { + return new Headers({ + Authorization: `Basic ${this.config.authHeader}`, + Accept: "application/json", + ...extra, + }); + } + + private async fetch( + path: string, + params?: Record, + options: RequestInit & { revalidate?: number; tags?: string[] } = {} + ): Promise { + 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 { + const raw = await this.fetch("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 { + 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>( + "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 { + 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>( + "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 { + const [book, pages] = await Promise.all([ + this.fetch(`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 { + const series = await this.fetch(`series/${seriesId}`, undefined, { + revalidate: CACHE_TTL_MED, + }); + return KomgaAdapter.toNormalizedSeries(series); + } + + async getReadProgress(bookId: string): Promise { + const book = await this.fetch(`books/${bookId}`, undefined, { + revalidate: CACHE_TTL_SHORT, + }); + return KomgaAdapter.toNormalizedReadProgress(book.readProgress); + } + + async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise { + 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 { + const trimmed = query.trim(); + if (!trimmed) return []; + + const body = { fullTextSearch: trimmed }; + const [seriesResp, booksResp] = await Promise.all([ + this.fetch>( + "series/list", + { page: "0", size: String(limit) }, + { method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT } + ), + this.fetch>( + "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 { + try { + const lib = await this.fetch(`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 { + try { + const book = await this.fetch(`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 { + const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] }; + const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([ + this.fetch>( + "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>( + "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>( + "books/latest", + { page: "0", size: "10", media_status: "READY" }, + { ...homeOpts } + ).catch(() => ({ content: [] as KomgaBook[] })), + this.fetch>( + "books/ondeck", + { page: "0", size: "10", media_status: "READY" }, + { ...homeOpts } + ).catch(() => ({ content: [] as KomgaBook[] })), + this.fetch>( + "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 { + 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 { + const url = this.buildUrl(`libraries/${libraryId}/scan`); + const headers = this.getHeaders(); + await fetch(url, { method: "POST", headers }); + } + + async getRandomBook(libraryIds?: string[]): Promise { + 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>( + "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("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}`; + } +} diff --git a/src/lib/providers/provider.factory.ts b/src/lib/providers/provider.factory.ts new file mode 100644 index 0000000..85734b7 --- /dev/null +++ b/src/lib/providers/provider.factory.ts @@ -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 { + 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 { + 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"; +} diff --git a/src/lib/providers/provider.interface.ts b/src/lib/providers/provider.interface.ts new file mode 100644 index 0000000..6318c41 --- /dev/null +++ b/src/lib/providers/provider.interface.ts @@ -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; + getLibraryById(libraryId: string): Promise; + + getSeries(libraryId: string, cursor?: string, limit?: number, unreadOnly?: boolean, search?: string): Promise; + getSeriesById(seriesId: string): Promise; + + getBooks(filter: BookListFilter): Promise; + getBook(bookId: string): Promise; + getNextBook(bookId: string): Promise; + + // ── Home ───────────────────────────────────────────────────────────────── + getHomeData(): Promise; + + // ── Read progress ──────────────────────────────────────────────────────── + getReadProgress(bookId: string): Promise; + saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise; + resetReadProgress(bookId: string): Promise; + + // ── Admin / utility ────────────────────────────────────────────────────── + scanLibrary(libraryId: string): Promise; + getRandomBook(libraryIds?: string[]): Promise; + + // ── Search ─────────────────────────────────────────────────────────────── + search(query: string, limit?: number): Promise; + + // ── 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; +} diff --git a/src/lib/providers/stripstream/stripstream.adapter.ts b/src/lib/providers/stripstream/stripstream.adapter.ts new file mode 100644 index 0000000..17fadf7 --- /dev/null +++ b/src/lib/providers/stripstream/stripstream.adapter.ts @@ -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, + }; + } +} diff --git a/src/lib/providers/stripstream/stripstream.client.ts b/src/lib/providers/stripstream/stripstream.client.ts new file mode 100644 index 0000000..1f315ab --- /dev/null +++ b/src/lib/providers/stripstream/stripstream.client.ts @@ -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 = {}): Headers { + return new Headers({ + Authorization: `Bearer ${this.token}`, + Accept: "application/json", + ...extra, + }); + } + + buildUrl(path: string, params?: Record): 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( + path: string, + params?: Record, + options: FetchOptions = {} + ): Promise { + 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 { + 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); + } + } +} diff --git a/src/lib/providers/stripstream/stripstream.provider.ts b/src/lib/providers/stripstream/stripstream.provider.ts new file mode 100644 index 0000000..c272ed2 --- /dev/null +++ b/src/lib/providers/stripstream/stripstream.provider.ts @@ -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 { + const libraries = await this.client.fetch("libraries", undefined, { + revalidate: CACHE_TTL_LONG, + }); + return libraries.map(StripstreamAdapter.toNormalizedLibrary); + } + + async getLibraryById(libraryId: string): Promise { + try { + const libraries = await this.client.fetch("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 { + const pageNumber = page ? parseInt(page) : 1; + const params: Record = { 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( + `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 { + // 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(`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( + "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 { + const limit = filter.limit ?? 24; + const params: Record = { 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( + `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("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 { + const book = await this.client.fetch(`books/${bookId}`, undefined, { + revalidate: CACHE_TTL_SHORT, + }); + return StripstreamAdapter.toNormalizedBookDetails(book); + } + + async getNextBook(bookId: string): Promise { + try { + const book = await this.client.fetch(`books/${bookId}`, undefined, { + revalidate: CACHE_TTL_SHORT, + }); + if (!book.series || book.volume == null) return null; + + const response = await this.client.fetch("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 { + // 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("books", { limit: "10" }, homeOpts), + this.client.fetch("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( + `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 { + const progress = await this.client.fetch( + `books/${bookId}/progress`, + undefined, + { revalidate: CACHE_TTL_SHORT } + ); + return StripstreamAdapter.toNormalizedReadProgress(progress); + } + + async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise { + const status = completed ? "read" : page !== null && page > 0 ? "reading" : "unread"; + await this.client.fetch(`books/${bookId}/progress`, undefined, { + method: "PATCH", + body: JSON.stringify({ status, current_page: page }), + }); + } + + async resetReadProgress(bookId: string): Promise { + await this.client.fetch(`books/${bookId}/progress`, undefined, { + method: "PATCH", + body: JSON.stringify({ status: "unread", current_page: null }), + }); + } + + async scanLibrary(libraryId: string): Promise { + await this.client.fetch(`libraries/${libraryId}/scan`, undefined, { + method: "POST", + }); + } + + async getRandomBook(libraryIds?: string[]): Promise { + try { + const params: Record = { limit: "50" }; + if (libraryIds?.length) { + params.library_id = libraryIds[Math.floor(Math.random() * libraryIds.length)]; + } + const response = await this.client.fetch("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 { + const trimmed = query.trim(); + if (!trimmed) return []; + + const response = await this.client.fetch("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("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}`; + } +} diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts new file mode 100644 index 0000000..ba5aaee --- /dev/null +++ b/src/lib/providers/types.ts @@ -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; +} diff --git a/src/lib/services/base-api.service.ts b/src/lib/services/base-api.service.ts deleted file mode 100644 index b21ac2f..0000000 --- a/src/lib/services/base-api.service.ts +++ /dev/null @@ -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; -} - -interface FetchErrorLike { - code?: string; - cause?: { - code?: string; - }; -} - -export abstract class BaseApiService { - protected static async getKomgaConfig(): Promise { - 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 { - 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( - urlBuilder: KomgaUrlBuilder, - headersOptions = {}, - options: KomgaRequestInit = {} - ): Promise { - 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); - } - } -} diff --git a/src/lib/services/book.service.ts b/src/lib/services/book.service.ts index 300a065..462ac96 100644 --- a/src/lib/services/book.service.ts +++ b/src/lib/services/book.service.ts @@ -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 { - try { - // Récupération parallèle des détails du tome et des pages - const [book, pages] = await Promise.all([ - this.fetchFromApi( - { 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 { - try { - // Utiliser l'endpoint natif Komga pour obtenir le livre suivant - return await this.fetchFromApi({ 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 { - try { - const book = await this.fetchFromApi( - { 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 { - 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 { - 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 { 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 { 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 { 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 { - 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); - } - } } diff --git a/src/lib/services/client-offlinebook.service.ts b/src/lib/services/client-offlinebook.service.ts index 22b03b4..19c372e 100644 --- a/src/lib/services/client-offlinebook.service.ts +++ b/src/lib/services/client-offlinebook.service.ts @@ -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`); diff --git a/src/lib/services/favorite.service.ts b/src/lib/services/favorite.service.ts index 0499604..a100abc 100644 --- a/src/lib/services/favorite.service.ts +++ b/src/lib/services/favorite.service.ts @@ -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 { 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 { 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 { 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 { - 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 { - const user = await this.getCurrentUser(); - const userId = parseInt(user.id, 10); - - const result = await prisma.favorite.deleteMany({ - where: { - userId, - seriesId, - }, - }); - return result.count > 0; - } } diff --git a/src/lib/services/favorites.service.ts b/src/lib/services/favorites.service.ts index cc7f0e8..5c559db 100644 --- a/src/lib/services/favorites.service.ts +++ b/src/lib/services/favorites.service.ts @@ -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 { + }): Promise { 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( { diff --git a/src/lib/services/home.service.ts b/src/lib/services/home.service.ts deleted file mode 100644 index b8237ee..0000000 --- a/src/lib/services/home.service.ts +++ /dev/null @@ -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 { - try { - const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([ - this.fetchFromApi>( - { - 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>( - { - 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>( - { - path: "books/latest", - params: { - page: "0", - size: "10", - media_status: "READY", - }, - }, - {}, - { revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] } - ), - this.fetchFromApi>( - { - path: "books/ondeck", - params: { - page: "0", - size: "10", - media_status: "READY", - }, - }, - {}, - { revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] } - ), - this.fetchFromApi>( - { - 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); - } - } -} diff --git a/src/lib/services/image.service.ts b/src/lib/services/image.service.ts index 892574a..32eaa27 100644 --- a/src/lib/services/image.service.ts +++ b/src/lib/services/image.service.ts @@ -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 { 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({ 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`; - } } diff --git a/src/lib/services/library.service.ts b/src/lib/services/library.service.ts deleted file mode 100644 index 8bcec7b..0000000 --- a/src/lib/services/library.service.ts +++ /dev/null @@ -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; - -const sortSeriesDeterministically = ( - 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 { - try { - const libraries = await this.fetchFromApi( - { 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 { - try { - return this.fetchFromApi({ 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> { - 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 = { - page: String(page), - size: String(size), - sort: "metadata.titleSort,asc", - }; - - if (search) { - searchBody.fullTextSearch = search; - } - - const response = await this.fetchFromApi>( - { 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 { - 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); - } - } -} diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts deleted file mode 100644 index 22319e3..0000000 --- a/src/lib/services/search.service.ts +++ /dev/null @@ -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 { - 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 { - 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>( - { path: "series/list", params: { page: "0", size: String(limit) } }, - headers, - { - method: "POST", - body: JSON.stringify(searchBody), - revalidate: this.CACHE_TTL, - } - ), - this.fetchFromApi>( - { 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); - } - } -} diff --git a/src/lib/services/series.service.ts b/src/lib/services/series.service.ts index 165d32c..27af469 100644 --- a/src/lib/services/series.service.ts +++ b/src/lib/services/series.service.ts @@ -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; +export class SeriesService { + private static async getFirstBook(seriesId: string): Promise { + 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 { - try { - return this.fetchFromApi( - { 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> { - 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 = { - page: String(page), - size: String(size), - sort: "metadata.numberSort,asc", - }; - - const response = await this.fetchFromApi>( - { 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 { - try { - const data: LibraryResponse = await this.fetchFromApi>({ - 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 { 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 { - try { - const seriesPromises: Promise[] = 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); } } diff --git a/src/lib/services/test.service.ts b/src/lib/services/test.service.ts deleted file mode 100644 index ed7f926..0000000 --- a/src/lib/services/test.service.ts +++ /dev/null @@ -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); - } - } -} diff --git a/src/types/home.ts b/src/types/home.ts index 349a0b4..bf5d8a3 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -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[]; } diff --git a/src/types/stripstream.ts b/src/types/stripstream.ts new file mode 100644 index 0000000..5e7a379 --- /dev/null +++ b/src/types/stripstream.ts @@ -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; +}