feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Introduce provider abstraction layer (IMediaProvider, KomgaProvider, StripstreamProvider) - Add Stripstream Librarian as second media provider with full feature parity - Migrate all pages and components from direct Komga services to provider factory - Remove dead service code (BaseApiService, HomeService, LibraryService, SearchService, TestService) - Fix library/series page-based pagination for both providers (Komga 0-indexed, Stripstream 1-indexed) - Fix unread filter and search on library page for both providers - Fix read progress display for Stripstream (reading_status mapping) - Fix series read status (books_read_count) for Stripstream - Add global search with series results for Stripstream (series_hits from Meilisearch) - Fix thumbnail proxy to return 404 gracefully instead of JSON on upstream error - Replace duration-based cache debug detection with x-nextjs-cache header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { BookService } from "@/lib/services/book.service";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface BookDataResult {
|
||||
success: boolean;
|
||||
data?: {
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
pages: number[];
|
||||
nextBook: KomgaBook | null;
|
||||
nextBook: NormalizedBook | null;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function getBookData(bookId: string): Promise<BookDataResult> {
|
||||
try {
|
||||
const data = await BookService.getBook(bookId);
|
||||
let nextBook = null;
|
||||
const provider = await getProvider();
|
||||
if (!provider) {
|
||||
return { success: false, message: "KOMGA_MISSING_CONFIG" };
|
||||
}
|
||||
|
||||
const book = await provider.getBook(bookId);
|
||||
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
|
||||
|
||||
let nextBook: NormalizedBook | null = null;
|
||||
try {
|
||||
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
||||
nextBook = await provider.getNextBook(bookId);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...data,
|
||||
nextBook,
|
||||
},
|
||||
data: { book, pages, nextBook },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
183
src/app/actions/stripstream-config.ts
Normal file
183
src/app/actions/stripstream-config.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { ProviderType } from "@/lib/providers/types";
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration Stripstream
|
||||
*/
|
||||
export async function saveStripstreamConfig(
|
||||
url: string,
|
||||
token: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { success: false, message: "Non authentifié" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
await prisma.stripstreamConfig.upsert({
|
||||
where: { userId },
|
||||
update: { url, token },
|
||||
create: { userId, url, token },
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "Configuration Stripstream sauvegardée" };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors de la sauvegarde" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Stripstream Librarian
|
||||
*/
|
||||
export async function testStripstreamConnection(
|
||||
url: string,
|
||||
token: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const provider = new StripstreamProvider(url, token);
|
||||
const result = await provider.testConnection();
|
||||
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.error ?? "Connexion échouée" };
|
||||
}
|
||||
|
||||
return { success: true, message: "Connexion Stripstream réussie !" };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors du test de connexion" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit le provider actif de l'utilisateur
|
||||
*/
|
||||
export async function setActiveProvider(
|
||||
provider: ProviderType
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { success: false, message: "Non authentifié" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
// Vérifier que le provider est configuré avant de l'activer
|
||||
if (provider === "komga") {
|
||||
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
|
||||
if (!config) {
|
||||
return { success: false, message: "Komga n'est pas encore configuré" };
|
||||
}
|
||||
} else if (provider === "stripstream") {
|
||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
||||
if (!config) {
|
||||
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { activeProvider: provider },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/settings");
|
||||
return {
|
||||
success: true,
|
||||
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors du changement de provider" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Stripstream de l'utilisateur
|
||||
*/
|
||||
export async function getStripstreamConfig(): Promise<{
|
||||
url?: string;
|
||||
hasToken: boolean;
|
||||
} | null> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const config = await prisma.stripstreamConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { url: true },
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
return { url: config.url, hasToken: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le provider actif de l'utilisateur
|
||||
*/
|
||||
export async function getActiveProvider(): Promise<ProviderType> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return "komga";
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
|
||||
return (dbUser?.activeProvider as ProviderType) ?? "komga";
|
||||
} catch {
|
||||
return "komga";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie quels providers sont configurés
|
||||
*/
|
||||
export async function getProvidersStatus(): Promise<{
|
||||
komgaConfigured: boolean;
|
||||
stripstreamConfigured: boolean;
|
||||
activeProvider: ProviderType;
|
||||
}> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
||||
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
komgaConfigured: !!komgaConfig,
|
||||
stripstreamConfigured: !!stripstreamConfig,
|
||||
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
||||
};
|
||||
} catch {
|
||||
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/provider/search/route.ts
Normal file
44
src/app/api/provider/search/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { AppError, getErrorMessage } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
const DEFAULT_LIMIT = 6;
|
||||
const MAX_LIMIT = 10;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
const limitParam = request.nextUrl.searchParams.get("limit");
|
||||
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
|
||||
const limit = Number.isFinite(parsedLimit)
|
||||
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
if (query.length < MIN_QUERY_LENGTH) {
|
||||
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
if (!provider) {
|
||||
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
const results = await provider.search(query, limit);
|
||||
|
||||
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: getErrorMessage(error.code) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/stripstream/search/route.ts
Normal file
44
src/app/api/stripstream/search/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { AppError, getErrorMessage } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
const DEFAULT_LIMIT = 6;
|
||||
const MAX_LIMIT = 10;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||
const limitParam = request.nextUrl.searchParams.get("limit");
|
||||
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
|
||||
const limit = Number.isFinite(parsedLimit)
|
||||
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
if (query.length < MIN_QUERY_LENGTH) {
|
||||
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
const provider = await getProvider();
|
||||
if (!provider) {
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||
}
|
||||
|
||||
const results = await provider.search(query, limit);
|
||||
|
||||
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: getErrorMessage(error.code) } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
||||
import { BookService } from "@/lib/services/book.service";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -11,23 +11,30 @@ export default async function BookPage({ params }: { params: Promise<{ bookId: s
|
||||
const { bookId } = await params;
|
||||
|
||||
try {
|
||||
// SSR: Fetch directly on server instead of client-side XHR
|
||||
const data = await BookService.getBook(bookId);
|
||||
const provider = await getProvider();
|
||||
if (!provider) redirect("/settings");
|
||||
|
||||
const book = await provider.getBook(bookId);
|
||||
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
|
||||
|
||||
let nextBook = null;
|
||||
try {
|
||||
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
||||
nextBook = await provider.getNextBook(bookId);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<BookSkeleton />}>
|
||||
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} />
|
||||
<ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
|
||||
</Suspense>
|
||||
);
|
||||
} catch (error) {
|
||||
// If config is missing, redirect to settings
|
||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||
if (error instanceof AppError && (
|
||||
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||
)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
),
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
||||
import { Container } from "@/components/ui/container";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { Series } from "@/types/series";
|
||||
import type { NormalizedLibrary, NormalizedSeriesPage } from "@/lib/providers/types";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
|
||||
interface LibraryContentProps {
|
||||
library: KomgaLibrary;
|
||||
series: LibraryResponse<Series>;
|
||||
library: NormalizedLibrary;
|
||||
series: NormalizedSeriesPage;
|
||||
currentPage: number;
|
||||
preferences: UserPreferences;
|
||||
unreadOnly: boolean;
|
||||
@@ -28,15 +26,15 @@ export function LibraryContent({
|
||||
<>
|
||||
<LibraryHeader
|
||||
library={library}
|
||||
seriesCount={series.totalElements}
|
||||
series={series.content || []}
|
||||
seriesCount={series.totalElements ?? series.items.length}
|
||||
series={series.items}
|
||||
/>
|
||||
<Container>
|
||||
<PaginatedSeriesGrid
|
||||
series={series.content || []}
|
||||
series={series.items}
|
||||
currentPage={currentPage}
|
||||
totalPages={series.totalPages}
|
||||
totalElements={series.totalElements}
|
||||
totalPages={series.totalPages ?? 1}
|
||||
totalElements={series.totalElements ?? series.items.length}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||
import { LibraryService } from "@/lib/services/library.service";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { LibraryClientWrapper } from "./LibraryClientWrapper";
|
||||
import { LibraryContent } from "./LibraryContent";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ libraryId: string }>;
|
||||
@@ -17,9 +18,9 @@ const DEFAULT_PAGE_SIZE = 20;
|
||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||
const libraryId = (await params).libraryId;
|
||||
const unread = (await searchParams).unread;
|
||||
const search = (await searchParams).search;
|
||||
const page = (await searchParams).page;
|
||||
const size = (await searchParams).size;
|
||||
const search = (await searchParams).search;
|
||||
|
||||
const currentPage = page ? parseInt(page) : 1;
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
@@ -31,31 +32,36 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||
|
||||
try {
|
||||
const [series, library] = await Promise.all([
|
||||
LibraryService.getLibrarySeries(
|
||||
libraryId,
|
||||
currentPage - 1,
|
||||
effectivePageSize,
|
||||
unreadOnly,
|
||||
search
|
||||
),
|
||||
LibraryService.getLibrary(libraryId),
|
||||
const provider = await getProvider();
|
||||
if (!provider) redirect("/settings");
|
||||
|
||||
const [seriesPage, library] = await Promise.all([
|
||||
provider.getSeries(libraryId, String(currentPage), effectivePageSize, unreadOnly, search),
|
||||
provider.getLibraryById(libraryId),
|
||||
]);
|
||||
|
||||
if (!library) throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND);
|
||||
|
||||
return (
|
||||
<LibraryClientWrapper libraryId={libraryId}>
|
||||
<LibraryContent
|
||||
library={library}
|
||||
series={series}
|
||||
series={seriesPage}
|
||||
currentPage={currentPage}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
search={search}
|
||||
pageSize={effectivePageSize}
|
||||
/>
|
||||
</LibraryClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && (
|
||||
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||
)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HomeService } from "@/lib/services/home.service";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { HomeContent } from "@/components/home/HomeContent";
|
||||
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
@@ -8,7 +8,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export default async function HomePage() {
|
||||
try {
|
||||
const data = await HomeService.getHomeData();
|
||||
const provider = await getProvider();
|
||||
if (!provider) redirect("/settings");
|
||||
|
||||
const data = await provider.getHomeData();
|
||||
|
||||
return (
|
||||
<HomeClientWrapper>
|
||||
@@ -16,13 +19,14 @@ export default async function HomePage() {
|
||||
</HomeClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
// Si la config Komga est manquante, rediriger vers les settings
|
||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||
if (error instanceof AppError && (
|
||||
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||
)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
// Afficher une erreur pour les autres cas
|
||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
|
||||
@@ -4,13 +4,12 @@ import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { Container } from "@/components/ui/container";
|
||||
import { useRefresh } from "@/contexts/RefreshContext";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedBooksPage, NormalizedSeries } from "@/lib/providers/types";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
|
||||
interface SeriesContentProps {
|
||||
series: KomgaSeries;
|
||||
books: LibraryResponse<KomgaBook>;
|
||||
series: NormalizedSeries;
|
||||
books: NormalizedBooksPage;
|
||||
currentPage: number;
|
||||
preferences: UserPreferences;
|
||||
unreadOnly: boolean;
|
||||
@@ -37,10 +36,10 @@ export function SeriesContent({
|
||||
/>
|
||||
<Container>
|
||||
<PaginatedBookGrid
|
||||
books={books.content || []}
|
||||
books={books.items}
|
||||
currentPage={currentPage}
|
||||
totalPages={books.totalPages}
|
||||
totalElements={books.totalElements}
|
||||
totalPages={books.totalPages ?? 1}
|
||||
totalElements={books.totalElements ?? books.items.length}
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||
import { SeriesService } from "@/lib/services/series.service";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
|
||||
import { FavoriteService } from "@/lib/services/favorite.service";
|
||||
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
||||
import { SeriesContent } from "./SeriesContent";
|
||||
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ seriesId: string }>;
|
||||
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
|
||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||
const seriesId = (await params).seriesId;
|
||||
const page = (await searchParams).page;
|
||||
const unread = (await searchParams).unread;
|
||||
const size = (await searchParams).size;
|
||||
|
||||
const unread = (await searchParams).unread;
|
||||
const currentPage = page ? parseInt(page) : 1;
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
|
||||
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
||||
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||
const effectivePageSize = size
|
||||
? parseInt(size)
|
||||
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||
|
||||
try {
|
||||
const [books, series, isFavorite] = await Promise.all([
|
||||
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
||||
SeriesService.getSeries(seriesId),
|
||||
const provider = await getProvider();
|
||||
if (!provider) redirect("/settings");
|
||||
|
||||
const [booksPage, series, isFavorite] = await Promise.all([
|
||||
provider.getBooks({
|
||||
seriesName: seriesId,
|
||||
cursor: String(currentPage),
|
||||
limit: effectivePageSize,
|
||||
unreadOnly,
|
||||
}),
|
||||
provider.getSeriesById(seriesId),
|
||||
FavoriteService.isFavorite(seriesId),
|
||||
]);
|
||||
|
||||
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||
|
||||
return (
|
||||
<SeriesClientWrapper seriesId={seriesId}>
|
||||
<SeriesContent
|
||||
series={series}
|
||||
books={books}
|
||||
books={booksPage}
|
||||
currentPage={currentPage}
|
||||
preferences={preferences}
|
||||
unreadOnly={unreadOnly}
|
||||
@@ -49,10 +61,16 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||
</SeriesClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
const errorCode = error instanceof AppError
|
||||
? error.code
|
||||
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
||||
|
||||
if (
|
||||
error instanceof AppError &&
|
||||
(error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG)
|
||||
) {
|
||||
redirect("/settings");
|
||||
}
|
||||
|
||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ErrorMessage errorCode={errorCode} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||
import { LibraryService } from "@/lib/services/library.service";
|
||||
import { ClientSettings } from "@/components/settings/ClientSettings";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import { getStripstreamConfig, getProvidersStatus } from "@/app/actions/stripstream-config";
|
||||
import type { Metadata } from "next";
|
||||
import type { KomgaConfig, KomgaLibrary } from "@/types/komga";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import type { NormalizedLibrary } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function SettingsPage() {
|
||||
let config: KomgaConfig | null = null;
|
||||
let libraries: KomgaLibrary[] = [];
|
||||
let libraries: NormalizedLibrary[] = [];
|
||||
let stripstreamConfig: { url?: string; hasToken: boolean } | null = null;
|
||||
let providersStatus: {
|
||||
komgaConfigured: boolean;
|
||||
stripstreamConfigured: boolean;
|
||||
activeProvider: "komga" | "stripstream";
|
||||
} | undefined = undefined;
|
||||
|
||||
try {
|
||||
// Récupérer la configuration Komga
|
||||
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||
if (mongoConfig) {
|
||||
config = {
|
||||
@@ -29,11 +36,31 @@ export default async function SettingsPage() {
|
||||
};
|
||||
}
|
||||
|
||||
libraries = await LibraryService.getLibraries();
|
||||
const [provider, stConfig, status] = await Promise.allSettled([
|
||||
getProvider().then((p) => p?.getLibraries() ?? []),
|
||||
getStripstreamConfig(),
|
||||
getProvidersStatus(),
|
||||
]);
|
||||
|
||||
if (provider.status === "fulfilled") {
|
||||
libraries = provider.value;
|
||||
}
|
||||
if (stConfig.status === "fulfilled") {
|
||||
stripstreamConfig = stConfig.value;
|
||||
}
|
||||
if (status.status === "fulfilled") {
|
||||
providersStatus = status.value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
||||
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
|
||||
}
|
||||
|
||||
return <ClientSettings initialConfig={config} initialLibraries={libraries} />;
|
||||
return (
|
||||
<ClientSettings
|
||||
initialConfig={config}
|
||||
initialLibraries={libraries}
|
||||
stripstreamConfig={stripstreamConfig}
|
||||
providersStatus={providersStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
|
||||
}
|
||||
|
||||
interface DownloadedBook {
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
status: BookDownloadStatus;
|
||||
}
|
||||
|
||||
@@ -112,11 +112,11 @@ export function DownloadManager() {
|
||||
};
|
||||
}, [loadDownloadedBooks, updateBookStatuses]);
|
||||
|
||||
const handleDeleteBook = async (book: KomgaBook) => {
|
||||
const handleDeleteBook = async (book: NormalizedBook) => {
|
||||
try {
|
||||
const cache = await caches.open("stripstream-books");
|
||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
||||
for (let i = 1; i <= book.media.pagesCount; i++) {
|
||||
for (let i = 1; i <= book.pageCount; i++) {
|
||||
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||
}
|
||||
localStorage.removeItem(getStorageKey(book.id));
|
||||
@@ -135,7 +135,7 @@ export function DownloadManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryDownload = async (book: KomgaBook) => {
|
||||
const handleRetryDownload = async (book: NormalizedBook) => {
|
||||
localStorage.removeItem(getStorageKey(book.id));
|
||||
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
||||
toast({
|
||||
@@ -279,7 +279,7 @@ export function DownloadManager() {
|
||||
}
|
||||
|
||||
interface BookDownloadCardProps {
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
status: BookDownloadStatus;
|
||||
onDelete: () => void;
|
||||
onRetry: () => void;
|
||||
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
||||
src={book.thumbnailUrl}
|
||||
alt={t("books.coverAlt", { title: book.title })}
|
||||
className="object-cover"
|
||||
fill
|
||||
sizes="64px"
|
||||
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
||||
className="hover:underline hover:text-primary transition-colors"
|
||||
>
|
||||
<h3 className="font-medium truncate">
|
||||
{book.metadata?.title || t("books.title", { number: book.metadata?.number })}
|
||||
{book.title || t("books.title", { number: book.number ?? "" })}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatSize(book.sizeBytes)}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{status.status === "downloading"
|
||||
? t("downloads.info.pages", {
|
||||
current: Math.floor((status.progress * book.media.pagesCount) / 100),
|
||||
total: book.media.pagesCount,
|
||||
current: Math.floor((status.progress * book.pageCount) / 100),
|
||||
total: book.pageCount,
|
||||
})
|
||||
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
|
||||
: t("downloads.info.totalPages", { count: book.pageCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -2,39 +2,27 @@
|
||||
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
|
||||
interface OptimizedHeroSeries {
|
||||
id: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
|
||||
interface HeroSectionProps {
|
||||
series: OptimizedHeroSeries[];
|
||||
series: NormalizedSeries[];
|
||||
}
|
||||
|
||||
export function HeroSection({ series }: HeroSectionProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
// logger.info("HeroSection - Séries reçues:", {
|
||||
// count: series?.length || 0,
|
||||
// firstSeries: series?.[0],
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
|
||||
{/* Grille de couvertures en arrière-plan */}
|
||||
<div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 sm:gap-4 p-4 opacity-10">
|
||||
{series?.map((series) => (
|
||||
{series?.map((s) => (
|
||||
<div
|
||||
key={series.id}
|
||||
key={s.id}
|
||||
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
|
||||
>
|
||||
<SeriesCover
|
||||
series={series as KomgaSeries}
|
||||
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
|
||||
series={s}
|
||||
alt={t("home.hero.coverAlt", { title: s.name })}
|
||||
showProgressUi={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import { MediaRow } from "./MediaRow";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { HomeData } from "@/types/home";
|
||||
|
||||
interface HomeContentProps {
|
||||
data: HomeData;
|
||||
}
|
||||
|
||||
const optimizeSeriesData = (series: KomgaSeries[]) => {
|
||||
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
|
||||
id,
|
||||
metadata: { title: metadata.title },
|
||||
booksCount,
|
||||
booksReadCount,
|
||||
}));
|
||||
};
|
||||
|
||||
const optimizeBookData = (books: KomgaBook[]) => {
|
||||
return books.map(({ id, metadata, readProgress, media }) => ({
|
||||
id,
|
||||
metadata: {
|
||||
title: metadata.title,
|
||||
number: metadata.number,
|
||||
},
|
||||
readProgress: readProgress || { page: 0 },
|
||||
media,
|
||||
}));
|
||||
};
|
||||
|
||||
export function HomeContent({ data }: HomeContentProps) {
|
||||
return (
|
||||
<div className="space-y-10 pb-2">
|
||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_reading"
|
||||
items={optimizeBookData(data.ongoingBooks)}
|
||||
items={data.ongoingBooks}
|
||||
iconName="BookOpen"
|
||||
featuredHeader
|
||||
/>
|
||||
@@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.ongoing && data.ongoing.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_series"
|
||||
items={optimizeSeriesData(data.ongoing)}
|
||||
items={data.ongoing}
|
||||
iconName="LibraryBig"
|
||||
/>
|
||||
)}
|
||||
@@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.onDeck && data.onDeck.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.up_next"
|
||||
items={optimizeBookData(data.onDeck)}
|
||||
items={data.onDeck}
|
||||
iconName="Clock"
|
||||
/>
|
||||
)}
|
||||
@@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.latest_series"
|
||||
items={optimizeSeriesData(data.latestSeries)}
|
||||
items={data.latestSeries}
|
||||
iconName="Sparkles"
|
||||
/>
|
||||
)}
|
||||
@@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.recently_added"
|
||||
items={optimizeBookData(data.recentlyRead)}
|
||||
items={data.recentlyRead}
|
||||
iconName="History"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
|
||||
import { BookCover } from "../ui/book-cover";
|
||||
import { SeriesCover } from "../ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -12,34 +12,9 @@ import { Card } from "@/components/ui/card";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BaseItem {
|
||||
id: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OptimizedSeries extends BaseItem {
|
||||
booksCount: number;
|
||||
booksReadCount: number;
|
||||
}
|
||||
|
||||
interface OptimizedBook extends BaseItem {
|
||||
readProgress: {
|
||||
page: number;
|
||||
};
|
||||
media: {
|
||||
pagesCount: number;
|
||||
};
|
||||
metadata: {
|
||||
title: string;
|
||||
number?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaRowProps {
|
||||
titleKey: string;
|
||||
items: (OptimizedSeries | OptimizedBook)[];
|
||||
items: (NormalizedSeries | NormalizedBook)[];
|
||||
iconName?: string;
|
||||
featuredHeader?: boolean;
|
||||
}
|
||||
@@ -52,13 +27,17 @@ const iconMap = {
|
||||
History,
|
||||
};
|
||||
|
||||
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
|
||||
return "bookCount" in item;
|
||||
}
|
||||
|
||||
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||
|
||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
||||
const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
|
||||
const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
@@ -92,24 +71,24 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
|
||||
}
|
||||
|
||||
interface MediaCardProps {
|
||||
item: OptimizedSeries | OptimizedBook;
|
||||
item: NormalizedSeries | NormalizedBook;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
const { t } = useTranslate();
|
||||
const isSeries = "booksCount" in item;
|
||||
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
||||
const isSeriesItem = isSeries(item);
|
||||
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||
|
||||
const title = isSeries
|
||||
? item.metadata.title
|
||||
: item.metadata.title ||
|
||||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
|
||||
const title = isSeriesItem
|
||||
? item.name
|
||||
: item.title ||
|
||||
(item.number ? t("navigation.volume", { number: item.number }) : "");
|
||||
|
||||
const handleClick = () => {
|
||||
// Pour les séries, toujours autoriser le clic
|
||||
// Pour les livres, vérifier si accessible
|
||||
if (isSeries || isAccessible) {
|
||||
if (isSeriesItem || isAccessible) {
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
@@ -119,24 +98,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
|
||||
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||
!isSeriesItem && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-[2/3] bg-muted">
|
||||
{isSeries ? (
|
||||
{isSeriesItem ? (
|
||||
<>
|
||||
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("series.books", { count: item.booksCount })}
|
||||
</p>
|
||||
<SeriesCover series={item} alt={`Couverture de ${title}`} />
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("series.books", { count: item.bookCount })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookCover
|
||||
book={item as KomgaBook}
|
||||
book={item}
|
||||
alt={`Couverture de ${title}`}
|
||||
showControls={false}
|
||||
overlayVariant="home"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
|
||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
|
||||
import { defaultPreferences } from "@/types/preferences";
|
||||
import logger from "@/lib/logger";
|
||||
import { getRandomBookFromLibraries } from "@/app/actions/library";
|
||||
@@ -20,8 +20,8 @@ const publicRoutes = ["/login", "/register"];
|
||||
|
||||
interface ClientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
initialLibraries: KomgaLibrary[];
|
||||
initialFavorites: KomgaSeries[];
|
||||
initialLibraries: NormalizedLibrary[];
|
||||
initialFavorites: NormalizedSeries[];
|
||||
userIsAdmin?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,26 +6,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { getImageUrl } from "@/lib/utils/image-url";
|
||||
|
||||
interface SearchSeriesResult {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
booksCount: number;
|
||||
}
|
||||
|
||||
interface SearchBookResult {
|
||||
id: string;
|
||||
title: string;
|
||||
seriesTitle: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
series: SearchSeriesResult[];
|
||||
books: SearchBookResult[];
|
||||
}
|
||||
import type { NormalizedSearchResult } from "@/lib/providers/types";
|
||||
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
@@ -38,21 +19,15 @@ export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResponse>({ series: [], books: [] });
|
||||
const [results, setResults] = useState<NormalizedSearchResult[]>([]);
|
||||
|
||||
const hasResults = results.series.length > 0 || results.books.length > 0;
|
||||
const seriesResults = results.filter((r) => r.type === "series");
|
||||
const bookResults = results.filter((r) => r.type === "book");
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
const firstResultHref = useMemo(() => {
|
||||
if (results.series.length > 0) {
|
||||
return results.series[0].href;
|
||||
}
|
||||
|
||||
if (results.books.length > 0) {
|
||||
return results.books[0].href;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [results.books, results.series]);
|
||||
return results[0]?.href ?? null;
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -77,7 +52,7 @@ export function GlobalSearch() {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
|
||||
setResults({ series: [], books: [] });
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +65,7 @@ export function GlobalSearch() {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
const response = await fetch(`/api/provider/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
@@ -100,12 +75,12 @@ export function GlobalSearch() {
|
||||
throw new Error("Search request failed");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SearchResponse;
|
||||
setResults(data);
|
||||
const data = (await response.json()) as NormalizedSearchResult[];
|
||||
setResults(Array.isArray(data) ? data : []);
|
||||
setIsOpen(true);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
setResults({ series: [], books: [] });
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -158,12 +133,12 @@ export function GlobalSearch() {
|
||||
{isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-xl backdrop-blur-xl">
|
||||
<div className="max-h-[26rem] overflow-y-auto p-2">
|
||||
{results.series.length > 0 && (
|
||||
{seriesResults.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("header.search.series")}
|
||||
</div>
|
||||
{results.series.map((item) => (
|
||||
{seriesResults.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
@@ -172,16 +147,17 @@ export function GlobalSearch() {
|
||||
aria-label={t("header.search.openSeries", { title: item.title })}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl("series", item.id)}
|
||||
src={item.coverUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
className="h-14 w-10 rounded object-cover bg-muted"
|
||||
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-medium">{item.title}</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Library className="h-3 w-3" />
|
||||
{t("series.books", { count: item.booksCount })}
|
||||
{item.bookCount !== undefined && t("series.books", { count: item.bookCount })}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -189,12 +165,12 @@ export function GlobalSearch() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.books.length > 0 && (
|
||||
{bookResults.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("header.search.books")}
|
||||
</div>
|
||||
{results.books.map((item) => (
|
||||
{bookResults.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
@@ -203,10 +179,11 @@ export function GlobalSearch() {
|
||||
aria-label={t("header.search.openBook", { title: item.title })}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl("book", item.id)}
|
||||
src={item.coverUrl}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
className="h-14 w-10 rounded object-cover bg-muted"
|
||||
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-base font-medium">{item.title}</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { NavButton } from "@/components/ui/nav-button";
|
||||
@@ -25,8 +25,8 @@ import logger from "@/lib/logger";
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialLibraries: KomgaLibrary[];
|
||||
initialFavorites: KomgaSeries[];
|
||||
initialLibraries: NormalizedLibrary[];
|
||||
initialFavorites: NormalizedSeries[];
|
||||
userIsAdmin?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export function Sidebar({
|
||||
const { t } = useTranslate();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
||||
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
|
||||
const [favorites, setFavorites] = useState<NormalizedSeries[]>(initialFavorites || []);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -60,7 +60,7 @@ export function Sidebar({
|
||||
const customEvent = event as CustomEvent<{
|
||||
seriesId?: string;
|
||||
action?: "add" | "remove";
|
||||
series?: KomgaSeries;
|
||||
series?: NormalizedSeries;
|
||||
}>;
|
||||
|
||||
// Si on a les détails de l'action, faire une mise à jour optimiste locale
|
||||
@@ -207,7 +207,7 @@ export function Sidebar({
|
||||
<NavButton
|
||||
key={series.id}
|
||||
icon={Star}
|
||||
label={series.metadata.title}
|
||||
label={series.name}
|
||||
active={pathname === `/series/${series.id}`}
|
||||
onClick={() => handleLinkClick(`/series/${series.id}`)}
|
||||
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Library } from "lucide-react";
|
||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
|
||||
import { RefreshButton } from "./RefreshButton";
|
||||
import { ScanButton } from "./ScanButton";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
library: KomgaLibrary;
|
||||
library: NormalizedLibrary;
|
||||
seriesCount: number;
|
||||
series: KomgaSeries[];
|
||||
series: NormalizedSeries[];
|
||||
}
|
||||
|
||||
const getHeaderSeries = (series: KomgaSeries[]) => {
|
||||
const getHeaderSeries = (series: NormalizedSeries[]) => {
|
||||
if (series.length === 0) {
|
||||
return { featured: null, background: null };
|
||||
}
|
||||
@@ -84,8 +84,6 @@ export function LibraryHeader({
|
||||
<RefreshButton libraryId={library.id} />
|
||||
<ScanButton libraryId={library.id} />
|
||||
</div>
|
||||
|
||||
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
|
||||
interface SeriesGridProps {
|
||||
series: KomgaSeries[];
|
||||
series: NormalizedSeries[];
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
// Utility function to get reading status info
|
||||
const getReadingStatusInfo = (
|
||||
series: KomgaSeries,
|
||||
series: NormalizedSeries,
|
||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||
) => {
|
||||
if (series.booksCount === 0) {
|
||||
if (series.bookCount === 0) {
|
||||
return {
|
||||
label: t("series.status.noBooks"),
|
||||
className: "bg-yellow-500/10 text-yellow-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (series.booksCount === series.booksReadCount) {
|
||||
if (series.bookCount === series.booksReadCount) {
|
||||
return {
|
||||
label: t("series.status.read"),
|
||||
className: "bg-green-500/10 text-green-500",
|
||||
@@ -34,7 +34,7 @@ const getReadingStatusInfo = (
|
||||
return {
|
||||
label: t("series.status.progress", {
|
||||
read: series.booksReadCount,
|
||||
total: series.booksCount,
|
||||
total: series.bookCount,
|
||||
}),
|
||||
className: "bg-primary/15 text-primary",
|
||||
};
|
||||
@@ -67,32 +67,32 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||
)}
|
||||
>
|
||||
{series.map((series) => (
|
||||
{series.map((seriesItem) => (
|
||||
<button
|
||||
key={series.id}
|
||||
onClick={() => router.push(`/series/${series.id}`)}
|
||||
key={seriesItem.id}
|
||||
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
||||
className={cn(
|
||||
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
|
||||
series.booksCount === series.booksReadCount && "opacity-50",
|
||||
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||
isCompact && "aspect-[3/4]"
|
||||
)}
|
||||
>
|
||||
<SeriesCover
|
||||
series={series as KomgaSeries}
|
||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
||||
series={seriesItem}
|
||||
alt={t("series.coverAlt", { title: seriesItem.name })}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
getReadingStatusInfo(series, t).className
|
||||
getReadingStatusInfo(seriesItem, t).className
|
||||
}`}
|
||||
>
|
||||
{getReadingStatusInfo(series, t).label}
|
||||
{getReadingStatusInfo(seriesItem, t).label}
|
||||
</span>
|
||||
<span className="text-xs text-white/80">
|
||||
{t("series.books", { count: series.booksCount })}
|
||||
{t("series.books", { count: seriesItem.bookCount })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -10,28 +10,28 @@ import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
interface SeriesListProps {
|
||||
series: KomgaSeries[];
|
||||
series: NormalizedSeries[];
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
interface SeriesListItemProps {
|
||||
series: KomgaSeries;
|
||||
series: NormalizedSeries;
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
// Utility function to get reading status info
|
||||
const getReadingStatusInfo = (
|
||||
series: KomgaSeries,
|
||||
series: NormalizedSeries,
|
||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||
) => {
|
||||
if (series.booksCount === 0) {
|
||||
if (series.bookCount === 0) {
|
||||
return {
|
||||
label: t("series.status.noBooks"),
|
||||
className: "bg-yellow-500/10 text-yellow-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (series.booksCount === series.booksReadCount) {
|
||||
if (series.bookCount === series.booksReadCount) {
|
||||
return {
|
||||
label: t("series.status.read"),
|
||||
className: "bg-green-500/10 text-green-500",
|
||||
@@ -42,7 +42,7 @@ const getReadingStatusInfo = (
|
||||
return {
|
||||
label: t("series.status.progress", {
|
||||
read: series.booksReadCount,
|
||||
total: series.booksCount,
|
||||
total: series.bookCount,
|
||||
}),
|
||||
className: "bg-primary/15 text-primary",
|
||||
};
|
||||
@@ -62,9 +62,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
router.push(`/series/${series.id}`);
|
||||
};
|
||||
|
||||
const isCompleted = series.booksCount === series.booksReadCount;
|
||||
const isCompleted = series.bookCount === series.booksReadCount;
|
||||
const progressPercentage =
|
||||
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
|
||||
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
|
||||
|
||||
const statusInfo = getReadingStatusInfo(series, t);
|
||||
|
||||
@@ -81,7 +81,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted">
|
||||
<SeriesCover
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
{/* Titre et statut */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
|
||||
{series.metadata.title}
|
||||
{series.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -108,15 +108,15 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>
|
||||
{series.booksCount === 1
|
||||
{series.bookCount === 1
|
||||
? t("series.book", { count: 1 })
|
||||
: t("series.books", { count: series.booksCount })}
|
||||
: t("series.books", { count: series.bookCount })}
|
||||
</span>
|
||||
</div>
|
||||
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
||||
{series.authors && series.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1 hidden sm:flex">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
|
||||
<span className="line-clamp-1">{series.authors[0].name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted">
|
||||
<SeriesCover
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
|
||||
{series.metadata.title}
|
||||
{series.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -164,9 +164,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Résumé */}
|
||||
{series.metadata.summary && (
|
||||
{series.summary && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
|
||||
{series.metadata.summary}
|
||||
{series.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -176,55 +176,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>
|
||||
{series.booksCount === 1
|
||||
{series.bookCount === 1
|
||||
? t("series.book", { count: 1 })
|
||||
: t("series.books", { count: series.booksCount })}
|
||||
: t("series.books", { count: series.bookCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Auteurs */}
|
||||
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
||||
{series.authors && series.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="line-clamp-1">
|
||||
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
|
||||
{series.authors.map((a) => a.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date de création */}
|
||||
{series.created && (
|
||||
{series.createdAt && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(series.created)}</span>
|
||||
<span>{formatDate(series.createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Genres */}
|
||||
{series.metadata.genres && series.metadata.genres.length > 0 && (
|
||||
{series.genres && series.genres.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
<span className="line-clamp-1">
|
||||
{series.metadata.genres.slice(0, 3).join(", ")}
|
||||
{series.metadata.genres.length > 3 && ` +${series.metadata.genres.length - 3}`}
|
||||
{series.genres.slice(0, 3).join(", ")}
|
||||
{series.genres.length > 3 && ` +${series.genres.length - 3}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{series.metadata.tags && series.metadata.tags.length > 0 && (
|
||||
{series.tags && series.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
<span className="line-clamp-1">
|
||||
{series.metadata.tags.slice(0, 3).join(", ")}
|
||||
{series.metadata.tags.length > 3 && ` +${series.metadata.tags.length - 3}`}
|
||||
{series.tags.slice(0, 3).join(", ")}
|
||||
{series.tags.length > 3 && ` +${series.tags.length - 3}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Barre de progression */}
|
||||
{series.booksCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -5,16 +5,16 @@ import { ClientBookWrapper } from "./ClientBookWrapper";
|
||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
import { getBookData } from "@/app/actions/books";
|
||||
|
||||
interface ClientBookPageProps {
|
||||
bookId: string;
|
||||
initialData?: {
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
pages: number[];
|
||||
nextBook: KomgaBook | null;
|
||||
nextBook: NormalizedBook | null;
|
||||
};
|
||||
initialError?: string;
|
||||
}
|
||||
@@ -23,9 +23,9 @@ export function ClientBookPage({ bookId, initialData, initialError }: ClientBook
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<{
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
pages: number[];
|
||||
nextBook: KomgaBook | null;
|
||||
nextBook: NormalizedBook | null;
|
||||
} | null>(null);
|
||||
|
||||
// Use SSR data if available
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import { PhotoswipeReader } from "./PhotoswipeReader";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||
|
||||
interface ClientBookWrapperProps {
|
||||
book: KomgaBook;
|
||||
book: NormalizedBook;
|
||||
pages: number[];
|
||||
nextBook: KomgaBook | null;
|
||||
nextBook: NormalizedBook | null;
|
||||
}
|
||||
|
||||
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
|
||||
const router = useRouter();
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [targetPath, setTargetPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClosing || !targetPath) return;
|
||||
router.push(targetPath);
|
||||
}, [isClosing, targetPath, router]);
|
||||
|
||||
const handleCloseReader = (currentPage: number) => {
|
||||
ClientOfflineBookService.setCurrentPage(book, currentPage);
|
||||
setTargetPath(`/series/${book.seriesId}`);
|
||||
setIsClosing(true);
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (isClosing) {
|
||||
|
||||
@@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Derive page URL builder from book.thumbnailUrl (provider-agnostic)
|
||||
const bookPageUrlBuilder = useCallback(
|
||||
(pageNum: number) => book.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`),
|
||||
[book.thumbnailUrl]
|
||||
);
|
||||
const nextBookPageUrlBuilder = useCallback(
|
||||
(pageNum: number) =>
|
||||
nextBook ? nextBook.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`) : "",
|
||||
[nextBook]
|
||||
);
|
||||
|
||||
// Hooks
|
||||
const { direction, toggleDirection, isRTL } = useReadingDirection();
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
||||
@@ -38,10 +49,10 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
getPageUrl,
|
||||
prefetchCount,
|
||||
} = useImageLoader({
|
||||
bookId: book.id,
|
||||
pageUrlBuilder: bookPageUrlBuilder,
|
||||
pages,
|
||||
prefetchCount: preferences.readerPrefetchCount,
|
||||
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
|
||||
nextBook: nextBook ? { getPageUrl: nextBookPageUrlBuilder, pages: [] } : null,
|
||||
});
|
||||
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
|
||||
usePageNavigation({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import { BookCover } from "@/components/ui/book-cover";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -9,22 +9,22 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Calendar, FileText, User, Tag } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
||||
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
||||
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
||||
|
||||
interface BookListProps {
|
||||
books: KomgaBook[];
|
||||
onBookClick: (book: KomgaBook) => void;
|
||||
books: NormalizedBook[];
|
||||
onBookClick: (book: NormalizedBook) => void;
|
||||
isCompact?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
interface BookListItemProps {
|
||||
book: KomgaBook;
|
||||
onBookClick: (book: KomgaBook) => void;
|
||||
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
|
||||
book: NormalizedBook;
|
||||
onBookClick: (book: NormalizedBook) => void;
|
||||
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
const isRead = book.readProgress?.completed || false;
|
||||
const hasReadProgress = book.readProgress !== null;
|
||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||
const totalPages = book.media.pagesCount;
|
||||
const totalPages = book.pageCount;
|
||||
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
||||
|
||||
const getStatusInfo = () => {
|
||||
@@ -52,7 +52,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
}
|
||||
|
||||
if (book.readProgress.completed) {
|
||||
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
|
||||
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
|
||||
return {
|
||||
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
||||
className: "bg-green-500/10 text-green-500",
|
||||
@@ -77,8 +77,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
const title =
|
||||
book.metadata.title ||
|
||||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
|
||||
book.title ||
|
||||
(book.number ? t("navigation.volume", { number: book.number }) : "");
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
@@ -130,8 +130,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
|
||||
{/* Métadonnées minimales */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{book.metadata.number && (
|
||||
<span>{t("navigation.volume", { number: book.metadata.number })}</span>
|
||||
{book.number && (
|
||||
<span>{t("navigation.volume", { number: book.number })}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
@@ -139,12 +139,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
|
||||
</span>
|
||||
</div>
|
||||
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1 hidden sm:flex">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{book.metadata.number && (
|
||||
{book.number && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("navigation.volume", { number: book.metadata.number })}
|
||||
{t("navigation.volume", { number: book.number })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Résumé */}
|
||||
{book.metadata.summary && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
|
||||
{book.metadata.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Métadonnées */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{/* Pages */}
|
||||
@@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Auteurs */}
|
||||
{book.metadata.authors && book.metadata.authors.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="line-clamp-1">
|
||||
{book.metadata.authors.map((a) => a.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date de sortie */}
|
||||
{book.metadata.releaseDate && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(book.metadata.releaseDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{book.metadata.tags && book.metadata.tags.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
<span className="line-clamp-1">
|
||||
{book.metadata.tags.slice(0, 3).join(", ")}
|
||||
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Barre de progression */}
|
||||
@@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
{!isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.media.pagesCount}
|
||||
pagesCount={book.pageCount}
|
||||
isRead={isRead}
|
||||
onSuccess={() => onSuccess(book, "read")}
|
||||
className="text-xs"
|
||||
@@ -311,7 +269,7 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
|
||||
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
|
||||
if (action === "read") {
|
||||
setLocalBooks(
|
||||
localBooks.map((previousBook) =>
|
||||
@@ -320,10 +278,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
|
||||
...previousBook,
|
||||
readProgress: {
|
||||
completed: true,
|
||||
page: previousBook.media.pagesCount,
|
||||
readDate: new Date().toISOString(),
|
||||
created: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
page: previousBook.pageCount,
|
||||
lastReadAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: previousBook
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||
@@ -16,7 +16,7 @@ import logger from "@/lib/logger";
|
||||
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
||||
|
||||
interface SeriesHeaderProps {
|
||||
series: KomgaSeries;
|
||||
series: NormalizedSeries;
|
||||
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
initialIsFavorite: boolean;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
window.dispatchEvent(event);
|
||||
toast({
|
||||
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
|
||||
description: series.metadata.title,
|
||||
description: series.name,
|
||||
});
|
||||
} else {
|
||||
throw new AppError(
|
||||
@@ -69,10 +69,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
};
|
||||
|
||||
const getReadingStatusInfo = () => {
|
||||
const { booksCount, booksReadCount, booksUnreadCount } = series;
|
||||
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
|
||||
const { bookCount, booksReadCount } = series;
|
||||
const booksUnreadCount = bookCount - booksReadCount;
|
||||
const booksInProgressCount = bookCount - (booksReadCount + booksUnreadCount);
|
||||
|
||||
if (booksReadCount === booksCount) {
|
||||
if (booksReadCount === bookCount) {
|
||||
return {
|
||||
label: t("series.header.status.read"),
|
||||
status: "success" as const,
|
||||
@@ -80,11 +81,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
};
|
||||
}
|
||||
|
||||
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) {
|
||||
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < bookCount)) {
|
||||
return {
|
||||
label: t("series.header.status.progress", {
|
||||
read: booksReadCount,
|
||||
total: booksCount,
|
||||
total: bookCount,
|
||||
}),
|
||||
status: "reading" as const,
|
||||
icon: BookOpen,
|
||||
@@ -105,8 +106,8 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
{/* Image de fond */}
|
||||
<div className="absolute inset-0">
|
||||
<SeriesCover
|
||||
series={series as KomgaSeries}
|
||||
alt={t("series.header.coverAlt", { title: series.metadata.title })}
|
||||
series={series}
|
||||
alt={t("series.header.coverAlt", { title: series.name })}
|
||||
className="blur-sm scale-105 brightness-50"
|
||||
showProgressUi={false}
|
||||
/>
|
||||
@@ -118,18 +119,18 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
{/* Image principale */}
|
||||
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0">
|
||||
<SeriesCover
|
||||
series={series as KomgaSeries}
|
||||
alt={t("series.header.coverAlt", { title: series.metadata.title })}
|
||||
series={series}
|
||||
alt={t("series.header.coverAlt", { title: series.name })}
|
||||
showProgressUi={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Informations */}
|
||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{series.metadata.title}</h1>
|
||||
{series.metadata.summary && (
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
||||
{series.summary && (
|
||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
||||
{series.metadata.summary}
|
||||
{series.summary}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
||||
@@ -137,9 +138,9 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
{statusInfo.label}
|
||||
</StatusBadge>
|
||||
<span className="text-sm text-white/80">
|
||||
{series.booksCount === 1
|
||||
? t("series.header.books", { count: series.booksCount })
|
||||
: t("series.header.books_plural", { count: series.booksCount })}
|
||||
{series.bookCount === 1
|
||||
? t("series.header.books", { count: series.bookCount })
|
||||
: t("series.header.books_plural", { count: series.bookCount })}
|
||||
</span>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Check } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SliderControl } from "@/components/ui/slider-control";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
import type { NormalizedLibrary } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface BackgroundSettingsProps {
|
||||
initialLibraries: KomgaLibrary[];
|
||||
initialLibraries: NormalizedLibrary[];
|
||||
}
|
||||
|
||||
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
|
||||
@@ -27,7 +27,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
|
||||
const { preferences, updatePreferences } = usePreferences();
|
||||
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
|
||||
const [komgaConfigValid, setKomgaConfigValid] = useState(false);
|
||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
||||
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
|
||||
preferences.background.komgaLibraries || []
|
||||
);
|
||||
@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
|
||||
htmlFor={`lib-${library.id}`}
|
||||
className="cursor-pointer font-normal text-sm"
|
||||
>
|
||||
{library.name} ({library.booksCount} livres)
|
||||
{library.name} ({library.bookCount} livres)
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
import type { NormalizedLibrary, ProviderType } from "@/lib/providers/types";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { DisplaySettings } from "./DisplaySettings";
|
||||
import { KomgaSettings } from "./KomgaSettings";
|
||||
import { StripstreamSettings } from "./StripstreamSettings";
|
||||
import { ProviderSelector } from "./ProviderSelector";
|
||||
import { BackgroundSettings } from "./BackgroundSettings";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { CacheSettings } from "./CacheSettings";
|
||||
@@ -14,12 +16,23 @@ import { Monitor, Network } from "lucide-react";
|
||||
|
||||
interface ClientSettingsProps {
|
||||
initialConfig: KomgaConfig | null;
|
||||
initialLibraries: KomgaLibrary[];
|
||||
initialLibraries: NormalizedLibrary[];
|
||||
stripstreamConfig?: { url?: string; hasToken: boolean } | null;
|
||||
providersStatus?: {
|
||||
komgaConfigured: boolean;
|
||||
stripstreamConfigured: boolean;
|
||||
activeProvider: ProviderType;
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab";
|
||||
|
||||
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
|
||||
export function ClientSettings({
|
||||
initialConfig,
|
||||
initialLibraries,
|
||||
stripstreamConfig,
|
||||
providersStatus,
|
||||
}: ClientSettingsProps) {
|
||||
const { t } = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
|
||||
|
||||
@@ -63,7 +76,18 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="connection" className="mt-6 space-y-6">
|
||||
{providersStatus && (
|
||||
<ProviderSelector
|
||||
activeProvider={providersStatus.activeProvider}
|
||||
komgaConfigured={providersStatus.komgaConfigured}
|
||||
stripstreamConfigured={providersStatus.stripstreamConfigured}
|
||||
/>
|
||||
)}
|
||||
<KomgaSettings initialConfig={initialConfig} />
|
||||
<StripstreamSettings
|
||||
initialUrl={stripstreamConfig?.url}
|
||||
hasToken={stripstreamConfig?.hasToken}
|
||||
/>
|
||||
<AdvancedSettings />
|
||||
<CacheSettings />
|
||||
</TabsContent>
|
||||
|
||||
113
src/components/settings/ProviderSelector.tsx
Normal file
113
src/components/settings/ProviderSelector.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { CheckCircle, Circle } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { setActiveProvider } from "@/app/actions/stripstream-config";
|
||||
import type { ProviderType } from "@/lib/providers/types";
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
activeProvider: ProviderType;
|
||||
komgaConfigured: boolean;
|
||||
stripstreamConfigured: boolean;
|
||||
}
|
||||
|
||||
const providers: { id: ProviderType; label: string; description: string }[] = [
|
||||
{
|
||||
id: "komga",
|
||||
label: "Komga",
|
||||
description: "Serveur de gestion de BD / manga (Basic Auth)",
|
||||
},
|
||||
{
|
||||
id: "stripstream",
|
||||
label: "Stripstream Librarian",
|
||||
description: "Serveur de gestion de BD / manga (Bearer Token)",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProviderSelector({
|
||||
activeProvider,
|
||||
komgaConfigured,
|
||||
stripstreamConfigured,
|
||||
}: ProviderSelectorProps) {
|
||||
const { toast } = useToast();
|
||||
const [current, setCurrent] = useState<ProviderType>(activeProvider);
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
|
||||
const isConfigured = (id: ProviderType) =>
|
||||
id === "komga" ? komgaConfigured : stripstreamConfigured;
|
||||
|
||||
const handleSelect = async (provider: ProviderType) => {
|
||||
if (provider === current) return;
|
||||
setIsChanging(true);
|
||||
try {
|
||||
const result = await setActiveProvider(provider);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
setCurrent(provider);
|
||||
toast({ title: "Provider actif", description: result.message });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur",
|
||||
description: error instanceof Error ? error.message : "Changement de provider échoué",
|
||||
});
|
||||
} finally {
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Provider actif</CardTitle>
|
||||
<CardDescription>
|
||||
Choisissez le serveur que l'application doit utiliser. Les deux configurations peuvent coexister.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{providers.map((provider) => {
|
||||
const active = current === provider.id;
|
||||
const configured = isConfigured(provider.id);
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
disabled={isChanging || !configured}
|
||||
onClick={() => handleSelect(provider.id)}
|
||||
className={cn(
|
||||
"relative flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50",
|
||||
(!configured || isChanging) && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{provider.label}</span>
|
||||
{active ? (
|
||||
<CheckCircle className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{provider.description}</p>
|
||||
{configured ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">✓ Configuré</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Non configuré</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
192
src/components/settings/StripstreamSettings.tsx
Normal file
192
src/components/settings/StripstreamSettings.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Network, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import logger from "@/lib/logger";
|
||||
import { saveStripstreamConfig, testStripstreamConnection } from "@/app/actions/stripstream-config";
|
||||
|
||||
interface StripstreamSettingsProps {
|
||||
initialUrl?: string;
|
||||
hasToken?: boolean;
|
||||
}
|
||||
|
||||
export function StripstreamSettings({ initialUrl, hasToken }: StripstreamSettingsProps) {
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [url, setUrl] = useState(initialUrl ?? "");
|
||||
const [token, setToken] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(!initialUrl);
|
||||
|
||||
const isConfigured = !!initialUrl;
|
||||
const shouldShowForm = !isConfigured || isEditing;
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await testStripstreamConnection(url.trim(), token);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
toast({ title: "Stripstream Librarian", description: result.message });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur test Stripstream:");
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur de connexion",
|
||||
description: error instanceof Error ? error.message : "Connexion échouée",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await saveStripstreamConfig(url.trim(), token);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
setIsEditing(false);
|
||||
setToken("");
|
||||
toast({ title: "Stripstream Librarian", description: result.message });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur sauvegarde Stripstream:");
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Erreur de sauvegarde",
|
||||
description: error instanceof Error ? error.message : "Erreur lors de la sauvegarde",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const btnSecondary =
|
||||
"flex-1 inline-flex items-center justify-center rounded-md bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
|
||||
const btnPrimary =
|
||||
"flex-1 inline-flex items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-3 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5" />
|
||||
Stripstream Librarian
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connectez votre instance Stripstream Librarian via token API (format{" "}
|
||||
<code className="text-xs">stl_...</code>).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!shouldShowForm ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">URL du serveur</label>
|
||||
<p className="text-sm text-muted-foreground">{url}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Token API</label>
|
||||
<p className="text-sm text-muted-foreground">{hasToken ? "••••••••" : "Non configuré"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="inline-flex items-center justify-center rounded-md bg-secondary/80 backdrop-blur-md px-3 py-2 text-sm font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="stripstream-url" className="text-sm font-medium">
|
||||
URL du serveur
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="stripstream-url"
|
||||
name="url"
|
||||
required
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://librarian.example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="stripstream-token" className="text-sm font-medium">
|
||||
Token API
|
||||
{isConfigured && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(laisser vide pour conserver l'actuel)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="stripstream-token"
|
||||
name="token"
|
||||
required={!isConfigured}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={isConfigured ? "••••••••" : "stl_xxxx_xxxxxxxx"}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" disabled={isSaving} className={btnPrimary}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
"Sauvegarder"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={isLoading || !url}
|
||||
className={btnSecondary}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Test...
|
||||
</>
|
||||
) : (
|
||||
"Tester"
|
||||
)}
|
||||
</button>
|
||||
{isConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setToken("");
|
||||
}}
|
||||
className={btnSecondary}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,18 @@
|
||||
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import type { BookCoverProps } from "./cover-utils";
|
||||
import { getImageUrl } from "@/lib/utils/image-url";
|
||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||
import { MarkAsReadButton } from "./mark-as-read-button";
|
||||
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
||||
import { BookOfflineButton } from "./book-offline-button";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
||||
const getReadingStatusInfo = (
|
||||
book: KomgaBook,
|
||||
book: BookCoverProps["book"],
|
||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||
) => {
|
||||
if (!book.readProgress) {
|
||||
@@ -26,7 +24,7 @@ const getReadingStatusInfo = (
|
||||
}
|
||||
|
||||
if (book.readProgress.completed) {
|
||||
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
|
||||
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
|
||||
return {
|
||||
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
||||
className: "bg-green-500/10 text-green-500",
|
||||
@@ -39,7 +37,7 @@ const getReadingStatusInfo = (
|
||||
return {
|
||||
label: t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.media.pagesCount,
|
||||
total: book.pageCount,
|
||||
}),
|
||||
className: "bg-blue-500/10 text-blue-500",
|
||||
};
|
||||
@@ -64,11 +62,10 @@ export function BookCover({
|
||||
const { t } = useTranslate();
|
||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||
|
||||
const imageUrl = getImageUrl("book", book.id);
|
||||
const isCompleted = book.readProgress?.completed || false;
|
||||
|
||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||
const totalPages = book.media.pagesCount;
|
||||
const totalPages = book.pageCount;
|
||||
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
||||
|
||||
const statusInfo = getReadingStatusInfo(book, t);
|
||||
@@ -90,7 +87,7 @@ export function BookCover({
|
||||
<>
|
||||
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
|
||||
<img
|
||||
src={imageUrl.trim()}
|
||||
src={book.thumbnailUrl.trim()}
|
||||
alt={alt || t("books.defaultCoverAlt")}
|
||||
loading="lazy"
|
||||
className={[
|
||||
@@ -121,7 +118,7 @@ export function BookCover({
|
||||
{!isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.media.pagesCount}
|
||||
pagesCount={book.pageCount}
|
||||
isRead={isRead}
|
||||
onSuccess={() => handleMarkAsRead()}
|
||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||
@@ -143,9 +140,9 @@ export function BookCover({
|
||||
{showOverlay && overlayVariant === "default" && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
|
||||
<p className="text-sm font-medium text-white text-left line-clamp-2">
|
||||
{book.metadata.title ||
|
||||
(book.metadata.number
|
||||
? t("navigation.volume", { number: book.metadata.number })
|
||||
{book.title ||
|
||||
(book.number
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -160,15 +157,15 @@ export function BookCover({
|
||||
{showOverlay && overlayVariant === "home" && (
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">
|
||||
{book.metadata.title ||
|
||||
(book.metadata.number
|
||||
? t("navigation.volume", { number: book.metadata.number })
|
||||
{book.title ||
|
||||
(book.number
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.media.pagesCount,
|
||||
total: book.pageCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import type { SeriesCoverProps } from "./cover-utils";
|
||||
import { getImageUrl } from "@/lib/utils/image-url";
|
||||
|
||||
export function SeriesCover({
|
||||
series,
|
||||
@@ -8,17 +7,16 @@ export function SeriesCover({
|
||||
className,
|
||||
showProgressUi = true,
|
||||
}: SeriesCoverProps) {
|
||||
const imageUrl = getImageUrl("series", series.id);
|
||||
const isCompleted = series.booksCount === series.booksReadCount;
|
||||
const isCompleted = series.bookCount === series.booksReadCount;
|
||||
|
||||
const readBooks = series.booksReadCount;
|
||||
const totalBooks = series.booksCount;
|
||||
const totalBooks = series.bookCount;
|
||||
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={series.thumbnailUrl}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={[
|
||||
|
||||
3
src/constants/cacheConstants.ts
Normal file
3
src/constants/cacheConstants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const HOME_CACHE_TAG = "home-data";
|
||||
export const LIBRARY_SERIES_CACHE_TAG = "library-series";
|
||||
export const SERIES_BOOKS_CACHE_TAG = "series-books";
|
||||
@@ -27,6 +27,11 @@ export const ERROR_CODES = {
|
||||
HTTP_ERROR: "KOMGA_HTTP_ERROR",
|
||||
SERVER_UNREACHABLE: "KOMGA_SERVER_UNREACHABLE",
|
||||
},
|
||||
STRIPSTREAM: {
|
||||
MISSING_CONFIG: "STRIPSTREAM_MISSING_CONFIG",
|
||||
CONNECTION_ERROR: "STRIPSTREAM_CONNECTION_ERROR",
|
||||
HTTP_ERROR: "STRIPSTREAM_HTTP_ERROR",
|
||||
},
|
||||
CONFIG: {
|
||||
SAVE_ERROR: "CONFIG_SAVE_ERROR",
|
||||
FETCH_ERROR: "CONFIG_FETCH_ERROR",
|
||||
|
||||
@@ -21,6 +21,11 @@ export const ERROR_MESSAGES: Record<string, string> = {
|
||||
[ERROR_CODES.AUTH.LOGOUT_ERROR]: "🚪 Error during logout",
|
||||
[ERROR_CODES.AUTH.REGISTRATION_FAILED]: "❌ Registration failed",
|
||||
|
||||
// Stripstream
|
||||
[ERROR_CODES.STRIPSTREAM.MISSING_CONFIG]: "⚙️ Stripstream Librarian configuration not found",
|
||||
[ERROR_CODES.STRIPSTREAM.CONNECTION_ERROR]: "🌐 Stripstream connection error",
|
||||
[ERROR_CODES.STRIPSTREAM.HTTP_ERROR]: "🌍 Stripstream HTTP Error: {status} {statusText}",
|
||||
|
||||
// Komga
|
||||
[ERROR_CODES.KOMGA.MISSING_CONFIG]: "⚙️ Komga configuration not found",
|
||||
[ERROR_CODES.KOMGA.MISSING_CREDENTIALS]: "🔑 Missing Komga credentials",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
55
src/lib/providers/komga/komga.adapter.ts
Normal file
55
src/lib/providers/komga/komga.adapter.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { KomgaBook, KomgaSeries, KomgaLibrary, ReadProgress } from "@/types/komga";
|
||||
import type {
|
||||
NormalizedBook,
|
||||
NormalizedSeries,
|
||||
NormalizedLibrary,
|
||||
NormalizedReadProgress,
|
||||
} from "../types";
|
||||
|
||||
export class KomgaAdapter {
|
||||
static toNormalizedReadProgress(rp: ReadProgress | null): NormalizedReadProgress | null {
|
||||
if (!rp) return null;
|
||||
return {
|
||||
page: rp.page ?? null,
|
||||
completed: rp.completed,
|
||||
lastReadAt: rp.readDate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBook(book: KomgaBook): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.libraryId,
|
||||
title: book.metadata?.title || book.name,
|
||||
number: book.metadata?.number ?? null,
|
||||
seriesId: book.seriesId ?? null,
|
||||
volume: typeof book.number === "number" ? book.number : null,
|
||||
pageCount: book.media?.pagesCount ?? 0,
|
||||
thumbnailUrl: `/api/komga/images/books/${book.id}/thumbnail`,
|
||||
readProgress: KomgaAdapter.toNormalizedReadProgress(book.readProgress),
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedSeries(series: KomgaSeries): NormalizedSeries {
|
||||
return {
|
||||
id: series.id,
|
||||
name: series.metadata?.title ?? series.name,
|
||||
bookCount: series.booksCount,
|
||||
booksReadCount: series.booksReadCount,
|
||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||
summary: series.metadata?.summary ?? null,
|
||||
authors: series.booksMetadata?.authors ?? [],
|
||||
genres: series.metadata?.genres ?? [],
|
||||
tags: series.metadata?.tags ?? [],
|
||||
createdAt: series.created ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedLibrary(library: KomgaLibrary): NormalizedLibrary {
|
||||
return {
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
bookCount: library.booksCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
507
src/lib/providers/komga/komga.provider.ts
Normal file
507
src/lib/providers/komga/komga.provider.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type { IMediaProvider, BookListFilter } from "../provider.interface";
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "../types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { KomgaAdapter } from "./komga.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type { KomgaBook, KomgaSeries, KomgaLibrary } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import logger from "@/lib/logger";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
|
||||
const CACHE_TTL_LONG = 300;
|
||||
const CACHE_TTL_MED = 120;
|
||||
const CACHE_TTL_SHORT = 30;
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
export class KomgaProvider implements IMediaProvider {
|
||||
private config: AuthConfig;
|
||||
|
||||
constructor(url: string, authHeader: string) {
|
||||
this.config = { serverUrl: url, authHeader };
|
||||
}
|
||||
|
||||
private buildUrl(path: string, params?: Record<string, string | string[]>): string {
|
||||
const url = new URL(`${this.config.serverUrl}/api/v1/${path}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((val) => url.searchParams.append(k, val));
|
||||
} else {
|
||||
url.searchParams.append(k, v);
|
||||
}
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private getHeaders(extra: Record<string, string> = {}): Headers {
|
||||
return new Headers({
|
||||
Authorization: `Basic ${this.config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetch<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | string[]>,
|
||||
options: RequestInit & { revalidate?: number; tags?: string[] } = {}
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(path, params);
|
||||
const headers = this.getHeaders(options.body ? { "Content-Type": "application/json" } : {});
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined;
|
||||
|
||||
const fetchOptions = {
|
||||
headers,
|
||||
...options,
|
||||
next: nextOptions,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
interface FetchErrorLike {
|
||||
code?: string;
|
||||
cause?: { code?: string };
|
||||
}
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
return await fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (err: unknown) {
|
||||
const e = err as FetchErrorLike;
|
||||
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}, retrying...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await doFetch();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(
|
||||
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
|
||||
"🟢 Komga Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, status: response.status, statusText: response.statusText },
|
||||
"🔴 Komga Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration: `${Date.now() - startTime}ms`,
|
||||
},
|
||||
"🔴 Komga Request Failed"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async getLibraries(): Promise<NormalizedLibrary[]> {
|
||||
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
// Enrich with book counts
|
||||
const enriched = await Promise.all(
|
||||
raw.map(async (lib) => {
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return { ...lib, booksCount: resp.totalElements, booksReadCount: 0 } as KomgaLibrary;
|
||||
} catch {
|
||||
return { ...lib, booksCount: 0, booksReadCount: 0 } as KomgaLibrary;
|
||||
}
|
||||
})
|
||||
);
|
||||
return enriched.map(KomgaAdapter.toNormalizedLibrary);
|
||||
}
|
||||
|
||||
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
|
||||
const page = cursor ? parseInt(cursor, 10) - 1 : 0;
|
||||
|
||||
let condition: KomgaCondition;
|
||||
if (unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ libraryId: { operator: "is", value: libraryId } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||
}
|
||||
|
||||
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
|
||||
if (search) searchBody.fullTextSearch = search;
|
||||
|
||||
const response = await this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: String(page), size: String(limit), sort: "metadata.titleSort,asc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: CACHE_TTL_MED,
|
||||
tags: [LIBRARY_SERIES_CACHE_TAG],
|
||||
}
|
||||
);
|
||||
|
||||
const filtered = response.content.filter((s) => !s.deleted);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const ta = a.metadata?.titleSort ?? "";
|
||||
const tb = b.metadata?.titleSort ?? "";
|
||||
const cmp = ta.localeCompare(tb);
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return {
|
||||
items: sorted.map(KomgaAdapter.toNormalizedSeries),
|
||||
nextCursor: response.last ? null : String(page + 1),
|
||||
totalPages: response.totalPages,
|
||||
totalElements: response.totalElements,
|
||||
};
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const page = filter.cursor ? parseInt(filter.cursor, 10) - 1 : 0;
|
||||
const limit = filter.limit ?? 24;
|
||||
let condition: KomgaCondition;
|
||||
|
||||
if (filter.seriesName && filter.unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ seriesId: { operator: "is", value: filter.seriesName } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (filter.seriesName) {
|
||||
condition = { seriesId: { operator: "is", value: filter.seriesName } };
|
||||
} else if (filter.libraryId) {
|
||||
condition = { libraryId: { operator: "is", value: filter.libraryId } };
|
||||
} else {
|
||||
condition = {};
|
||||
}
|
||||
|
||||
const response = await this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: String(page), size: String(limit), sort: "metadata.numberSort,asc" },
|
||||
{ method: "POST", body: JSON.stringify({ condition }), revalidate: CACHE_TTL_MED, tags: [SERIES_BOOKS_CACHE_TAG] }
|
||||
);
|
||||
const items = response.content.filter((b) => !b.deleted).map(KomgaAdapter.toNormalizedBook);
|
||||
return {
|
||||
items,
|
||||
nextCursor: response.last ? null : String(page + 1),
|
||||
totalPages: response.totalPages,
|
||||
totalElements: response.totalElements,
|
||||
};
|
||||
}
|
||||
|
||||
async getBook(bookId: string): Promise<NormalizedBook> {
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetch<KomgaBook>(`books/${bookId}`, undefined, { revalidate: CACHE_TTL_SHORT }),
|
||||
this.fetch<{ number: number }[]>(`books/${bookId}/pages`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
}),
|
||||
]);
|
||||
const normalized = KomgaAdapter.toNormalizedBook(book);
|
||||
return { ...normalized, pageCount: pages.length };
|
||||
}
|
||||
|
||||
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
|
||||
const series = await this.fetch<KomgaSeries>(`series/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
return KomgaAdapter.toNormalizedSeries(series);
|
||||
}
|
||||
|
||||
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
|
||||
const book = await this.fetch<KomgaBook>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
return KomgaAdapter.toNormalizedReadProgress(book.readProgress);
|
||||
}
|
||||
|
||||
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
|
||||
const url = this.buildUrl(`books/${bookId}/read-progress`);
|
||||
const headers = this.getHeaders({ "Content-Type": "application/json" });
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ page: page ?? 0, completed }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const body = { fullTextSearch: trimmed };
|
||||
const [seriesResp, booksResp] = await Promise.all([
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: "0", size: String(limit) },
|
||||
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
|
||||
),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: "0", size: String(limit) },
|
||||
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
|
||||
),
|
||||
]);
|
||||
|
||||
const results: NormalizedSearchResult[] = [
|
||||
...seriesResp.content
|
||||
.filter((s) => !s.deleted)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.metadata?.title ?? s.name,
|
||||
href: `/series/${s.id}`,
|
||||
coverUrl: `/api/komga/images/series/${s.id}/thumbnail`,
|
||||
type: "series" as const,
|
||||
bookCount: s.booksCount,
|
||||
})),
|
||||
...booksResp.content
|
||||
.filter((b) => !b.deleted)
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.metadata?.title ?? b.name,
|
||||
seriesTitle: b.seriesTitle,
|
||||
seriesId: b.seriesId,
|
||||
href: `/books/${b.id}`,
|
||||
coverUrl: `/api/komga/images/books/${b.id}/thumbnail`,
|
||||
type: "book" as const,
|
||||
})),
|
||||
];
|
||||
return results;
|
||||
}
|
||||
|
||||
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
|
||||
try {
|
||||
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return KomgaAdapter.toNormalizedLibrary({
|
||||
...lib,
|
||||
booksCount: resp.totalElements,
|
||||
booksReadCount: 0,
|
||||
});
|
||||
} catch {
|
||||
return KomgaAdapter.toNormalizedLibrary({ ...lib, booksCount: 0, booksReadCount: 0 });
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
|
||||
try {
|
||||
const book = await this.fetch<KomgaBook>(`books/${bookId}/next`);
|
||||
return KomgaAdapter.toNormalizedBook(book);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AppError &&
|
||||
(error as AppError & { params?: { status?: number } }).params?.status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: "0", size: "10", sort: "readDate,desc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: "0", size: "10", sort: "readProgress.readDate,desc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/ondeck",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
]);
|
||||
return {
|
||||
ongoing: (ongoing.content || []).map(KomgaAdapter.toNormalizedSeries),
|
||||
ongoingBooks: (ongoingBooks.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
recentlyRead: (recentlyRead.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
onDeck: (onDeck.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
latestSeries: (latestSeries.content || []).map(KomgaAdapter.toNormalizedSeries),
|
||||
};
|
||||
}
|
||||
|
||||
async resetReadProgress(bookId: string): Promise<void> {
|
||||
const url = this.buildUrl(`books/${bookId}/read-progress`);
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(url, { method: "DELETE", headers });
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async scanLibrary(libraryId: string): Promise<void> {
|
||||
const url = this.buildUrl(`libraries/${libraryId}/scan`);
|
||||
const headers = this.getHeaders();
|
||||
await fetch(url, { method: "POST", headers });
|
||||
}
|
||||
|
||||
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
|
||||
try {
|
||||
const libraryId = libraryIds?.length
|
||||
? libraryIds[Math.floor(Math.random() * libraryIds.length)]
|
||||
: undefined;
|
||||
const condition: KomgaCondition = libraryId
|
||||
? { libraryId: { operator: "is", value: libraryId } }
|
||||
: {};
|
||||
const randomPage = Math.floor(Math.random() * 5);
|
||||
const response = await this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
|
||||
{ method: "POST", body: JSON.stringify({ condition }) }
|
||||
);
|
||||
const books = response.content.filter((b) => !b.deleted);
|
||||
if (!books.length) return null;
|
||||
return books[Math.floor(Math.random() * books.length)].id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await this.fetch<KomgaLibrary[]>("libraries");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
|
||||
}
|
||||
}
|
||||
|
||||
getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
}
|
||||
52
src/lib/providers/provider.factory.ts
Normal file
52
src/lib/providers/provider.factory.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import type { IMediaProvider } from "./provider.interface";
|
||||
|
||||
export async function getProvider(): Promise<IMediaProvider | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
activeProvider: true,
|
||||
config: { select: { url: true, authHeader: true } },
|
||||
stripstreamConfig: { select: { url: true, token: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser) return null;
|
||||
|
||||
const activeProvider = dbUser.activeProvider ?? "komga";
|
||||
|
||||
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
|
||||
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
||||
return new StripstreamProvider(
|
||||
dbUser.stripstreamConfig.url,
|
||||
dbUser.stripstreamConfig.token
|
||||
);
|
||||
}
|
||||
|
||||
if (activeProvider === "komga" || !dbUser.activeProvider) {
|
||||
if (!dbUser.config) return null;
|
||||
const { KomgaProvider } = await import("./komga/komga.provider");
|
||||
return new KomgaProvider(dbUser.config.url, dbUser.config.authHeader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getActiveProviderType(): Promise<string | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
|
||||
return dbUser?.activeProvider ?? "komga";
|
||||
}
|
||||
54
src/lib/providers/provider.interface.ts
Normal file
54
src/lib/providers/provider.interface.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "./types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
|
||||
export interface BookListFilter {
|
||||
libraryId?: string;
|
||||
seriesName?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IMediaProvider {
|
||||
// ── Collections ─────────────────────────────────────────────────────────
|
||||
getLibraries(): Promise<NormalizedLibrary[]>;
|
||||
getLibraryById(libraryId: string): Promise<NormalizedLibrary | null>;
|
||||
|
||||
getSeries(libraryId: string, cursor?: string, limit?: number, unreadOnly?: boolean, search?: string): Promise<NormalizedSeriesPage>;
|
||||
getSeriesById(seriesId: string): Promise<NormalizedSeries | null>;
|
||||
|
||||
getBooks(filter: BookListFilter): Promise<NormalizedBooksPage>;
|
||||
getBook(bookId: string): Promise<NormalizedBook>;
|
||||
getNextBook(bookId: string): Promise<NormalizedBook | null>;
|
||||
|
||||
// ── Home ─────────────────────────────────────────────────────────────────
|
||||
getHomeData(): Promise<HomeData>;
|
||||
|
||||
// ── Read progress ────────────────────────────────────────────────────────
|
||||
getReadProgress(bookId: string): Promise<NormalizedReadProgress | null>;
|
||||
saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void>;
|
||||
resetReadProgress(bookId: string): Promise<void>;
|
||||
|
||||
// ── Admin / utility ──────────────────────────────────────────────────────
|
||||
scanLibrary(libraryId: string): Promise<void>;
|
||||
getRandomBook(libraryIds?: string[]): Promise<string | null>;
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────────
|
||||
search(query: string, limit?: number): Promise<NormalizedSearchResult[]>;
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────────
|
||||
testConnection(): Promise<{ ok: boolean; error?: string }>;
|
||||
|
||||
// ── URL builders (return local proxy URLs) ───────────────────────────────
|
||||
getBookThumbnailUrl(bookId: string): string;
|
||||
getSeriesThumbnailUrl(seriesId: string): string;
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string;
|
||||
}
|
||||
85
src/lib/providers/stripstream/stripstream.adapter.ts
Normal file
85
src/lib/providers/stripstream/stripstream.adapter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
StripstreamBookItem,
|
||||
StripstreamBookDetails,
|
||||
StripstreamSeriesItem,
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamReadingProgressResponse,
|
||||
} from "@/types/stripstream";
|
||||
import type {
|
||||
NormalizedBook,
|
||||
NormalizedSeries,
|
||||
NormalizedLibrary,
|
||||
NormalizedReadProgress,
|
||||
} from "../types";
|
||||
|
||||
export class StripstreamAdapter {
|
||||
static toNormalizedReadProgress(
|
||||
rp: StripstreamReadingProgressResponse | null
|
||||
): NormalizedReadProgress | null {
|
||||
if (!rp) return null;
|
||||
return {
|
||||
page: rp.current_page ?? null,
|
||||
completed: rp.status === "read",
|
||||
lastReadAt: rp.last_read_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBook(book: StripstreamBookItem): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.library_id,
|
||||
title: book.title,
|
||||
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
|
||||
seriesId: book.series ?? null,
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBookDetails(book: StripstreamBookDetails): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.library_id,
|
||||
title: book.title,
|
||||
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
|
||||
seriesId: book.series ?? null,
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedSeries(series: StripstreamSeriesItem): NormalizedSeries {
|
||||
return {
|
||||
id: series.first_book_id,
|
||||
name: series.name,
|
||||
bookCount: series.book_count,
|
||||
booksReadCount: series.books_read_count,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedLibrary(library: StripstreamLibraryResponse): NormalizedLibrary {
|
||||
return {
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
bookCount: library.book_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
161
src/lib/providers/stripstream/stripstream.client.ts
Normal file
161
src/lib/providers/stripstream/stripstream.client.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
interface FetchErrorLike { code?: string; cause?: { code?: string } }
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
revalidate?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class StripstreamClient {
|
||||
private baseUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
// Trim trailing slash
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private getHeaders(extra: Record<string, string> = {}): Headers {
|
||||
return new Headers({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
buildUrl(path: string, params?: Record<string, string | undefined>): string {
|
||||
const url = new URL(`${this.baseUrl}/${path}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined) url.searchParams.append(k, v);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async fetch<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | undefined>,
|
||||
options: FetchOptions = {}
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(path, params);
|
||||
const headers = this.getHeaders(
|
||||
options.body ? { "Content-Type": "application/json" } : {}
|
||||
);
|
||||
|
||||
const isDebug = process.env.STRIPSTREAM_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
|
||||
"🔵 Stripstream Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined;
|
||||
|
||||
const fetchOptions = {
|
||||
headers,
|
||||
...options,
|
||||
next: nextOptions,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
return await fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (err: unknown) {
|
||||
const e = err as FetchErrorLike;
|
||||
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}, retrying...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await doFetch();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(
|
||||
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
|
||||
"🟢 Stripstream Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, status: response.status, statusText: response.statusText },
|
||||
"🔴 Stripstream Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, error: error instanceof Error ? error.message : String(error), duration: `${Date.now() - startTime}ms` },
|
||||
"🔴 Stripstream Request Failed"
|
||||
);
|
||||
}
|
||||
if (error instanceof AppError) throw error;
|
||||
logger.error({ err: error, url }, "Stripstream request failed");
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.CONNECTION_ERROR, {}, error);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchImage(path: string): Promise<Response> {
|
||||
const url = this.buildUrl(path);
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "image/webp, image/jpeg, image/png, */*",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
327
src/lib/providers/stripstream/stripstream.provider.ts
Normal file
327
src/lib/providers/stripstream/stripstream.provider.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type { IMediaProvider, BookListFilter } from "../provider.interface";
|
||||
import logger from "@/lib/logger";
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "../types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { StripstreamClient } from "./stripstream.client";
|
||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type {
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamBooksPage,
|
||||
StripstreamSeriesPage,
|
||||
StripstreamBookDetails,
|
||||
StripstreamReadingProgressResponse,
|
||||
StripstreamSearchResponse,
|
||||
} from "@/types/stripstream";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
const CACHE_TTL_LONG = 300;
|
||||
const CACHE_TTL_MED = 120;
|
||||
const CACHE_TTL_SHORT = 30;
|
||||
|
||||
export class StripstreamProvider implements IMediaProvider {
|
||||
private client: StripstreamClient;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.client = new StripstreamClient(url, token);
|
||||
}
|
||||
|
||||
async getLibraries(): Promise<NormalizedLibrary[]> {
|
||||
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
return libraries.map(StripstreamAdapter.toNormalizedLibrary);
|
||||
}
|
||||
|
||||
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
|
||||
try {
|
||||
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
const lib = libraries.find((l) => l.id === libraryId);
|
||||
return lib ? StripstreamAdapter.toNormalizedLibrary(lib) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Stripstream series endpoint: GET /libraries/{library_id}/series
|
||||
async getSeries(libraryId: string, page?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
|
||||
const pageNumber = page ? parseInt(page) : 1;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit), page: String(pageNumber) };
|
||||
if (unreadOnly) params.reading_status = "unread,reading";
|
||||
if (search?.trim()) params.q = search.trim();
|
||||
|
||||
const response = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraryId}/series`,
|
||||
params,
|
||||
{ revalidate: CACHE_TTL_MED, tags: [LIBRARY_SERIES_CACHE_TAG] }
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(response.total / limit);
|
||||
return {
|
||||
items: response.items.map(StripstreamAdapter.toNormalizedSeries),
|
||||
nextCursor: null,
|
||||
totalElements: response.total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
|
||||
// seriesId can be either a first_book_id (from series cards) or a series name (from book.seriesId).
|
||||
// Try first_book_id first; fall back to series name search.
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
if (!book.series) return null;
|
||||
return {
|
||||
id: seriesId,
|
||||
name: book.series,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
} catch {
|
||||
// Fall back: treat seriesId as a series name, find its first book
|
||||
try {
|
||||
const page = await this.client.fetch<StripstreamBooksPage>(
|
||||
"books",
|
||||
{ series: seriesId, limit: "1" },
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
if (!page.items.length) return null;
|
||||
const firstBook = page.items[0];
|
||||
return {
|
||||
id: firstBook.id,
|
||||
name: seriesId,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const limit = filter.limit ?? 24;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||
|
||||
if (filter.seriesName) {
|
||||
// seriesName is first_book_id for Stripstream — resolve to actual series name
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(
|
||||
`books/${filter.seriesName}`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
params.series = book.series ?? filter.seriesName;
|
||||
} catch {
|
||||
params.series = filter.seriesName;
|
||||
}
|
||||
} else if (filter.libraryId) {
|
||||
params.library_id = filter.libraryId;
|
||||
}
|
||||
|
||||
if (filter.unreadOnly) params.reading_status = "unread,reading";
|
||||
const pageNumber = filter.cursor ? parseInt(filter.cursor) : 1;
|
||||
params.page = String(pageNumber);
|
||||
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", params, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
tags: [SERIES_BOOKS_CACHE_TAG],
|
||||
});
|
||||
|
||||
const pageSize = filter.limit ?? 24;
|
||||
const totalPages = Math.ceil(response.total / pageSize);
|
||||
return {
|
||||
items: response.items.map(StripstreamAdapter.toNormalizedBook),
|
||||
nextCursor: null,
|
||||
totalElements: response.total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async getBook(bookId: string): Promise<NormalizedBook> {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
return StripstreamAdapter.toNormalizedBookDetails(book);
|
||||
}
|
||||
|
||||
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
if (!book.series || book.volume == null) return null;
|
||||
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", {
|
||||
series: book.series,
|
||||
limit: "200",
|
||||
}, { revalidate: CACHE_TTL_SHORT });
|
||||
|
||||
const sorted = response.items
|
||||
.filter((b) => b.volume != null)
|
||||
.sort((a, b) => (a.volume ?? 0) - (b.volume ?? 0));
|
||||
|
||||
const idx = sorted.findIndex((b) => b.id === bookId);
|
||||
if (idx === -1 || idx === sorted.length - 1) return null;
|
||||
return StripstreamAdapter.toNormalizedBook(sorted[idx + 1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
// Stripstream has no "in-progress" filter — show recent books and first library's series
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
const [booksPage, libraries] = await Promise.allSettled([
|
||||
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts),
|
||||
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }),
|
||||
]);
|
||||
|
||||
const books = booksPage.status === "fulfilled"
|
||||
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
|
||||
: [];
|
||||
|
||||
let latestSeries: NormalizedSeries[] = [];
|
||||
if (libraries.status === "fulfilled" && libraries.value.length > 0) {
|
||||
try {
|
||||
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraries.value[0].id}/series`,
|
||||
{ limit: "10" },
|
||||
homeOpts
|
||||
);
|
||||
latestSeries = seriesPage.items.map(StripstreamAdapter.toNormalizedSeries);
|
||||
} catch {
|
||||
latestSeries = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ongoing: latestSeries,
|
||||
ongoingBooks: books,
|
||||
recentlyRead: books,
|
||||
onDeck: [],
|
||||
latestSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
|
||||
const progress = await this.client.fetch<StripstreamReadingProgressResponse>(
|
||||
`books/${bookId}/progress`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_SHORT }
|
||||
);
|
||||
return StripstreamAdapter.toNormalizedReadProgress(progress);
|
||||
}
|
||||
|
||||
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
|
||||
const status = completed ? "read" : page !== null && page > 0 ? "reading" : "unread";
|
||||
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, current_page: page }),
|
||||
});
|
||||
}
|
||||
|
||||
async resetReadProgress(bookId: string): Promise<void> {
|
||||
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "unread", current_page: null }),
|
||||
});
|
||||
}
|
||||
|
||||
async scanLibrary(libraryId: string): Promise<void> {
|
||||
await this.client.fetch<unknown>(`libraries/${libraryId}/scan`, undefined, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
|
||||
try {
|
||||
const params: Record<string, string | undefined> = { limit: "50" };
|
||||
if (libraryIds?.length) {
|
||||
params.library_id = libraryIds[Math.floor(Math.random() * libraryIds.length)];
|
||||
}
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", params);
|
||||
if (!response.items.length) return null;
|
||||
return response.items[Math.floor(Math.random() * response.items.length)].id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const response = await this.client.fetch<StripstreamSearchResponse>("search", {
|
||||
q: trimmed,
|
||||
limit: String(limit),
|
||||
}, { revalidate: CACHE_TTL_SHORT });
|
||||
|
||||
const seriesResults: NormalizedSearchResult[] = response.series_hits.map((s) => ({
|
||||
id: s.first_book_id,
|
||||
title: s.name,
|
||||
href: `/series/${s.first_book_id}`,
|
||||
coverUrl: `/api/stripstream/images/books/${s.first_book_id}/thumbnail`,
|
||||
type: "series" as const,
|
||||
bookCount: s.book_count,
|
||||
}));
|
||||
|
||||
const bookResults: NormalizedSearchResult[] = response.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
seriesTitle: hit.series ?? null,
|
||||
seriesId: hit.series ?? null,
|
||||
href: `/books/${hit.id}`,
|
||||
coverUrl: `/api/stripstream/images/books/${hit.id}/thumbnail`,
|
||||
type: "book" as const,
|
||||
}));
|
||||
|
||||
return [...seriesResults, ...bookResults];
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await this.client.fetch<StripstreamLibraryResponse[]>("libraries");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
|
||||
}
|
||||
}
|
||||
|
||||
getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/stripstream/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/stripstream/images/books/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/stripstream/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
}
|
||||
64
src/lib/providers/types.ts
Normal file
64
src/lib/providers/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type ProviderType = "komga" | "stripstream";
|
||||
|
||||
export interface NormalizedReadProgress {
|
||||
page: number | null;
|
||||
completed: boolean;
|
||||
lastReadAt: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
export interface NormalizedSeries {
|
||||
id: string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
booksReadCount: number;
|
||||
thumbnailUrl: string;
|
||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||
summary?: string | null;
|
||||
authors?: Array<{ name: string; role: string }>;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedBook {
|
||||
id: string;
|
||||
libraryId: string;
|
||||
title: string;
|
||||
number: string | null;
|
||||
seriesId: string | null;
|
||||
volume: number | null;
|
||||
pageCount: number;
|
||||
thumbnailUrl: string;
|
||||
readProgress: NormalizedReadProgress | null;
|
||||
}
|
||||
|
||||
export interface NormalizedSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
seriesTitle?: string | null;
|
||||
seriesId?: string | null;
|
||||
href: string;
|
||||
coverUrl: string;
|
||||
type: "series" | "book";
|
||||
bookCount?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedSeriesPage {
|
||||
items: NormalizedSeries[];
|
||||
nextCursor: string | null;
|
||||
totalPages?: number;
|
||||
totalElements?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedBooksPage {
|
||||
items: NormalizedBook[];
|
||||
nextCursor: string | null;
|
||||
totalPages?: number;
|
||||
totalElements?: number;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface KomgaRequestInit extends RequestInit {
|
||||
isImage?: boolean;
|
||||
noJson?: boolean;
|
||||
/** Next.js cache duration in seconds. Use false to disable cache, number for TTL */
|
||||
revalidate?: number | false;
|
||||
/** Cache tags for targeted invalidation */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface KomgaUrlBuilder {
|
||||
path: string;
|
||||
params?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
interface FetchErrorLike {
|
||||
code?: string;
|
||||
cause?: {
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class BaseApiService {
|
||||
protected static async getKomgaConfig(): Promise<AuthConfig> {
|
||||
try {
|
||||
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||
if (!config) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: config.url,
|
||||
authHeader: config.authHeader,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration");
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
protected static getAuthHeaders(config: AuthConfig): Headers {
|
||||
if (!config.authHeader) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CREDENTIALS);
|
||||
}
|
||||
|
||||
return new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
protected static buildUrl(
|
||||
config: AuthConfig,
|
||||
path: string,
|
||||
params?: Record<string, string | string[]>
|
||||
): string {
|
||||
const url = new URL(`${config.serverUrl}/api/v1/${path}`);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
if (v !== undefined) {
|
||||
url.searchParams.append(key, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected static async fetchFromApi<T>(
|
||||
urlBuilder: KomgaUrlBuilder,
|
||||
headersOptions = {},
|
||||
options: KomgaRequestInit = {}
|
||||
): Promise<T> {
|
||||
const config: AuthConfig = await this.getKomgaConfig();
|
||||
const { path, params } = urlBuilder;
|
||||
const url = this.buildUrl(config, path, params);
|
||||
|
||||
const headers: Headers = this.getAuthHeaders(config);
|
||||
if (headersOptions) {
|
||||
for (const [key, value] of Object.entries(headersOptions)) {
|
||||
headers.set(key as string, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug || isCacheDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
params,
|
||||
isImage: options.isImage,
|
||||
noJson: options.noJson,
|
||||
revalidate: options.revalidate,
|
||||
},
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
cache: "enabled",
|
||||
ttl: options.revalidate,
|
||||
},
|
||||
"💾 Cache enabled"
|
||||
);
|
||||
}
|
||||
|
||||
// Timeout de 15 secondes pour éviter les blocages longs
|
||||
const timeoutMs = 15000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options not in standard fetch types
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
const normalizedError = fetchError as FetchErrorLike;
|
||||
// Gestion spécifique des erreurs DNS
|
||||
if (normalizedError.cause?.code === "EAI_AGAIN" || normalizedError.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Force IPv4 si IPv6 pose problème
|
||||
family: 4,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else if (normalizedError.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
duration: `${duration}ms`,
|
||||
ok: response.ok,
|
||||
},
|
||||
"🟢 Komga Response"
|
||||
);
|
||||
}
|
||||
|
||||
// Log potential cache hit/miss based on response time
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
// Fast response (< 50ms) is likely a cache hit
|
||||
if (duration < 50) {
|
||||
logger.info({ url, duration: `${duration}ms` }, "⚡ Cache HIT (fast response)");
|
||||
} else {
|
||||
logger.info({ url, duration: `${duration}ms` }, "🔄 Cache MISS (slow response)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
"🔴 Komga Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.isImage) {
|
||||
return response as T;
|
||||
}
|
||||
|
||||
if (options.noJson) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
"🔴 Komga Request Failed"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,12 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
type ErrorWithStatusParams = AppError & { params?: { status?: number } };
|
||||
|
||||
export class BookService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 60; // 1 minute
|
||||
|
||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||
try {
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<{ number: number }[]>(
|
||||
{ path: `books/${bookId}/pages` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
book,
|
||||
pages: pages.map((page) => page.number),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
||||
try {
|
||||
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant
|
||||
return await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}/next` });
|
||||
} catch (error) {
|
||||
// Si le livre suivant n'existe pas, Komga retourne 404
|
||||
// On retourne null dans ce cas
|
||||
if (
|
||||
error instanceof AppError &&
|
||||
error.code === ERROR_CODES.KOMGA.HTTP_ERROR &&
|
||||
(error as ErrorWithStatusParams).params?.status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Pour les autres erreurs, on les propage
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||
try {
|
||||
const book = await this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return book.seriesId;
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateReadProgress(
|
||||
bookId: string,
|
||||
page: number,
|
||||
completed: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await this.getKomgaConfig();
|
||||
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
|
||||
const headers = this.getAuthHeaders(config);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ page, completed }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteReadProgress(bookId: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.getKomgaConfig();
|
||||
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
|
||||
const headers = this.getAuthHeaders(config);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
export class BookService {
|
||||
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
|
||||
try {
|
||||
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
||||
const adjustedPageNumber = pageNumber - 1;
|
||||
// Stream directement sans buffer en mémoire
|
||||
return ImageService.streamImage(
|
||||
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
||||
);
|
||||
@@ -133,32 +17,18 @@ export class BookService extends BaseApiService {
|
||||
|
||||
static async getCover(bookId: string): Promise<Response> {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||
if (preferences.showThumbnails) {
|
||||
return ImageService.streamImage(`books/${bookId}/thumbnail`);
|
||||
}
|
||||
|
||||
// Sinon, récupérer la première page (streaming)
|
||||
return this.getPage(bookId, 1);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
|
||||
static getPageThumbnailUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
||||
try {
|
||||
// Stream directement sans buffer en mémoire
|
||||
return ImageService.streamImage(
|
||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
||||
);
|
||||
@@ -166,78 +36,4 @@ export class BookService extends BaseApiService {
|
||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getCoverUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getRandomBookFromLibraries(libraryIds: string[]): Promise<string> {
|
||||
try {
|
||||
if (libraryIds.length === 0) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, {
|
||||
message: "Aucune bibliothèque sélectionnée",
|
||||
});
|
||||
}
|
||||
|
||||
// Use books/list directly with library filter to avoid extra series/list call
|
||||
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
||||
const randomLibraryId = libraryIds[randomLibraryIndex];
|
||||
|
||||
// Random page offset for variety (assuming most libraries have at least 100 books)
|
||||
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
|
||||
|
||||
const searchBody = {
|
||||
condition: {
|
||||
libraryId: {
|
||||
operator: "is",
|
||||
value: randomLibraryId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const booksResponse = await this.fetchFromApi<{
|
||||
content: KomgaBook[];
|
||||
totalElements: number;
|
||||
}>(
|
||||
{
|
||||
path: "books/list",
|
||||
params: { page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
|
||||
},
|
||||
{ "Content-Type": "application/json" },
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
);
|
||||
|
||||
if (booksResponse.content.length === 0) {
|
||||
// Fallback to page 0 if random page was empty
|
||||
const fallbackResponse = await this.fetchFromApi<{
|
||||
content: KomgaBook[];
|
||||
totalElements: number;
|
||||
}>(
|
||||
{
|
||||
path: "books/list",
|
||||
params: { page: "0", size: "20", sort: "metadata.numberSort,asc" },
|
||||
},
|
||||
{ "Content-Type": "application/json" },
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
);
|
||||
|
||||
if (fallbackResponse.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
||||
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
|
||||
});
|
||||
}
|
||||
|
||||
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
|
||||
return fallbackResponse.content[randomBookIndex].id;
|
||||
}
|
||||
|
||||
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
||||
return booksResponse.content[randomBookIndex].id;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -9,7 +9,6 @@ export class FavoriteService {
|
||||
private static readonly FAVORITES_CHANGE_EVENT = "favoritesChanged";
|
||||
|
||||
private static dispatchFavoritesChanged() {
|
||||
// Dispatch l'événement pour notifier les changements
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(FavoriteService.FAVORITES_CHANGE_EVENT));
|
||||
}
|
||||
@@ -23,19 +22,26 @@ export class FavoriteService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private static async getCurrentUserWithProvider(): Promise<{ userId: number; provider: string }> {
|
||||
const user = await FavoriteService.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
const provider = dbUser?.activeProvider ?? "komga";
|
||||
return { userId, provider };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une série est dans les favoris
|
||||
* Vérifie si une série est dans les favoris (pour le provider actif)
|
||||
*/
|
||||
static async isFavorite(seriesId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
const favorite = await prisma.favorite.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
seriesId: seriesId,
|
||||
},
|
||||
where: { userId, seriesId, provider },
|
||||
});
|
||||
return !!favorite;
|
||||
} catch (error) {
|
||||
@@ -45,25 +51,18 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une série aux favoris
|
||||
* Ajoute une série aux favoris (pour le provider actif)
|
||||
*/
|
||||
static async addToFavorites(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
userId_provider_seriesId: { userId, provider, seriesId },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
create: { userId, provider, seriesId },
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
@@ -73,18 +72,14 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une série des favoris
|
||||
* Retire une série des favoris (pour le provider actif)
|
||||
*/
|
||||
static async removeFromFavorites(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
where: { userId, seriesId, provider },
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
@@ -94,49 +89,16 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les IDs des séries favorites
|
||||
* Récupère tous les IDs des séries favorites (pour le provider actif)
|
||||
*/
|
||||
static async getAllFavoriteIds(): Promise<string[]> {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
where: { userId, provider },
|
||||
select: { seriesId: true },
|
||||
});
|
||||
return favorites.map((favorite) => favorite.seriesId);
|
||||
}
|
||||
|
||||
static async addFavorite(seriesId: string) {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const favorite = await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
return favorite;
|
||||
}
|
||||
|
||||
static async removeFavorite(seriesId: string): Promise<boolean> {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const result = await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { FavoriteService } from "./favorite.service";
|
||||
import { SeriesService } from "./series.service";
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export class FavoritesService {
|
||||
static async getFavorites(context?: {
|
||||
requestPath?: string;
|
||||
requestPathname?: string;
|
||||
}): Promise<KomgaSeries[]> {
|
||||
}): Promise<NormalizedSeries[]> {
|
||||
try {
|
||||
const favoriteIds = await FavoriteService.getAllFavoriteIds();
|
||||
const [favoriteIds, provider] = await Promise.all([
|
||||
FavoriteService.getAllFavoriteIds(),
|
||||
getProvider(),
|
||||
]);
|
||||
|
||||
if (favoriteIds.length === 0) {
|
||||
if (favoriteIds.length === 0 || !provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch toutes les séries en parallèle
|
||||
const promises = favoriteIds.map(async (id: string) => {
|
||||
try {
|
||||
return await SeriesService.getSeries(id);
|
||||
return await provider.getSeriesById(id);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -40,7 +42,7 @@ export class FavoritesService {
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter((series): series is KomgaSeries => series !== null);
|
||||
return results.filter((series): series is NormalizedSeries => series !== null);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
export type { HomeData };
|
||||
|
||||
// Cache tag pour invalidation ciblée
|
||||
const HOME_CACHE_TAG = "home-data";
|
||||
|
||||
export class HomeService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes fallback
|
||||
private static readonly CACHE_TAG = HOME_CACHE_TAG;
|
||||
|
||||
static async getHomeData(): Promise<HomeData> {
|
||||
try {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readProgress.readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
ongoing: ongoing.content || [],
|
||||
ongoingBooks: ongoingBooks.content || [],
|
||||
recentlyRead: recentlyRead.content || [],
|
||||
onDeck: onDeck.content || [],
|
||||
latestSeries: latestSeries.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
|
||||
const IMAGE_CACHE_MAX_AGE = 2592000;
|
||||
|
||||
export class ImageService extends BaseApiService {
|
||||
/**
|
||||
* Stream an image directly from Komga without buffering in memory
|
||||
* Returns a Response that can be directly returned to the client
|
||||
*/
|
||||
export class ImageService {
|
||||
static async streamImage(
|
||||
path: string,
|
||||
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
||||
const config = await ConfigDBService.getConfig();
|
||||
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
|
||||
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
|
||||
const url = new URL(`${config.url}/api/v1/${path}`).toString();
|
||||
const headers = new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "image/jpeg, image/png, image/gif, image/webp, */*",
|
||||
});
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
|
||||
|
||||
// Stream the response body directly without buffering
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
@@ -31,23 +33,8 @@ export class ImageService extends BaseApiService {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors du streaming de l'image");
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
static getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
static getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
|
||||
static getBookPageThumbnailUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { Series } from "@/types/series";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
|
||||
// Raw library type from Komga API (without booksCount)
|
||||
interface KomgaLibraryRaw {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
|
||||
const sortSeriesDeterministically = <T extends { id: string; metadata?: { titleSort?: string } }>(
|
||||
items: T[]
|
||||
): T[] => {
|
||||
return [...items].sort((a, b) => {
|
||||
const titleA = a.metadata?.titleSort ?? "";
|
||||
const titleB = b.metadata?.titleSort ?? "";
|
||||
const titleComparison = titleA.localeCompare(titleB);
|
||||
|
||||
if (titleComparison !== 0) {
|
||||
return titleComparison;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const LIBRARY_SERIES_CACHE_TAG = "library-series";
|
||||
|
||||
export class LibraryService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||
try {
|
||||
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>(
|
||||
{ path: "libraries" },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
|
||||
// Enrich each library with book counts (parallel requests)
|
||||
const enrichedLibraries = await Promise.all(
|
||||
libraries.map(async (library) => {
|
||||
try {
|
||||
const booksResponse = await this.fetchFromApi<{ totalElements: number }>(
|
||||
{
|
||||
path: "books",
|
||||
params: { library_id: library.id, size: "0" },
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return {
|
||||
...library,
|
||||
importLastModified: "",
|
||||
lastModified: "",
|
||||
booksCount: booksResponse.totalElements,
|
||||
booksReadCount: 0,
|
||||
} as KomgaLibrary;
|
||||
} catch {
|
||||
return {
|
||||
...library,
|
||||
importLastModified: "",
|
||||
lastModified: "",
|
||||
booksCount: 0,
|
||||
booksReadCount: 0,
|
||||
} as KomgaLibrary;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return enrichedLibraries;
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
|
||||
try {
|
||||
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getLibrarySeries(
|
||||
libraryId: string,
|
||||
page: number = 0,
|
||||
size: number = 20,
|
||||
unreadOnly: boolean = false,
|
||||
search?: string
|
||||
): Promise<LibraryResponse<Series>> {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Construction du body de recherche pour Komga
|
||||
let condition: KomgaCondition;
|
||||
|
||||
if (unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ libraryId: { operator: "is", value: libraryId } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||
}
|
||||
|
||||
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.titleSort,asc",
|
||||
};
|
||||
|
||||
if (search) {
|
||||
searchBody.fullTextSearch = search;
|
||||
}
|
||||
|
||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
tags: [LIBRARY_SERIES_CACHE_TAG],
|
||||
}
|
||||
);
|
||||
|
||||
const filteredContent = response.content.filter((series) => !series.deleted);
|
||||
const sortedContent = sortSeriesDeterministically(filteredContent);
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: sortedContent,
|
||||
numberOfElements: sortedContent.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||
try {
|
||||
await this.fetchFromApi(
|
||||
{ path: `libraries/${libraryId}/scan`, params: { deep: String(deep) } },
|
||||
{},
|
||||
{ method: "POST", noJson: true, revalidate: 0 } // bypass cache on mutations
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.SCAN_ERROR, { libraryId }, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
|
||||
interface SearchResponse<T> {
|
||||
content: T[];
|
||||
}
|
||||
|
||||
export interface GlobalSearchResult {
|
||||
series: KomgaSeries[];
|
||||
books: KomgaBook[];
|
||||
}
|
||||
|
||||
export class SearchService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 30;
|
||||
|
||||
static async globalSearch(query: string, limit: number = 6): Promise<GlobalSearchResult> {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
return { series: [], books: [] };
|
||||
}
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const searchBody = {
|
||||
fullTextSearch: trimmedQuery,
|
||||
};
|
||||
|
||||
try {
|
||||
const [seriesResponse, booksResponse] = await Promise.all([
|
||||
this.fetchFromApi<SearchResponse<KomgaSeries>>(
|
||||
{ path: "series/list", params: { page: "0", size: String(limit) } },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
),
|
||||
this.fetchFromApi<SearchResponse<KomgaBook>>(
|
||||
{ path: "books/list", params: { page: "0", size: String(limit) } },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
series: seriesResponse.content.filter((item) => !item.deleted),
|
||||
books: booksResponse.content.filter((item) => !item.deleted),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,45 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import { BookService } from "./book.service";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
export class SeriesService {
|
||||
private static async getFirstBook(seriesId: string): Promise<string> {
|
||||
const config = await ConfigDBService.getConfig();
|
||||
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
|
||||
export class SeriesService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||
const url = new URL(`${config.url}/api/v1/series/${seriesId}/books`);
|
||||
url.searchParams.set("page", "0");
|
||||
url.searchParams.set("size", "1");
|
||||
|
||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||
try {
|
||||
return this.fetchFromApi<KomgaSeries>(
|
||||
{ path: `series/${seriesId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
const headers = new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
static async getSeriesBooks(
|
||||
seriesId: string,
|
||||
page: number = 0,
|
||||
size: number = 24,
|
||||
unreadOnly: boolean = false
|
||||
): Promise<LibraryResponse<KomgaBook>> {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||
|
||||
// Construction du body de recherche pour Komga
|
||||
let condition: KomgaCondition;
|
||||
const data: { content: KomgaBook[] } = await response.json();
|
||||
if (!data.content?.length) throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
|
||||
if (unreadOnly) {
|
||||
// Utiliser allOf pour combiner seriesId avec anyOf pour UNREAD ou IN_PROGRESS
|
||||
condition = {
|
||||
allOf: [
|
||||
{
|
||||
seriesId: {
|
||||
operator: "is",
|
||||
value: seriesId,
|
||||
},
|
||||
},
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "UNREAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "IN_PROGRESS",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = {
|
||||
seriesId: {
|
||||
operator: "is",
|
||||
value: seriesId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.numberSort,asc",
|
||||
};
|
||||
|
||||
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{ path: "books/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
);
|
||||
|
||||
// Filtrer uniquement les livres supprimés côté client (léger)
|
||||
const filteredContent = response.content.filter((book: KomgaBook) => !book.deleted);
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: filteredContent,
|
||||
numberOfElements: filteredContent.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getFirstBook(seriesId: string): Promise<string> {
|
||||
try {
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: `series/${seriesId}/books`,
|
||||
params: { page: "0", size: "1" },
|
||||
});
|
||||
if (!data.content || data.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
}
|
||||
|
||||
return data.content[0].id;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
return data.content[0].id;
|
||||
}
|
||||
|
||||
static async getCover(seriesId: string): Promise<Response> {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
if (preferences.showThumbnails) {
|
||||
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
|
||||
}
|
||||
|
||||
// Sinon, récupérer la première page (streaming)
|
||||
const firstBookId = await this.getFirstBook(seriesId);
|
||||
const firstBookId = await SeriesService.getFirstBook(seriesId);
|
||||
return BookService.getPage(firstBookId, 1);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getCoverUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getMultipleSeries(seriesIds: string[]): Promise<KomgaSeries[]> {
|
||||
try {
|
||||
const seriesPromises: Promise<KomgaSeries>[] = seriesIds.map((id: string) =>
|
||||
this.getSeries(id)
|
||||
);
|
||||
const series: KomgaSeries[] = await Promise.all(seriesPromises);
|
||||
return series.filter(Boolean);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { KomgaBook, KomgaSeries } from "./komga";
|
||||
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
|
||||
|
||||
export interface HomeData {
|
||||
ongoing: KomgaSeries[];
|
||||
ongoingBooks: KomgaBook[];
|
||||
recentlyRead: KomgaBook[];
|
||||
onDeck: KomgaBook[];
|
||||
latestSeries: KomgaSeries[];
|
||||
ongoing: NormalizedSeries[];
|
||||
ongoingBooks: NormalizedBook[];
|
||||
recentlyRead: NormalizedBook[];
|
||||
onDeck: NormalizedBook[];
|
||||
latestSeries: NormalizedSeries[];
|
||||
}
|
||||
|
||||
105
src/types/stripstream.ts
Normal file
105
src/types/stripstream.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Types natifs de l'API Stripstream Librarian
|
||||
|
||||
export interface StripstreamBookItem {
|
||||
id: string;
|
||||
library_id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
reading_status: "unread" | "reading" | "read";
|
||||
author?: string | null;
|
||||
language?: string | null;
|
||||
page_count?: number | null;
|
||||
reading_current_page?: number | null;
|
||||
reading_last_read_at?: string | null;
|
||||
series?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
volume?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamBookDetails {
|
||||
id: string;
|
||||
library_id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
reading_status: "unread" | "reading" | "read";
|
||||
author?: string | null;
|
||||
file_format?: string | null;
|
||||
file_parse_status?: string | null;
|
||||
file_path?: string | null;
|
||||
language?: string | null;
|
||||
page_count?: number | null;
|
||||
reading_current_page?: number | null;
|
||||
reading_last_read_at?: string | null;
|
||||
series?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
volume?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamBooksPage {
|
||||
items: StripstreamBookItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesItem {
|
||||
name: string;
|
||||
book_count: number;
|
||||
books_read_count: number;
|
||||
first_book_id: string;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesPage {
|
||||
items: StripstreamSeriesItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface StripstreamLibraryResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
root_path: string;
|
||||
enabled: boolean;
|
||||
book_count: number;
|
||||
monitor_enabled: boolean;
|
||||
scan_mode: string;
|
||||
watcher_enabled: boolean;
|
||||
next_scan_at?: string | null;
|
||||
}
|
||||
|
||||
export interface StripstreamReadingProgressResponse {
|
||||
status: "unread" | "reading" | "read";
|
||||
current_page?: number | null;
|
||||
last_read_at?: string | null;
|
||||
}
|
||||
|
||||
export interface StripstreamUpdateReadingProgressRequest {
|
||||
status: "unread" | "reading" | "read";
|
||||
current_page?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSearchResponse {
|
||||
hits: StripstreamSearchHit[];
|
||||
series_hits: StripstreamSeriesHit[];
|
||||
estimated_total_hits?: number | null;
|
||||
processing_time_ms?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSearchHit {
|
||||
id: string;
|
||||
title: string;
|
||||
series?: string | null;
|
||||
library_id: string;
|
||||
thumbnail_url?: string | null;
|
||||
volume?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesHit {
|
||||
name: string;
|
||||
book_count: number;
|
||||
books_read_count: number;
|
||||
first_book_id: string;
|
||||
library_id: string;
|
||||
}
|
||||
Reference in New Issue
Block a user