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 {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
roles Json @default("[\"ROLE_USER\"]")
|
roles Json @default("[\"ROLE_USER\"]")
|
||||||
authenticated Boolean @default(true)
|
authenticated Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
activeProvider String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
config KomgaConfig?
|
config KomgaConfig?
|
||||||
preferences Preferences?
|
stripstreamConfig StripstreamConfig?
|
||||||
favorites Favorite[]
|
preferences Preferences?
|
||||||
|
favorites Favorite[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,19 @@ model KomgaConfig {
|
|||||||
@@map("komgaconfigs")
|
@@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 {
|
model Preferences {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
@@ -61,12 +76,13 @@ model Favorite {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
seriesId String
|
seriesId String
|
||||||
|
provider String @default("komga")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, seriesId])
|
@@unique([userId, provider, seriesId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("favorites")
|
@@map("favorites")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface BookDataResult {
|
interface BookDataResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: {
|
data?: {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
nextBook: KomgaBook | null;
|
nextBook: NormalizedBook | null;
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBookData(bookId: string): Promise<BookDataResult> {
|
export async function getBookData(bookId: string): Promise<BookDataResult> {
|
||||||
try {
|
try {
|
||||||
const data = await BookService.getBook(bookId);
|
const provider = await getProvider();
|
||||||
let nextBook = null;
|
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 {
|
try {
|
||||||
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
nextBook = await provider.getNextBook(bookId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
|
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { book, pages, nextBook },
|
||||||
...data,
|
|
||||||
nextBook,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||||
import { TestService } from "@/lib/services/test.service";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
|
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
|
||||||
|
|
||||||
interface SaveConfigInput {
|
interface SaveConfigInput {
|
||||||
@@ -13,9 +13,6 @@ interface SaveConfigInput {
|
|||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Teste la connexion à Komga
|
|
||||||
*/
|
|
||||||
export async function testKomgaConnection(
|
export async function testKomgaConnection(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
@@ -23,12 +20,19 @@ export async function testKomgaConnection(
|
|||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
|
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
|
||||||
|
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
|
||||||
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({
|
const headers = new Headers({
|
||||||
serverUrl,
|
Authorization: `Basic ${authHeader}`,
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
|
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(
|
export async function saveKomgaConfig(
|
||||||
config: SaveConfigInput
|
config: SaveConfigInput
|
||||||
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
|
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
|
||||||
@@ -55,15 +56,8 @@ export async function saveKomgaConfig(
|
|||||||
authHeader: config.authHeader || "",
|
authHeader: config.authHeader || "",
|
||||||
};
|
};
|
||||||
const mongoConfig = await ConfigDBService.saveConfig(configData);
|
const mongoConfig = await ConfigDBService.saveConfig(configData);
|
||||||
|
|
||||||
// Invalider le cache
|
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
|
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Configuration sauvegardée",
|
|
||||||
data: mongoConfig,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
|
||||||
/**
|
|
||||||
* Lance un scan de bibliothèque
|
|
||||||
*/
|
|
||||||
export async function scanLibrary(
|
export async function scanLibrary(
|
||||||
libraryId: string
|
libraryId: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
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/${libraryId}`);
|
||||||
revalidatePath("/libraries");
|
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(
|
export async function getRandomBookFromLibraries(
|
||||||
libraryIds: string[]
|
libraryIds: string[]
|
||||||
): Promise<{ success: boolean; bookId?: string; message?: 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" };
|
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookId = await BookService.getRandomBookFromLibraries(libraryIds);
|
const provider = await getProvider();
|
||||||
return { success: true, bookId };
|
if (!provider) return { success: false, message: "Provider non configuré" };
|
||||||
|
|
||||||
|
const bookId = await provider.getRandomBook(libraryIds);
|
||||||
|
return { success: true, bookId: bookId ?? undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
|
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
|
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||||
import { AppError } from "@/utils/errors";
|
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(
|
export async function updateReadProgress(
|
||||||
bookId: string,
|
bookId: string,
|
||||||
page: number,
|
page: number,
|
||||||
completed: boolean = false
|
completed: boolean = false
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
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)
|
await provider.saveReadProgress(bookId, page, completed);
|
||||||
revalidateTag(HOME_CACHE_TAG, "max");
|
revalidateReadCaches();
|
||||||
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
|
|
||||||
|
|
||||||
return { success: true, message: "Progression mise à jour" };
|
return { success: true, message: "Progression mise à jour" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -32,18 +32,15 @@ export async function updateReadProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime la progression de lecture d'un livre
|
|
||||||
*/
|
|
||||||
export async function deleteReadProgress(
|
export async function deleteReadProgress(
|
||||||
bookId: string
|
bookId: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
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)
|
await provider.resetReadProgress(bookId);
|
||||||
revalidateTag(HOME_CACHE_TAG, "max");
|
revalidateReadCaches();
|
||||||
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
|
|
||||||
|
|
||||||
return { success: true, message: "Progression supprimée" };
|
return { success: true, message: "Progression supprimée" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath, revalidateTag } from "next/cache";
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
|
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
|
||||||
|
|
||||||
const HOME_CACHE_TAG = "home-data";
|
|
||||||
|
|
||||||
export type RefreshScope = "home" | "library" | "series";
|
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 { Suspense } from "react";
|
||||||
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
||||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
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 { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -11,23 +11,30 @@ export default async function BookPage({ params }: { params: Promise<{ bookId: s
|
|||||||
const { bookId } = await params;
|
const { bookId } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// SSR: Fetch directly on server instead of client-side XHR
|
const provider = await getProvider();
|
||||||
const data = await BookService.getBook(bookId);
|
if (!provider) redirect("/settings");
|
||||||
|
|
||||||
|
const book = await provider.getBook(bookId);
|
||||||
|
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
|
||||||
|
|
||||||
let nextBook = null;
|
let nextBook = null;
|
||||||
try {
|
try {
|
||||||
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
nextBook = await provider.getNextBook(bookId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
|
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<BookSkeleton />}>
|
<Suspense fallback={<BookSkeleton />}>
|
||||||
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} />
|
<ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If config is missing, redirect to settings
|
// 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");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AuthProvider } from "@/components/providers/AuthProvider";
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { defaultPreferences } from "@/types/preferences";
|
import { defaultPreferences } from "@/types/preferences";
|
||||||
import type { UserPreferences } 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";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -77,8 +77,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
let preferences: UserPreferences = defaultPreferences;
|
let preferences: UserPreferences = defaultPreferences;
|
||||||
let userIsAdmin = false;
|
let userIsAdmin = false;
|
||||||
let libraries: KomgaLibrary[] = [];
|
let libraries: NormalizedLibrary[] = [];
|
||||||
let favorites: KomgaSeries[] = [];
|
let favorites: NormalizedSeries[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
|
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) {
|
if (currentUser) {
|
||||||
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
|
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
|
||||||
PreferencesService.getPreferences(),
|
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) =>
|
import("@/lib/services/favorites.service").then((m) =>
|
||||||
m.FavoritesService.getFavorites({ requestPath, requestPathname })
|
m.FavoritesService.getFavorites({ requestPath, requestPathname })
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||||
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
||||||
import { Container } from "@/components/ui/container";
|
import { Container } from "@/components/ui/container";
|
||||||
import type { KomgaLibrary } from "@/types/komga";
|
import type { NormalizedLibrary, NormalizedSeriesPage } from "@/lib/providers/types";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
|
||||||
import type { Series } from "@/types/series";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface LibraryContentProps {
|
interface LibraryContentProps {
|
||||||
library: KomgaLibrary;
|
library: NormalizedLibrary;
|
||||||
series: LibraryResponse<Series>;
|
series: NormalizedSeriesPage;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
@@ -28,15 +26,15 @@ export function LibraryContent({
|
|||||||
<>
|
<>
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
library={library}
|
library={library}
|
||||||
seriesCount={series.totalElements}
|
seriesCount={series.totalElements ?? series.items.length}
|
||||||
series={series.content || []}
|
series={series.items}
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<PaginatedSeriesGrid
|
<PaginatedSeriesGrid
|
||||||
series={series.content || []}
|
series={series.items}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={series.totalPages}
|
totalPages={series.totalPages ?? 1}
|
||||||
totalElements={series.totalElements}
|
totalElements={series.totalElements ?? series.items.length}
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
showOnlyUnread={unreadOnly}
|
showOnlyUnread={unreadOnly}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
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 { LibraryClientWrapper } from "./LibraryClientWrapper";
|
||||||
import { LibraryContent } from "./LibraryContent";
|
import { LibraryContent } from "./LibraryContent";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ libraryId: string }>;
|
params: Promise<{ libraryId: string }>;
|
||||||
@@ -17,9 +18,9 @@ const DEFAULT_PAGE_SIZE = 20;
|
|||||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||||
const libraryId = (await params).libraryId;
|
const libraryId = (await params).libraryId;
|
||||||
const unread = (await searchParams).unread;
|
const unread = (await searchParams).unread;
|
||||||
const search = (await searchParams).search;
|
|
||||||
const page = (await searchParams).page;
|
const page = (await searchParams).page;
|
||||||
const size = (await searchParams).size;
|
const size = (await searchParams).size;
|
||||||
|
const search = (await searchParams).search;
|
||||||
|
|
||||||
const currentPage = page ? parseInt(page) : 1;
|
const currentPage = page ? parseInt(page) : 1;
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||||
@@ -31,31 +32,36 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
|
|||||||
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [series, library] = await Promise.all([
|
const provider = await getProvider();
|
||||||
LibraryService.getLibrarySeries(
|
if (!provider) redirect("/settings");
|
||||||
libraryId,
|
|
||||||
currentPage - 1,
|
const [seriesPage, library] = await Promise.all([
|
||||||
effectivePageSize,
|
provider.getSeries(libraryId, String(currentPage), effectivePageSize, unreadOnly, search),
|
||||||
unreadOnly,
|
provider.getLibraryById(libraryId),
|
||||||
search
|
|
||||||
),
|
|
||||||
LibraryService.getLibrary(libraryId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!library) throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryClientWrapper libraryId={libraryId}>
|
<LibraryClientWrapper libraryId={libraryId}>
|
||||||
<LibraryContent
|
<LibraryContent
|
||||||
library={library}
|
library={library}
|
||||||
series={series}
|
series={seriesPage}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
search={search}
|
|
||||||
pageSize={effectivePageSize}
|
pageSize={effectivePageSize}
|
||||||
/>
|
/>
|
||||||
</LibraryClientWrapper>
|
</LibraryClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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;
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
||||||
|
|
||||||
return (
|
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 { HomeContent } from "@/components/home/HomeContent";
|
||||||
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
@@ -8,7 +8,10 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
try {
|
try {
|
||||||
const data = await HomeService.getHomeData();
|
const provider = await getProvider();
|
||||||
|
if (!provider) redirect("/settings");
|
||||||
|
|
||||||
|
const data = await provider.getHomeData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClientWrapper>
|
<HomeClientWrapper>
|
||||||
@@ -16,13 +19,14 @@ export default async function HomePage() {
|
|||||||
</HomeClientWrapper>
|
</HomeClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Si la config Komga est manquante, rediriger vers les settings
|
if (error instanceof AppError && (
|
||||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||||
|
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||||
|
)) {
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher une erreur pour les autres cas
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
|
||||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<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 { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { Container } from "@/components/ui/container";
|
import { Container } from "@/components/ui/container";
|
||||||
import { useRefresh } from "@/contexts/RefreshContext";
|
import { useRefresh } from "@/contexts/RefreshContext";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { NormalizedBooksPage, NormalizedSeries } from "@/lib/providers/types";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface SeriesContentProps {
|
interface SeriesContentProps {
|
||||||
series: KomgaSeries;
|
series: NormalizedSeries;
|
||||||
books: LibraryResponse<KomgaBook>;
|
books: NormalizedBooksPage;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
@@ -37,10 +36,10 @@ export function SeriesContent({
|
|||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<PaginatedBookGrid
|
<PaginatedBookGrid
|
||||||
books={books.content || []}
|
books={books.items}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={books.totalPages}
|
totalPages={books.totalPages ?? 1}
|
||||||
totalElements={books.totalElements}
|
totalElements={books.totalElements ?? books.items.length}
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
showOnlyUnread={unreadOnly}
|
showOnlyUnread={unreadOnly}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
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 { FavoriteService } from "@/lib/services/favorite.service";
|
||||||
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
||||||
import { SeriesContent } from "./SeriesContent";
|
import { SeriesContent } from "./SeriesContent";
|
||||||
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ seriesId: string }>;
|
params: Promise<{ seriesId: string }>;
|
||||||
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
|
|||||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||||
const seriesId = (await params).seriesId;
|
const seriesId = (await params).seriesId;
|
||||||
const page = (await searchParams).page;
|
const page = (await searchParams).page;
|
||||||
const unread = (await searchParams).unread;
|
|
||||||
const size = (await searchParams).size;
|
const size = (await searchParams).size;
|
||||||
|
const unread = (await searchParams).unread;
|
||||||
const currentPage = page ? parseInt(page) : 1;
|
const currentPage = page ? parseInt(page) : 1;
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
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 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 {
|
try {
|
||||||
const [books, series, isFavorite] = await Promise.all([
|
const provider = await getProvider();
|
||||||
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
if (!provider) redirect("/settings");
|
||||||
SeriesService.getSeries(seriesId),
|
|
||||||
|
const [booksPage, series, isFavorite] = await Promise.all([
|
||||||
|
provider.getBooks({
|
||||||
|
seriesName: seriesId,
|
||||||
|
cursor: String(currentPage),
|
||||||
|
limit: effectivePageSize,
|
||||||
|
unreadOnly,
|
||||||
|
}),
|
||||||
|
provider.getSeriesById(seriesId),
|
||||||
FavoriteService.isFavorite(seriesId),
|
FavoriteService.isFavorite(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SeriesClientWrapper seriesId={seriesId}>
|
<SeriesClientWrapper seriesId={seriesId}>
|
||||||
<SeriesContent
|
<SeriesContent
|
||||||
series={series}
|
series={series}
|
||||||
books={books}
|
books={booksPage}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
@@ -49,10 +61,16 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
|
|||||||
</SeriesClientWrapper>
|
</SeriesClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorCode = error instanceof AppError
|
if (
|
||||||
? error.code
|
error instanceof AppError &&
|
||||||
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
(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 (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<ErrorMessage errorCode={errorCode} />
|
<ErrorMessage errorCode={errorCode} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
|
||||||
import { ClientSettings } from "@/components/settings/ClientSettings";
|
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 { 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";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
let config: KomgaConfig | null = null;
|
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 {
|
try {
|
||||||
// Récupérer la configuration Komga
|
|
||||||
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
|
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||||
if (mongoConfig) {
|
if (mongoConfig) {
|
||||||
config = {
|
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) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadedBook {
|
interface DownloadedBook {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
status: BookDownloadStatus;
|
status: BookDownloadStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +112,11 @@ export function DownloadManager() {
|
|||||||
};
|
};
|
||||||
}, [loadDownloadedBooks, updateBookStatuses]);
|
}, [loadDownloadedBooks, updateBookStatuses]);
|
||||||
|
|
||||||
const handleDeleteBook = async (book: KomgaBook) => {
|
const handleDeleteBook = async (book: NormalizedBook) => {
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open("stripstream-books");
|
const cache = await caches.open("stripstream-books");
|
||||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
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}`);
|
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||||
}
|
}
|
||||||
localStorage.removeItem(getStorageKey(book.id));
|
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));
|
localStorage.removeItem(getStorageKey(book.id));
|
||||||
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
||||||
toast({
|
toast({
|
||||||
@@ -279,7 +279,7 @@ export function DownloadManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BookDownloadCardProps {
|
interface BookDownloadCardProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
status: BookDownloadStatus;
|
status: BookDownloadStatus;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
src={book.thumbnailUrl}
|
||||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
alt={t("books.coverAlt", { title: book.title })}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="64px"
|
||||||
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
className="hover:underline hover:text-primary transition-colors"
|
className="hover:underline hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<h3 className="font-medium truncate">
|
<h3 className="font-medium truncate">
|
||||||
{book.metadata?.title || t("books.title", { number: book.metadata?.number })}
|
{book.title || t("books.title", { number: book.number ?? "" })}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{formatSize(book.sizeBytes)}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
<span>
|
||||||
{status.status === "downloading"
|
{status.status === "downloading"
|
||||||
? t("downloads.info.pages", {
|
? t("downloads.info.pages", {
|
||||||
current: Math.floor((status.progress * book.media.pagesCount) / 100),
|
current: Math.floor((status.progress * book.pageCount) / 100),
|
||||||
total: book.media.pagesCount,
|
total: book.pageCount,
|
||||||
})
|
})
|
||||||
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
|
: t("downloads.info.totalPages", { count: book.pageCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,39 +2,27 @@
|
|||||||
|
|
||||||
import { SeriesCover } from "@/components/ui/series-cover";
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import type { KomgaSeries } from "@/types/komga";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
|
|
||||||
interface OptimizedHeroSeries {
|
|
||||||
id: string;
|
|
||||||
metadata: {
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
series: OptimizedHeroSeries[];
|
series: NormalizedSeries[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroSection({ series }: HeroSectionProps) {
|
export function HeroSection({ series }: HeroSectionProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
// logger.info("HeroSection - Séries reçues:", {
|
|
||||||
// count: series?.length || 0,
|
|
||||||
// firstSeries: series?.[0],
|
|
||||||
// });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
|
<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 */}
|
{/* 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">
|
<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
|
<div
|
||||||
key={series.id}
|
key={s.id}
|
||||||
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
|
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series as KomgaSeries}
|
series={s}
|
||||||
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
|
alt={t("home.hero.coverAlt", { title: s.name })}
|
||||||
showProgressUi={false}
|
showProgressUi={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,17 @@
|
|||||||
import { MediaRow } from "./MediaRow";
|
import { MediaRow } from "./MediaRow";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
|
||||||
import type { HomeData } from "@/types/home";
|
import type { HomeData } from "@/types/home";
|
||||||
|
|
||||||
interface HomeContentProps {
|
interface HomeContentProps {
|
||||||
data: HomeData;
|
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) {
|
export function HomeContent({ data }: HomeContentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 pb-2">
|
<div className="space-y-10 pb-2">
|
||||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.continue_reading"
|
titleKey="home.sections.continue_reading"
|
||||||
items={optimizeBookData(data.ongoingBooks)}
|
items={data.ongoingBooks}
|
||||||
iconName="BookOpen"
|
iconName="BookOpen"
|
||||||
featuredHeader
|
featuredHeader
|
||||||
/>
|
/>
|
||||||
@@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
{data.ongoing && data.ongoing.length > 0 && (
|
{data.ongoing && data.ongoing.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.continue_series"
|
titleKey="home.sections.continue_series"
|
||||||
items={optimizeSeriesData(data.ongoing)}
|
items={data.ongoing}
|
||||||
iconName="LibraryBig"
|
iconName="LibraryBig"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
{data.onDeck && data.onDeck.length > 0 && (
|
{data.onDeck && data.onDeck.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.up_next"
|
titleKey="home.sections.up_next"
|
||||||
items={optimizeBookData(data.onDeck)}
|
items={data.onDeck}
|
||||||
iconName="Clock"
|
iconName="Clock"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
{data.latestSeries && data.latestSeries.length > 0 && (
|
{data.latestSeries && data.latestSeries.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.latest_series"
|
titleKey="home.sections.latest_series"
|
||||||
items={optimizeSeriesData(data.latestSeries)}
|
items={data.latestSeries}
|
||||||
iconName="Sparkles"
|
iconName="Sparkles"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
{data.recentlyRead && data.recentlyRead.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.recently_added"
|
titleKey="home.sections.recently_added"
|
||||||
items={optimizeBookData(data.recentlyRead)}
|
items={data.recentlyRead}
|
||||||
iconName="History"
|
iconName="History"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
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 { BookCover } from "../ui/book-cover";
|
||||||
import { SeriesCover } from "../ui/series-cover";
|
import { SeriesCover } from "../ui/series-cover";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -12,34 +12,9 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface MediaRowProps {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
items: (OptimizedSeries | OptimizedBook)[];
|
items: (NormalizedSeries | NormalizedBook)[];
|
||||||
iconName?: string;
|
iconName?: string;
|
||||||
featuredHeader?: boolean;
|
featuredHeader?: boolean;
|
||||||
}
|
}
|
||||||
@@ -52,13 +27,17 @@ const iconMap = {
|
|||||||
History,
|
History,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
|
||||||
|
return "bookCount" in item;
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
|
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
|
||||||
|
|
||||||
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
|
const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
|
||||||
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
|
const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
|
||||||
router.push(path);
|
router.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,24 +71,24 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MediaCardProps {
|
interface MediaCardProps {
|
||||||
item: OptimizedSeries | OptimizedBook;
|
item: NormalizedSeries | NormalizedBook;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const isSeries = "booksCount" in item;
|
const isSeriesItem = isSeries(item);
|
||||||
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
|
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||||
|
|
||||||
const title = isSeries
|
const title = isSeriesItem
|
||||||
? item.metadata.title
|
? item.name
|
||||||
: item.metadata.title ||
|
: item.title ||
|
||||||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
|
(item.number ? t("navigation.volume", { number: item.number }) : "");
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// Pour les séries, toujours autoriser le clic
|
// Pour les séries, toujours autoriser le clic
|
||||||
// Pour les livres, vérifier si accessible
|
// Pour les livres, vérifier si accessible
|
||||||
if (isSeries || isAccessible) {
|
if (isSeriesItem || isAccessible) {
|
||||||
onClick?.();
|
onClick?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -119,24 +98,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
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]",
|
"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">
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
{isSeries ? (
|
{isSeriesItem ? (
|
||||||
<>
|
<>
|
||||||
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
|
<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">
|
<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>
|
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||||
<p className="text-xs text-white/80 mt-1">
|
<p className="text-xs text-white/80 mt-1">
|
||||||
{t("series.books", { count: item.booksCount })}
|
{t("series.books", { count: item.bookCount })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<BookCover
|
<BookCover
|
||||||
book={item as KomgaBook}
|
book={item}
|
||||||
alt={`Couverture de ${title}`}
|
alt={`Couverture de ${title}`}
|
||||||
showControls={false}
|
showControls={false}
|
||||||
overlayVariant="home"
|
overlayVariant="home"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { NetworkStatus } from "../ui/NetworkStatus";
|
import { NetworkStatus } from "../ui/NetworkStatus";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
|
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 { defaultPreferences } from "@/types/preferences";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
import { getRandomBookFromLibraries } from "@/app/actions/library";
|
import { getRandomBookFromLibraries } from "@/app/actions/library";
|
||||||
@@ -20,8 +20,8 @@ const publicRoutes = ["/login", "/register"];
|
|||||||
|
|
||||||
interface ClientLayoutProps {
|
interface ClientLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
initialLibraries: KomgaLibrary[];
|
initialLibraries: NormalizedLibrary[];
|
||||||
initialFavorites: KomgaSeries[];
|
initialFavorites: NormalizedSeries[];
|
||||||
userIsAdmin?: boolean;
|
userIsAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { getImageUrl } from "@/lib/utils/image-url";
|
import type { NormalizedSearchResult } from "@/lib/providers/types";
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_QUERY_LENGTH = 2;
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
|
||||||
@@ -38,21 +19,15 @@ export function GlobalSearch() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = 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(() => {
|
const firstResultHref = useMemo(() => {
|
||||||
if (results.series.length > 0) {
|
return results[0]?.href ?? null;
|
||||||
return results.series[0].href;
|
}, [results]);
|
||||||
}
|
|
||||||
|
|
||||||
if (results.books.length > 0) {
|
|
||||||
return results.books[0].href;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [results.books, results.series]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -77,7 +52,7 @@ export function GlobalSearch() {
|
|||||||
const trimmedQuery = query.trim();
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
|
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
|
||||||
setResults({ series: [], books: [] });
|
setResults([]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,7 +65,7 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
const response = await fetch(`/api/provider/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -100,12 +75,12 @@ export function GlobalSearch() {
|
|||||||
throw new Error("Search request failed");
|
throw new Error("Search request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as SearchResponse;
|
const data = (await response.json()) as NormalizedSearchResult[];
|
||||||
setResults(data);
|
setResults(Array.isArray(data) ? data : []);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error).name !== "AbortError") {
|
if ((error as Error).name !== "AbortError") {
|
||||||
setResults({ series: [], books: [] });
|
setResults([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -158,12 +133,12 @@ export function GlobalSearch() {
|
|||||||
{isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
|
{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="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">
|
<div className="max-h-[26rem] overflow-y-auto p-2">
|
||||||
{results.series.length > 0 && (
|
{seriesResults.length > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{t("header.search.series")}
|
{t("header.search.series")}
|
||||||
</div>
|
</div>
|
||||||
{results.series.map((item) => (
|
{seriesResults.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -172,16 +147,17 @@ export function GlobalSearch() {
|
|||||||
aria-label={t("header.search.openSeries", { title: item.title })}
|
aria-label={t("header.search.openSeries", { title: item.title })}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getImageUrl("series", item.id)}
|
src={item.coverUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
loading="lazy"
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-base font-medium">{item.title}</p>
|
<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">
|
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Library className="h-3 w-3" />
|
<Library className="h-3 w-3" />
|
||||||
{t("series.books", { count: item.booksCount })}
|
{item.bookCount !== undefined && t("series.books", { count: item.bookCount })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -189,12 +165,12 @@ export function GlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results.books.length > 0 && (
|
{bookResults.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{t("header.search.books")}
|
{t("header.search.books")}
|
||||||
</div>
|
</div>
|
||||||
{results.books.map((item) => (
|
{bookResults.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -203,10 +179,11 @@ export function GlobalSearch() {
|
|||||||
aria-label={t("header.search.openBook", { title: item.title })}
|
aria-label={t("header.search.openBook", { title: item.title })}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getImageUrl("book", item.id)}
|
src={item.coverUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
loading="lazy"
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-base font-medium">{item.title}</p>
|
<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 { cn } from "@/lib/utils";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useEffect, useState, useCallback } from "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 { useToast } from "@/components/ui/use-toast";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { NavButton } from "@/components/ui/nav-button";
|
import { NavButton } from "@/components/ui/nav-button";
|
||||||
@@ -25,8 +25,8 @@ import logger from "@/lib/logger";
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initialLibraries: KomgaLibrary[];
|
initialLibraries: NormalizedLibrary[];
|
||||||
initialFavorites: KomgaSeries[];
|
initialFavorites: NormalizedSeries[];
|
||||||
userIsAdmin?: boolean;
|
userIsAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ export function Sidebar({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
|
||||||
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []);
|
const [favorites, setFavorites] = useState<NormalizedSeries[]>(initialFavorites || []);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -60,7 +60,7 @@ export function Sidebar({
|
|||||||
const customEvent = event as CustomEvent<{
|
const customEvent = event as CustomEvent<{
|
||||||
seriesId?: string;
|
seriesId?: string;
|
||||||
action?: "add" | "remove";
|
action?: "add" | "remove";
|
||||||
series?: KomgaSeries;
|
series?: NormalizedSeries;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Si on a les détails de l'action, faire une mise à jour optimiste locale
|
// Si on a les détails de l'action, faire une mise à jour optimiste locale
|
||||||
@@ -207,7 +207,7 @@ export function Sidebar({
|
|||||||
<NavButton
|
<NavButton
|
||||||
key={series.id}
|
key={series.id}
|
||||||
icon={Star}
|
icon={Star}
|
||||||
label={series.metadata.title}
|
label={series.name}
|
||||||
active={pathname === `/series/${series.id}`}
|
active={pathname === `/series/${series.id}`}
|
||||||
onClick={() => handleLinkClick(`/series/${series.id}`)}
|
onClick={() => handleLinkClick(`/series/${series.id}`)}
|
||||||
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"
|
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Library } from "lucide-react";
|
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 { RefreshButton } from "./RefreshButton";
|
||||||
import { ScanButton } from "./ScanButton";
|
import { ScanButton } from "./ScanButton";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { SeriesCover } from "@/components/ui/series-cover";
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
|
|
||||||
interface LibraryHeaderProps {
|
interface LibraryHeaderProps {
|
||||||
library: KomgaLibrary;
|
library: NormalizedLibrary;
|
||||||
seriesCount: number;
|
seriesCount: number;
|
||||||
series: KomgaSeries[];
|
series: NormalizedSeries[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHeaderSeries = (series: KomgaSeries[]) => {
|
const getHeaderSeries = (series: NormalizedSeries[]) => {
|
||||||
if (series.length === 0) {
|
if (series.length === 0) {
|
||||||
return { featured: null, background: null };
|
return { featured: null, background: null };
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,6 @@ export function LibraryHeader({
|
|||||||
<RefreshButton libraryId={library.id} />
|
<RefreshButton libraryId={library.id} />
|
||||||
<ScanButton libraryId={library.id} />
|
<ScanButton libraryId={library.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SeriesList } from "./SeriesList";
|
|||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { KomgaSeries } from "@/types/komga";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import { SearchInput } from "./SearchInput";
|
import { SearchInput } from "./SearchInput";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
|
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
|
||||||
@@ -15,7 +15,7 @@ import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
|||||||
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
|
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
|
||||||
|
|
||||||
interface PaginatedSeriesGridProps {
|
interface PaginatedSeriesGridProps {
|
||||||
series: KomgaSeries[];
|
series: NormalizedSeries[];
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalElements: number;
|
totalElements: number;
|
||||||
@@ -108,19 +108,13 @@ export function PaginatedSeriesGrid({
|
|||||||
const handleUnreadFilter = async () => {
|
const handleUnreadFilter = async () => {
|
||||||
const newUnreadState = !showOnlyUnread;
|
const newUnreadState = !showOnlyUnread;
|
||||||
setShowOnlyUnread(newUnreadState);
|
setShowOnlyUnread(newUnreadState);
|
||||||
await updateUrlParams({
|
await updateUrlParams({ page: "1", unread: newUnreadState ? "true" : "false" });
|
||||||
page: "1",
|
|
||||||
unread: newUnreadState ? "true" : "false",
|
|
||||||
});
|
|
||||||
await persistPreferences({ showOnlyUnread: newUnreadState });
|
await persistPreferences({ showOnlyUnread: newUnreadState });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeChange = async (size: number) => {
|
const handlePageSizeChange = async (size: number) => {
|
||||||
setCurrentPageSize(size);
|
setCurrentPageSize(size);
|
||||||
await updateUrlParams({
|
await updateUrlParams({ page: "1", size: size.toString() });
|
||||||
page: "1",
|
|
||||||
size: size.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await persistPreferences({
|
await persistPreferences({
|
||||||
displayMode: {
|
displayMode: {
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KomgaSeries } from "@/types/komga";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SeriesCover } from "@/components/ui/series-cover";
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
|
||||||
interface SeriesGridProps {
|
interface SeriesGridProps {
|
||||||
series: KomgaSeries[];
|
series: NormalizedSeries[];
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to get reading status info
|
// Utility function to get reading status info
|
||||||
const getReadingStatusInfo = (
|
const getReadingStatusInfo = (
|
||||||
series: KomgaSeries,
|
series: NormalizedSeries,
|
||||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||||
) => {
|
) => {
|
||||||
if (series.booksCount === 0) {
|
if (series.bookCount === 0) {
|
||||||
return {
|
return {
|
||||||
label: t("series.status.noBooks"),
|
label: t("series.status.noBooks"),
|
||||||
className: "bg-yellow-500/10 text-yellow-500",
|
className: "bg-yellow-500/10 text-yellow-500",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (series.booksCount === series.booksReadCount) {
|
if (series.bookCount === series.booksReadCount) {
|
||||||
return {
|
return {
|
||||||
label: t("series.status.read"),
|
label: t("series.status.read"),
|
||||||
className: "bg-green-500/10 text-green-500",
|
className: "bg-green-500/10 text-green-500",
|
||||||
@@ -34,7 +34,7 @@ const getReadingStatusInfo = (
|
|||||||
return {
|
return {
|
||||||
label: t("series.status.progress", {
|
label: t("series.status.progress", {
|
||||||
read: series.booksReadCount,
|
read: series.booksReadCount,
|
||||||
total: series.booksCount,
|
total: series.bookCount,
|
||||||
}),
|
}),
|
||||||
className: "bg-primary/15 text-primary",
|
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"
|
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{series.map((series) => (
|
{series.map((seriesItem) => (
|
||||||
<button
|
<button
|
||||||
key={series.id}
|
key={seriesItem.id}
|
||||||
onClick={() => router.push(`/series/${series.id}`)}
|
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
||||||
className={cn(
|
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",
|
"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]"
|
isCompact && "aspect-[3/4]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series as KomgaSeries}
|
series={seriesItem}
|
||||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
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>
|
||||||
<span className="text-xs text-white/80">
|
<span className="text-xs text-white/80">
|
||||||
{t("series.books", { count: series.booksCount })}
|
{t("series.books", { count: seriesItem.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KomgaSeries } from "@/types/komga";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import { SeriesCover } from "@/components/ui/series-cover";
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -10,28 +10,28 @@ import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
interface SeriesListProps {
|
interface SeriesListProps {
|
||||||
series: KomgaSeries[];
|
series: NormalizedSeries[];
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SeriesListItemProps {
|
interface SeriesListItemProps {
|
||||||
series: KomgaSeries;
|
series: NormalizedSeries;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to get reading status info
|
// Utility function to get reading status info
|
||||||
const getReadingStatusInfo = (
|
const getReadingStatusInfo = (
|
||||||
series: KomgaSeries,
|
series: NormalizedSeries,
|
||||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||||
) => {
|
) => {
|
||||||
if (series.booksCount === 0) {
|
if (series.bookCount === 0) {
|
||||||
return {
|
return {
|
||||||
label: t("series.status.noBooks"),
|
label: t("series.status.noBooks"),
|
||||||
className: "bg-yellow-500/10 text-yellow-500",
|
className: "bg-yellow-500/10 text-yellow-500",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (series.booksCount === series.booksReadCount) {
|
if (series.bookCount === series.booksReadCount) {
|
||||||
return {
|
return {
|
||||||
label: t("series.status.read"),
|
label: t("series.status.read"),
|
||||||
className: "bg-green-500/10 text-green-500",
|
className: "bg-green-500/10 text-green-500",
|
||||||
@@ -42,7 +42,7 @@ const getReadingStatusInfo = (
|
|||||||
return {
|
return {
|
||||||
label: t("series.status.progress", {
|
label: t("series.status.progress", {
|
||||||
read: series.booksReadCount,
|
read: series.booksReadCount,
|
||||||
total: series.booksCount,
|
total: series.bookCount,
|
||||||
}),
|
}),
|
||||||
className: "bg-primary/15 text-primary",
|
className: "bg-primary/15 text-primary",
|
||||||
};
|
};
|
||||||
@@ -62,9 +62,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
router.push(`/series/${series.id}`);
|
router.push(`/series/${series.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCompleted = series.booksCount === series.booksReadCount;
|
const isCompleted = series.bookCount === series.booksReadCount;
|
||||||
const progressPercentage =
|
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);
|
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">
|
<div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series}
|
series={series}
|
||||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
alt={t("series.coverAlt", { title: series.name })}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
{/* Titre et statut */}
|
{/* Titre et statut */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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">
|
<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>
|
</h3>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -108,15 +108,15 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BookOpen className="h-3 w-3" />
|
<BookOpen className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{series.booksCount === 1
|
{series.bookCount === 1
|
||||||
? t("series.book", { count: 1 })
|
? t("series.book", { count: 1 })
|
||||||
: t("series.books", { count: series.booksCount })}
|
: t("series.books", { count: series.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1 hidden sm:flex">
|
||||||
<User className="h-3 w-3" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series}
|
series={series}
|
||||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
alt={t("series.coverAlt", { title: series.name })}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
|
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
|
||||||
{series.metadata.title}
|
{series.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,9 +164,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Résumé */}
|
{/* Résumé */}
|
||||||
{series.metadata.summary && (
|
{series.summary && (
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
|
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
|
||||||
{series.metadata.summary}
|
{series.summary}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -176,55 +176,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BookOpen className="h-3 w-3" />
|
<BookOpen className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{series.booksCount === 1
|
{series.bookCount === 1
|
||||||
? t("series.book", { count: 1 })
|
? t("series.book", { count: 1 })
|
||||||
: t("series.books", { count: series.booksCount })}
|
: t("series.books", { count: series.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auteurs */}
|
{/* Auteurs */}
|
||||||
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
|
{series.authors && series.authors.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
|
{series.authors.map((a) => a.name).join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date de création */}
|
{/* Date de création */}
|
||||||
{series.created && (
|
{series.createdAt && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
<span>{formatDate(series.created)}</span>
|
<span>{formatDate(series.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Genres */}
|
{/* Genres */}
|
||||||
{series.metadata.genres && series.metadata.genres.length > 0 && (
|
{series.genres && series.genres.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{series.metadata.genres.slice(0, 3).join(", ")}
|
{series.genres.slice(0, 3).join(", ")}
|
||||||
{series.metadata.genres.length > 3 && ` +${series.metadata.genres.length - 3}`}
|
{series.genres.length > 3 && ` +${series.genres.length - 3}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{series.metadata.tags && series.metadata.tags.length > 0 && (
|
{series.tags && series.tags.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Tag className="h-3 w-3" />
|
<Tag className="h-3 w-3" />
|
||||||
<span className="line-clamp-1">
|
<span className="line-clamp-1">
|
||||||
{series.metadata.tags.slice(0, 3).join(", ")}
|
{series.tags.slice(0, 3).join(", ")}
|
||||||
{series.metadata.tags.length > 3 && ` +${series.metadata.tags.length - 3}`}
|
{series.tags.length > 3 && ` +${series.tags.length - 3}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barre de progression */}
|
{/* Barre de progression */}
|
||||||
{series.booksCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Progress value={progressPercentage} className="h-2" />
|
<Progress value={progressPercentage} className="h-2" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { ClientBookWrapper } from "./ClientBookWrapper";
|
|||||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
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 logger from "@/lib/logger";
|
||||||
import { getBookData } from "@/app/actions/books";
|
import { getBookData } from "@/app/actions/books";
|
||||||
|
|
||||||
interface ClientBookPageProps {
|
interface ClientBookPageProps {
|
||||||
bookId: string;
|
bookId: string;
|
||||||
initialData?: {
|
initialData?: {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
nextBook: KomgaBook | null;
|
nextBook: NormalizedBook | null;
|
||||||
};
|
};
|
||||||
initialError?: string;
|
initialError?: string;
|
||||||
}
|
}
|
||||||
@@ -23,9 +23,9 @@ export function ClientBookPage({ bookId, initialData, initialError }: ClientBook
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
nextBook: KomgaBook | null;
|
nextBook: NormalizedBook | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Use SSR data if available
|
// Use SSR data if available
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import { PhotoswipeReader } from "./PhotoswipeReader";
|
import { PhotoswipeReader } from "./PhotoswipeReader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ClientBookReaderProps {
|
interface ClientBookReaderProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import { PhotoswipeReader } from "./PhotoswipeReader";
|
import { PhotoswipeReader } from "./PhotoswipeReader";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||||
|
|
||||||
interface ClientBookWrapperProps {
|
interface ClientBookWrapperProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
nextBook: KomgaBook | null;
|
nextBook: NormalizedBook | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
|
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
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) => {
|
const handleCloseReader = (currentPage: number) => {
|
||||||
ClientOfflineBookService.setCurrentPage(book, currentPage);
|
ClientOfflineBookService.setCurrentPage(book, currentPage);
|
||||||
setTargetPath(`/series/${book.seriesId}`);
|
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isClosing) {
|
if (isClosing) {
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
const lastClickTimeRef = useRef<number>(0);
|
const lastClickTimeRef = useRef<number>(0);
|
||||||
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
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
|
// Hooks
|
||||||
const { direction, toggleDirection, isRTL } = useReadingDirection();
|
const { direction, toggleDirection, isRTL } = useReadingDirection();
|
||||||
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
const { isFullscreen, toggleFullscreen } = useFullscreen();
|
||||||
@@ -38,10 +49,10 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
getPageUrl,
|
getPageUrl,
|
||||||
prefetchCount,
|
prefetchCount,
|
||||||
} = useImageLoader({
|
} = useImageLoader({
|
||||||
bookId: book.id,
|
pageUrlBuilder: bookPageUrlBuilder,
|
||||||
pages,
|
pages,
|
||||||
prefetchCount: preferences.readerPrefetchCount,
|
prefetchCount: preferences.readerPrefetchCount,
|
||||||
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
|
nextBook: nextBook ? { getPageUrl: nextBookPageUrlBuilder, pages: [] } : null,
|
||||||
});
|
});
|
||||||
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
|
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
|
||||||
usePageNavigation({
|
usePageNavigation({
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ interface ImageDimensions {
|
|||||||
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
|
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
|
||||||
|
|
||||||
interface UseImageLoaderProps {
|
interface UseImageLoaderProps {
|
||||||
bookId: string;
|
pageUrlBuilder: (pageNum: number) => string;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
prefetchCount?: number; // Nombre de pages à précharger (défaut: 5)
|
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({
|
export function useImageLoader({
|
||||||
bookId,
|
pageUrlBuilder,
|
||||||
pages: _pages,
|
pages: _pages,
|
||||||
prefetchCount = 5,
|
prefetchCount = 5,
|
||||||
nextBook,
|
nextBook,
|
||||||
@@ -73,8 +73,8 @@ export function useImageLoader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getPageUrl = useCallback(
|
const getPageUrl = useCallback(
|
||||||
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
|
(pageNum: number) => pageUrlBuilder(pageNum),
|
||||||
[bookId]
|
[pageUrlBuilder]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch image and store dimensions
|
// Prefetch image and store dimensions
|
||||||
@@ -216,7 +216,7 @@ export function useImageLoader({
|
|||||||
abortControllersRef.current.set(nextBookPageKey, controller);
|
abortControllersRef.current.set(nextBookPageKey, controller);
|
||||||
|
|
||||||
try {
|
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
|
cache: "default", // Respect Cache-Control headers from server
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
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 logger from "@/lib/logger";
|
||||||
import { updateReadProgress } from "@/app/actions/read-progress";
|
import { updateReadProgress } from "@/app/actions/read-progress";
|
||||||
|
|
||||||
interface UsePageNavigationProps {
|
interface UsePageNavigationProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
isDoublePage: boolean;
|
isDoublePage: boolean;
|
||||||
shouldShowDoublePage: (page: number) => boolean;
|
shouldShowDoublePage: (page: number) => boolean;
|
||||||
onClose?: (currentPage: number) => void;
|
onClose?: (currentPage: number) => void;
|
||||||
nextBook?: KomgaBook | null;
|
nextBook?: NormalizedBook | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePageNavigation({
|
export function usePageNavigation({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
|
|
||||||
interface UseThumbnailsProps {
|
interface UseThumbnailsProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,9 +16,13 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
|
|||||||
|
|
||||||
const getThumbnailUrl = useCallback(
|
const getThumbnailUrl = useCallback(
|
||||||
(pageNumber: number) => {
|
(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`;
|
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
|
// 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 {
|
export interface PageCache {
|
||||||
[pageNumber: number]: {
|
[pageNumber: number]: {
|
||||||
@@ -10,10 +10,10 @@ export interface PageCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookReaderProps {
|
export interface BookReaderProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
onClose?: (currentPage: number) => void;
|
onClose?: (currentPage: number) => void;
|
||||||
nextBook?: KomgaBook | null;
|
nextBook?: NormalizedBook | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThumbnailProps {
|
export interface ThumbnailProps {
|
||||||
@@ -32,7 +32,7 @@ export interface NavigationBarProps {
|
|||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
showThumbnails: boolean;
|
showThumbnails: boolean;
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlButtonsProps {
|
export interface ControlButtonsProps {
|
||||||
@@ -57,7 +57,7 @@ export interface ControlButtonsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UsePageNavigationProps {
|
export interface UsePageNavigationProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
isDoublePage: boolean;
|
isDoublePage: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import { BookCover } from "@/components/ui/book-cover";
|
import { BookCover } from "@/components/ui/book-cover";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -8,16 +8,16 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
|
|
||||||
interface BookGridProps {
|
interface BookGridProps {
|
||||||
books: KomgaBook[];
|
books: NormalizedBook[];
|
||||||
onBookClick: (book: KomgaBook) => void;
|
onBookClick: (book: NormalizedBook) => void;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookCardProps {
|
interface BookCardProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
onBookClick: (book: KomgaBook) => void;
|
onBookClick: (book: NormalizedBook) => void;
|
||||||
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
|
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +50,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
|
|||||||
book={book}
|
book={book}
|
||||||
alt={t("books.coverAlt", {
|
alt={t("books.coverAlt", {
|
||||||
title:
|
title:
|
||||||
book.metadata.title ||
|
book.title ||
|
||||||
(book.metadata.number
|
(book.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.number })
|
||||||
: ""),
|
: ""),
|
||||||
})}
|
})}
|
||||||
onSuccess={(book, action) => onSuccess(book, action)}
|
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") {
|
if (action === "read") {
|
||||||
setLocalBooks(
|
setLocalBooks(
|
||||||
localBooks.map((previousBook) =>
|
localBooks.map((previousBook) =>
|
||||||
@@ -93,10 +93,8 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
|
|||||||
...previousBook,
|
...previousBook,
|
||||||
readProgress: {
|
readProgress: {
|
||||||
completed: true,
|
completed: true,
|
||||||
page: previousBook.media.pagesCount,
|
page: previousBook.pageCount,
|
||||||
readDate: new Date().toISOString(),
|
lastReadAt: new Date().toISOString(),
|
||||||
created: new Date().toISOString(),
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: previousBook
|
: previousBook
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import { BookCover } from "@/components/ui/book-cover";
|
import { BookCover } from "@/components/ui/book-cover";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -9,22 +9,22 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||||
import { Progress } from "@/components/ui/progress";
|
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 { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
||||||
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
||||||
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
||||||
|
|
||||||
interface BookListProps {
|
interface BookListProps {
|
||||||
books: KomgaBook[];
|
books: NormalizedBook[];
|
||||||
onBookClick: (book: KomgaBook) => void;
|
onBookClick: (book: NormalizedBook) => void;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookListItemProps {
|
interface BookListItemProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
onBookClick: (book: KomgaBook) => void;
|
onBookClick: (book: NormalizedBook) => void;
|
||||||
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
|
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
const isRead = book.readProgress?.completed || false;
|
const isRead = book.readProgress?.completed || false;
|
||||||
const hasReadProgress = book.readProgress !== null;
|
const hasReadProgress = book.readProgress !== null;
|
||||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||||
const totalPages = book.media.pagesCount;
|
const totalPages = book.pageCount;
|
||||||
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
||||||
|
|
||||||
const getStatusInfo = () => {
|
const getStatusInfo = () => {
|
||||||
@@ -52,7 +52,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (book.readProgress.completed) {
|
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 {
|
return {
|
||||||
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
||||||
className: "bg-green-500/10 text-green-500",
|
className: "bg-green-500/10 text-green-500",
|
||||||
@@ -77,8 +77,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
|
|
||||||
const statusInfo = getStatusInfo();
|
const statusInfo = getStatusInfo();
|
||||||
const title =
|
const title =
|
||||||
book.metadata.title ||
|
book.title ||
|
||||||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
|
(book.number ? t("navigation.volume", { number: book.number }) : "");
|
||||||
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
@@ -130,8 +130,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
|
|
||||||
{/* Métadonnées minimales */}
|
{/* Métadonnées minimales */}
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
{book.metadata.number && (
|
{book.number && (
|
||||||
<span>{t("navigation.volume", { number: book.metadata.number })}</span>
|
<span>{t("navigation.volume", { number: book.number })}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileText className="h-3 w-3" />
|
<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")}
|
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{book.metadata.number && (
|
{book.number && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{t("navigation.volume", { number: book.metadata.number })}
|
{t("navigation.volume", { number: book.number })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Métadonnées */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||||
{/* Pages */}
|
{/* Pages */}
|
||||||
@@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
|
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Barre de progression */}
|
{/* Barre de progression */}
|
||||||
@@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
{!isRead && (
|
{!isRead && (
|
||||||
<MarkAsReadButton
|
<MarkAsReadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
pagesCount={book.media.pagesCount}
|
pagesCount={book.pageCount}
|
||||||
isRead={isRead}
|
isRead={isRead}
|
||||||
onSuccess={() => onSuccess(book, "read")}
|
onSuccess={() => onSuccess(book, "read")}
|
||||||
className="text-xs"
|
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") {
|
if (action === "read") {
|
||||||
setLocalBooks(
|
setLocalBooks(
|
||||||
localBooks.map((previousBook) =>
|
localBooks.map((previousBook) =>
|
||||||
@@ -320,10 +278,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
|
|||||||
...previousBook,
|
...previousBook,
|
||||||
readProgress: {
|
readProgress: {
|
||||||
completed: true,
|
completed: true,
|
||||||
page: previousBook.media.pagesCount,
|
page: previousBook.pageCount,
|
||||||
readDate: new Date().toISOString(),
|
lastReadAt: new Date().toISOString(),
|
||||||
created: new Date().toISOString(),
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: previousBook
|
: previousBook
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BookList } from "./BookList";
|
|||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
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 { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
||||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
@@ -15,7 +15,7 @@ import { ViewModeButton } from "@/components/common/ViewModeButton";
|
|||||||
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
||||||
|
|
||||||
interface PaginatedBookGridProps {
|
interface PaginatedBookGridProps {
|
||||||
books: KomgaBook[];
|
books: NormalizedBook[];
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalElements: number;
|
totalElements: number;
|
||||||
@@ -95,13 +95,10 @@ export function PaginatedBookGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeChange = async (size: number) => {
|
const handlePageSizeChange = async (size: number) => {
|
||||||
await updateUrlParams({
|
await updateUrlParams({ page: "1", size: size.toString() });
|
||||||
page: "1",
|
|
||||||
size: size.toString(),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBookClick = (book: KomgaBook) => {
|
const handleBookClick = (book: NormalizedBook) => {
|
||||||
router.push(`/books/${book.id}`);
|
router.push(`/books/${book.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
|
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 { useState, useEffect } from "react";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||||
@@ -16,7 +16,7 @@ import logger from "@/lib/logger";
|
|||||||
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
||||||
|
|
||||||
interface SeriesHeaderProps {
|
interface SeriesHeaderProps {
|
||||||
series: KomgaSeries;
|
series: NormalizedSeries;
|
||||||
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
|
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
initialIsFavorite: boolean;
|
initialIsFavorite: boolean;
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
toast({
|
toast({
|
||||||
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
|
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
|
||||||
description: series.metadata.title,
|
description: series.name,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@@ -69,10 +69,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getReadingStatusInfo = () => {
|
const getReadingStatusInfo = () => {
|
||||||
const { booksCount, booksReadCount, booksUnreadCount } = series;
|
const { bookCount, booksReadCount } = series;
|
||||||
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount);
|
const booksUnreadCount = bookCount - booksReadCount;
|
||||||
|
const booksInProgressCount = bookCount - (booksReadCount + booksUnreadCount);
|
||||||
|
|
||||||
if (booksReadCount === booksCount) {
|
if (booksReadCount === bookCount) {
|
||||||
return {
|
return {
|
||||||
label: t("series.header.status.read"),
|
label: t("series.header.status.read"),
|
||||||
status: "success" as const,
|
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 {
|
return {
|
||||||
label: t("series.header.status.progress", {
|
label: t("series.header.status.progress", {
|
||||||
read: booksReadCount,
|
read: booksReadCount,
|
||||||
total: booksCount,
|
total: bookCount,
|
||||||
}),
|
}),
|
||||||
status: "reading" as const,
|
status: "reading" as const,
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
@@ -105,8 +106,8 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
{/* Image de fond */}
|
{/* Image de fond */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series as KomgaSeries}
|
series={series}
|
||||||
alt={t("series.header.coverAlt", { title: series.metadata.title })}
|
alt={t("series.header.coverAlt", { title: series.name })}
|
||||||
className="blur-sm scale-105 brightness-50"
|
className="blur-sm scale-105 brightness-50"
|
||||||
showProgressUi={false}
|
showProgressUi={false}
|
||||||
/>
|
/>
|
||||||
@@ -118,18 +119,18 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
{/* Image principale */}
|
{/* 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">
|
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={series as KomgaSeries}
|
series={series}
|
||||||
alt={t("series.header.coverAlt", { title: series.metadata.title })}
|
alt={t("series.header.coverAlt", { title: series.name })}
|
||||||
showProgressUi={false}
|
showProgressUi={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Informations */}
|
{/* Informations */}
|
||||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
<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>
|
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
||||||
{series.metadata.summary && (
|
{series.summary && (
|
||||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
||||||
{series.metadata.summary}
|
{series.summary}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
<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}
|
{statusInfo.label}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span className="text-sm text-white/80">
|
<span className="text-sm text-white/80">
|
||||||
{series.booksCount === 1
|
{series.bookCount === 1
|
||||||
? t("series.header.books", { count: series.booksCount })
|
? t("series.header.books", { count: series.bookCount })
|
||||||
: t("series.header.books_plural", { count: series.booksCount })}
|
: t("series.header.books_plural", { count: series.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { Check } from "lucide-react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { SliderControl } from "@/components/ui/slider-control";
|
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";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface BackgroundSettingsProps {
|
interface BackgroundSettingsProps {
|
||||||
initialLibraries: KomgaLibrary[];
|
initialLibraries: NormalizedLibrary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
|
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
|
||||||
@@ -27,7 +27,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
|
|||||||
const { preferences, updatePreferences } = usePreferences();
|
const { preferences, updatePreferences } = usePreferences();
|
||||||
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
|
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
|
||||||
const [komgaConfigValid, setKomgaConfigValid] = useState(false);
|
const [komgaConfigValid, setKomgaConfigValid] = useState(false);
|
||||||
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []);
|
const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
|
||||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
|
const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
|
||||||
preferences.background.komgaLibraries || []
|
preferences.background.komgaLibraries || []
|
||||||
);
|
);
|
||||||
@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
|
|||||||
htmlFor={`lib-${library.id}`}
|
htmlFor={`lib-${library.id}`}
|
||||||
className="cursor-pointer font-normal text-sm"
|
className="cursor-pointer font-normal text-sm"
|
||||||
>
|
>
|
||||||
{library.name} ({library.booksCount} livres)
|
{library.name} ({library.bookCount} livres)
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { KomgaConfig } from "@/types/komga";
|
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 { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { DisplaySettings } from "./DisplaySettings";
|
import { DisplaySettings } from "./DisplaySettings";
|
||||||
import { KomgaSettings } from "./KomgaSettings";
|
import { KomgaSettings } from "./KomgaSettings";
|
||||||
|
import { StripstreamSettings } from "./StripstreamSettings";
|
||||||
|
import { ProviderSelector } from "./ProviderSelector";
|
||||||
import { BackgroundSettings } from "./BackgroundSettings";
|
import { BackgroundSettings } from "./BackgroundSettings";
|
||||||
import { AdvancedSettings } from "./AdvancedSettings";
|
import { AdvancedSettings } from "./AdvancedSettings";
|
||||||
import { CacheSettings } from "./CacheSettings";
|
import { CacheSettings } from "./CacheSettings";
|
||||||
@@ -14,12 +16,23 @@ import { Monitor, Network } from "lucide-react";
|
|||||||
|
|
||||||
interface ClientSettingsProps {
|
interface ClientSettingsProps {
|
||||||
initialConfig: KomgaConfig | null;
|
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";
|
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 { t } = useTranslate();
|
||||||
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
|
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
|
||||||
|
|
||||||
@@ -63,7 +76,18 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="connection" className="mt-6 space-y-6">
|
<TabsContent value="connection" className="mt-6 space-y-6">
|
||||||
|
{providersStatus && (
|
||||||
|
<ProviderSelector
|
||||||
|
activeProvider={providersStatus.activeProvider}
|
||||||
|
komgaConfigured={providersStatus.komgaConfigured}
|
||||||
|
stripstreamConfigured={providersStatus.stripstreamConfigured}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<KomgaSettings initialConfig={initialConfig} />
|
<KomgaSettings initialConfig={initialConfig} />
|
||||||
|
<StripstreamSettings
|
||||||
|
initialUrl={stripstreamConfig?.url}
|
||||||
|
hasToken={stripstreamConfig?.hasToken}
|
||||||
|
/>
|
||||||
<AdvancedSettings />
|
<AdvancedSettings />
|
||||||
<CacheSettings />
|
<CacheSettings />
|
||||||
</TabsContent>
|
</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 { ProgressBar } from "./progress-bar";
|
||||||
import type { BookCoverProps } from "./cover-utils";
|
import type { BookCoverProps } from "./cover-utils";
|
||||||
import { getImageUrl } from "@/lib/utils/image-url";
|
|
||||||
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
|
||||||
import { MarkAsReadButton } from "./mark-as-read-button";
|
import { MarkAsReadButton } from "./mark-as-read-button";
|
||||||
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
import { MarkAsUnreadButton } from "./mark-as-unread-button";
|
||||||
import { BookOfflineButton } from "./book-offline-button";
|
import { BookOfflineButton } from "./book-offline-button";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
|
|
||||||
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
||||||
const getReadingStatusInfo = (
|
const getReadingStatusInfo = (
|
||||||
book: KomgaBook,
|
book: BookCoverProps["book"],
|
||||||
t: (key: string, options?: { [key: string]: string | number }) => string
|
t: (key: string, options?: { [key: string]: string | number }) => string
|
||||||
) => {
|
) => {
|
||||||
if (!book.readProgress) {
|
if (!book.readProgress) {
|
||||||
@@ -26,7 +24,7 @@ const getReadingStatusInfo = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (book.readProgress.completed) {
|
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 {
|
return {
|
||||||
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
|
||||||
className: "bg-green-500/10 text-green-500",
|
className: "bg-green-500/10 text-green-500",
|
||||||
@@ -39,7 +37,7 @@ const getReadingStatusInfo = (
|
|||||||
return {
|
return {
|
||||||
label: t("books.status.progress", {
|
label: t("books.status.progress", {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
total: book.media.pagesCount,
|
total: book.pageCount,
|
||||||
}),
|
}),
|
||||||
className: "bg-blue-500/10 text-blue-500",
|
className: "bg-blue-500/10 text-blue-500",
|
||||||
};
|
};
|
||||||
@@ -64,11 +62,10 @@ export function BookCover({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
const imageUrl = getImageUrl("book", book.id);
|
|
||||||
const isCompleted = book.readProgress?.completed || false;
|
const isCompleted = book.readProgress?.completed || false;
|
||||||
|
|
||||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||||
const totalPages = book.media.pagesCount;
|
const totalPages = book.pageCount;
|
||||||
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo(book, t);
|
const statusInfo = getReadingStatusInfo(book, t);
|
||||||
@@ -90,7 +87,7 @@ export function BookCover({
|
|||||||
<>
|
<>
|
||||||
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
|
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
|
||||||
<img
|
<img
|
||||||
src={imageUrl.trim()}
|
src={book.thumbnailUrl.trim()}
|
||||||
alt={alt || t("books.defaultCoverAlt")}
|
alt={alt || t("books.defaultCoverAlt")}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={[
|
className={[
|
||||||
@@ -121,7 +118,7 @@ export function BookCover({
|
|||||||
{!isRead && (
|
{!isRead && (
|
||||||
<MarkAsReadButton
|
<MarkAsReadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
pagesCount={book.media.pagesCount}
|
pagesCount={book.pageCount}
|
||||||
isRead={isRead}
|
isRead={isRead}
|
||||||
onSuccess={() => handleMarkAsRead()}
|
onSuccess={() => handleMarkAsRead()}
|
||||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||||
@@ -143,9 +140,9 @@ export function BookCover({
|
|||||||
{showOverlay && overlayVariant === "default" && (
|
{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">
|
<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">
|
<p className="text-sm font-medium text-white text-left line-clamp-2">
|
||||||
{book.metadata.title ||
|
{book.title ||
|
||||||
(book.metadata.number
|
(book.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.number })
|
||||||
: "")}
|
: "")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -160,15 +157,15 @@ export function BookCover({
|
|||||||
{showOverlay && overlayVariant === "home" && (
|
{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">
|
<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">
|
<h3 className="font-medium text-sm text-white line-clamp-2">
|
||||||
{book.metadata.title ||
|
{book.title ||
|
||||||
(book.metadata.number
|
(book.number
|
||||||
? t("navigation.volume", { number: book.metadata.number })
|
? t("navigation.volume", { number: book.number })
|
||||||
: "")}
|
: "")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-white/80 mt-1">
|
<p className="text-xs text-white/80 mt-1">
|
||||||
{t("books.status.progress", {
|
{t("books.status.progress", {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
total: book.media.pagesCount,
|
total: book.pageCount,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { Download, Check, Loader2 } from "lucide-react";
|
import { Download, Check, Loader2 } from "lucide-react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { useToast } from "./use-toast";
|
import { useToast } from "./use-toast";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface BookOfflineButtonProps {
|
interface BookOfflineButtonProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
// Marque le début du téléchargement
|
// Marque le début du téléchargement
|
||||||
setBookStatus(book.id, {
|
setBookStatus(book.id, {
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
|
progress: ((startFromPage - 1) / book.pageCount) * 100,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
lastDownloadedPage: startFromPage - 1,
|
lastDownloadedPage: startFromPage - 1,
|
||||||
});
|
});
|
||||||
@@ -71,7 +71,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
|
|
||||||
// Cache chaque page avec retry
|
// Cache chaque page avec retry
|
||||||
let failedPages = 0;
|
let failedPages = 0;
|
||||||
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
|
for (let i = startFromPage; i <= book.pageCount; i++) {
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour du statut
|
// Mise à jour du statut
|
||||||
const progress = (i / book.media.pagesCount) * 100;
|
const progress = (i / book.pageCount) * 100;
|
||||||
setDownloadProgress(progress);
|
setDownloadProgress(progress);
|
||||||
setBookStatus(book.id, {
|
setBookStatus(book.id, {
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
@@ -125,7 +125,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
if (failedPages > 0) {
|
if (failedPages > 0) {
|
||||||
// Si des pages ont échoué, on supprime tout le cache pour ce livre
|
// Si des pages ont échoué, on supprime tout le cache pour ce livre
|
||||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
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}`);
|
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||||
}
|
}
|
||||||
setIsAvailableOffline(false);
|
setIsAvailableOffline(false);
|
||||||
@@ -159,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
|
[book.id, book.pageCount, getBookStatus, setBookStatus, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkOfflineAvailability = useCallback(async () => {
|
const checkOfflineAvailability = useCallback(async () => {
|
||||||
@@ -177,7 +177,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
|
|
||||||
// Vérifie que toutes les pages sont dans le cache
|
// Vérifie que toutes les pages sont dans le cache
|
||||||
let allPagesAvailable = true;
|
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}`);
|
const page = await cache.match(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
allPagesAvailable = false;
|
allPagesAvailable = false;
|
||||||
@@ -195,7 +195,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
logger.error({ err: error }, "Erreur lors de la vérification du cache:");
|
logger.error({ err: error }, "Erreur lors de la vérification du cache:");
|
||||||
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
|
||||||
}
|
}
|
||||||
}, [book.id, book.media.pagesCount, setBookStatus]);
|
}, [book.id, book.pageCount, setBookStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
@@ -242,9 +242,9 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
|
|||||||
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
|
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
|
||||||
// Supprime le livre du cache
|
// Supprime le livre du cache
|
||||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
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}`);
|
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);
|
setDownloadProgress(progress);
|
||||||
}
|
}
|
||||||
setIsAvailableOffline(false);
|
setIsAvailableOffline(false);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
|
||||||
|
|
||||||
export interface BaseCoverProps {
|
export interface BaseCoverProps {
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@@ -9,13 +9,13 @@ export interface BaseCoverProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookCoverProps extends BaseCoverProps {
|
export interface BookCoverProps extends BaseCoverProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
onSuccess?: (book: KomgaBook, action: "read" | "unread") => void;
|
onSuccess?: (book: NormalizedBook, action: "read" | "unread") => void;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
showOverlay?: boolean;
|
showOverlay?: boolean;
|
||||||
overlayVariant?: "default" | "home";
|
overlayVariant?: "default" | "home";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeriesCoverProps extends BaseCoverProps {
|
export interface SeriesCoverProps extends BaseCoverProps {
|
||||||
series: KomgaSeries;
|
series: NormalizedSeries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ProgressBar } from "./progress-bar";
|
import { ProgressBar } from "./progress-bar";
|
||||||
import type { SeriesCoverProps } from "./cover-utils";
|
import type { SeriesCoverProps } from "./cover-utils";
|
||||||
import { getImageUrl } from "@/lib/utils/image-url";
|
|
||||||
|
|
||||||
export function SeriesCover({
|
export function SeriesCover({
|
||||||
series,
|
series,
|
||||||
@@ -8,17 +7,16 @@ export function SeriesCover({
|
|||||||
className,
|
className,
|
||||||
showProgressUi = true,
|
showProgressUi = true,
|
||||||
}: SeriesCoverProps) {
|
}: SeriesCoverProps) {
|
||||||
const imageUrl = getImageUrl("series", series.id);
|
const isCompleted = series.bookCount === series.booksReadCount;
|
||||||
const isCompleted = series.booksCount === series.booksReadCount;
|
|
||||||
|
|
||||||
const readBooks = series.booksReadCount;
|
const readBooks = series.booksReadCount;
|
||||||
const totalBooks = series.booksCount;
|
const totalBooks = series.bookCount;
|
||||||
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={series.thumbnailUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={[
|
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",
|
HTTP_ERROR: "KOMGA_HTTP_ERROR",
|
||||||
SERVER_UNREACHABLE: "KOMGA_SERVER_UNREACHABLE",
|
SERVER_UNREACHABLE: "KOMGA_SERVER_UNREACHABLE",
|
||||||
},
|
},
|
||||||
|
STRIPSTREAM: {
|
||||||
|
MISSING_CONFIG: "STRIPSTREAM_MISSING_CONFIG",
|
||||||
|
CONNECTION_ERROR: "STRIPSTREAM_CONNECTION_ERROR",
|
||||||
|
HTTP_ERROR: "STRIPSTREAM_HTTP_ERROR",
|
||||||
|
},
|
||||||
CONFIG: {
|
CONFIG: {
|
||||||
SAVE_ERROR: "CONFIG_SAVE_ERROR",
|
SAVE_ERROR: "CONFIG_SAVE_ERROR",
|
||||||
FETCH_ERROR: "CONFIG_FETCH_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.LOGOUT_ERROR]: "🚪 Error during logout",
|
||||||
[ERROR_CODES.AUTH.REGISTRATION_FAILED]: "❌ Registration failed",
|
[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
|
// Komga
|
||||||
[ERROR_CODES.KOMGA.MISSING_CONFIG]: "⚙️ Komga configuration not found",
|
[ERROR_CODES.KOMGA.MISSING_CONFIG]: "⚙️ Komga configuration not found",
|
||||||
[ERROR_CODES.KOMGA.MISSING_CREDENTIALS]: "🔑 Missing Komga credentials",
|
[ERROR_CODES.KOMGA.MISSING_CREDENTIALS]: "🔑 Missing Komga credentials",
|
||||||
|
|||||||
@@ -377,6 +377,9 @@
|
|||||||
"KOMGA_CONNECTION_ERROR": "Error connecting to Komga server",
|
"KOMGA_CONNECTION_ERROR": "Error connecting to Komga server",
|
||||||
"KOMGA_HTTP_ERROR": "HTTP error while communicating with Komga",
|
"KOMGA_HTTP_ERROR": "HTTP error while communicating with Komga",
|
||||||
"KOMGA_SERVER_UNREACHABLE": "Komga server unreachable",
|
"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_SAVE_ERROR": "Error saving configuration",
|
||||||
"CONFIG_FETCH_ERROR": "Error fetching configuration",
|
"CONFIG_FETCH_ERROR": "Error fetching configuration",
|
||||||
|
|||||||
@@ -375,6 +375,9 @@
|
|||||||
"KOMGA_CONNECTION_ERROR": "Erreur de connexion au serveur Komga",
|
"KOMGA_CONNECTION_ERROR": "Erreur de connexion au serveur Komga",
|
||||||
"KOMGA_HTTP_ERROR": "Erreur HTTP lors de la communication avec Komga",
|
"KOMGA_HTTP_ERROR": "Erreur HTTP lors de la communication avec Komga",
|
||||||
"KOMGA_SERVER_UNREACHABLE": "Serveur Komga inaccessible",
|
"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_SAVE_ERROR": "Erreur lors de la sauvegarde de la configuration",
|
||||||
"CONFIG_FETCH_ERROR": "Erreur lors de la récupération 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 { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
|
|
||||||
type ErrorWithStatusParams = AppError & { params?: { status?: number } };
|
export class BookService {
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
|
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
|
||||||
const adjustedPageNumber = pageNumber - 1;
|
const adjustedPageNumber = pageNumber - 1;
|
||||||
// Stream directement sans buffer en mémoire
|
|
||||||
return ImageService.streamImage(
|
return ImageService.streamImage(
|
||||||
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
||||||
);
|
);
|
||||||
@@ -133,32 +17,18 @@ export class BookService extends BaseApiService {
|
|||||||
|
|
||||||
static async getCover(bookId: string): Promise<Response> {
|
static async getCover(bookId: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Récupérer les préférences de l'utilisateur
|
|
||||||
const preferences = await PreferencesService.getPreferences();
|
const preferences = await PreferencesService.getPreferences();
|
||||||
|
|
||||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
|
||||||
if (preferences.showThumbnails) {
|
if (preferences.showThumbnails) {
|
||||||
return ImageService.streamImage(`books/${bookId}/thumbnail`);
|
return ImageService.streamImage(`books/${bookId}/thumbnail`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinon, récupérer la première page (streaming)
|
|
||||||
return this.getPage(bookId, 1);
|
return this.getPage(bookId, 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, 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> {
|
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Stream directement sans buffer en mémoire
|
|
||||||
return ImageService.streamImage(
|
return ImageService.streamImage(
|
||||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
`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);
|
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 {
|
export class ClientOfflineBookService {
|
||||||
static setCurrentPage(book: KomgaBook, page: number) {
|
static setCurrentPage(book: NormalizedBook, page: number) {
|
||||||
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`${book.id}-page`, page.toString());
|
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;
|
const readProgressPage = book.readProgress?.page || 0;
|
||||||
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
||||||
try {
|
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) {
|
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(`${book.id}-page`);
|
localStorage.removeItem(`${book.id}-page`);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export class FavoriteService {
|
|||||||
private static readonly FAVORITES_CHANGE_EVENT = "favoritesChanged";
|
private static readonly FAVORITES_CHANGE_EVENT = "favoritesChanged";
|
||||||
|
|
||||||
private static dispatchFavoritesChanged() {
|
private static dispatchFavoritesChanged() {
|
||||||
// Dispatch l'événement pour notifier les changements
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(new Event(FavoriteService.FAVORITES_CHANGE_EVENT));
|
window.dispatchEvent(new Event(FavoriteService.FAVORITES_CHANGE_EVENT));
|
||||||
}
|
}
|
||||||
@@ -23,19 +22,26 @@ export class FavoriteService {
|
|||||||
return user;
|
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> {
|
static async isFavorite(seriesId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const user = await this.getCurrentUser();
|
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
const favorite = await prisma.favorite.findFirst({
|
const favorite = await prisma.favorite.findFirst({
|
||||||
where: {
|
where: { userId, seriesId, provider },
|
||||||
userId,
|
|
||||||
seriesId: seriesId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return !!favorite;
|
return !!favorite;
|
||||||
} catch (error) {
|
} 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> {
|
static async addToFavorites(seriesId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const user = await this.getCurrentUser();
|
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
await prisma.favorite.upsert({
|
await prisma.favorite.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_seriesId: {
|
userId_provider_seriesId: { userId, provider, seriesId },
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: { userId, provider, seriesId },
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dispatchFavoritesChanged();
|
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> {
|
static async removeFromFavorites(seriesId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const user = await this.getCurrentUser();
|
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
await prisma.favorite.deleteMany({
|
await prisma.favorite.deleteMany({
|
||||||
where: {
|
where: { userId, seriesId, provider },
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dispatchFavoritesChanged();
|
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[]> {
|
static async getAllFavoriteIds(): Promise<string[]> {
|
||||||
const user = await this.getCurrentUser();
|
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||||
const userId = parseInt(user.id, 10);
|
|
||||||
|
|
||||||
const favorites = await prisma.favorite.findMany({
|
const favorites = await prisma.favorite.findMany({
|
||||||
where: { userId },
|
where: { userId, provider },
|
||||||
select: { seriesId: true },
|
select: { seriesId: true },
|
||||||
});
|
});
|
||||||
return favorites.map((favorite) => favorite.seriesId);
|
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 { FavoriteService } from "./favorite.service";
|
||||||
import { SeriesService } from "./series.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import type { KomgaSeries } from "@/types/komga";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
static async getFavorites(context?: {
|
static async getFavorites(context?: {
|
||||||
requestPath?: string;
|
requestPath?: string;
|
||||||
requestPathname?: string;
|
requestPathname?: string;
|
||||||
}): Promise<KomgaSeries[]> {
|
}): Promise<NormalizedSeries[]> {
|
||||||
try {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch toutes les séries en parallèle
|
|
||||||
const promises = favoriteIds.map(async (id: string) => {
|
const promises = favoriteIds.map(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
return await SeriesService.getSeries(id);
|
return await provider.getSeriesById(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
@@ -40,7 +42,7 @@ export class FavoritesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
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) {
|
} catch (error) {
|
||||||
logger.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 { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
|
|
||||||
const IMAGE_CACHE_MAX_AGE = 2592000;
|
const IMAGE_CACHE_MAX_AGE = 2592000;
|
||||||
|
|
||||||
export class ImageService extends BaseApiService {
|
export class ImageService {
|
||||||
/**
|
|
||||||
* Stream an image directly from Komga without buffering in memory
|
|
||||||
* Returns a Response that can be directly returned to the client
|
|
||||||
*/
|
|
||||||
static async streamImage(
|
static async streamImage(
|
||||||
path: string,
|
path: string,
|
||||||
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
|
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
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, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -31,23 +33,8 @@ export class ImageService extends BaseApiService {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors du streaming de l'image");
|
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);
|
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 { KomgaBook } from "@/types/komga";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
|
||||||
import { BookService } from "./book.service";
|
import { BookService } from "./book.service";
|
||||||
import { ImageService } from "./image.service";
|
import { ImageService } from "./image.service";
|
||||||
import { PreferencesService } from "./preferences.service";
|
import { PreferencesService } from "./preferences.service";
|
||||||
|
import { ConfigDBService } from "./config-db.service";
|
||||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||||
import { AppError } from "../../utils/errors";
|
import { AppError } from "../../utils/errors";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
|
||||||
import logger from "@/lib/logger";
|
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 {
|
const url = new URL(`${config.url}/api/v1/series/${seriesId}/books`);
|
||||||
private static readonly CACHE_TTL = 120; // 2 minutes
|
url.searchParams.set("page", "0");
|
||||||
|
url.searchParams.set("size", "1");
|
||||||
|
|
||||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
const headers = new Headers({
|
||||||
try {
|
Authorization: `Basic ${config.authHeader}`,
|
||||||
return this.fetchFromApi<KomgaSeries>(
|
Accept: "application/json",
|
||||||
{ path: `series/${seriesId}` },
|
});
|
||||||
{},
|
|
||||||
{ revalidate: this.CACHE_TTL }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getSeriesBooks(
|
const response = await fetch(url.toString(), { headers });
|
||||||
seriesId: string,
|
if (!response.ok) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||||
page: number = 0,
|
|
||||||
size: number = 24,
|
|
||||||
unreadOnly: boolean = false
|
|
||||||
): Promise<LibraryResponse<KomgaBook>> {
|
|
||||||
try {
|
|
||||||
const headers = { "Content-Type": "application/json" };
|
|
||||||
|
|
||||||
// Construction du body de recherche pour Komga
|
const data: { content: KomgaBook[] } = await response.json();
|
||||||
let condition: KomgaCondition;
|
if (!data.content?.length) throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||||
|
|
||||||
if (unreadOnly) {
|
return data.content[0].id;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getCover(seriesId: string): Promise<Response> {
|
static async getCover(seriesId: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Récupérer les préférences de l'utilisateur
|
const preferences = await PreferencesService.getPreferences();
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
|
||||||
|
|
||||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
|
||||||
if (preferences.showThumbnails) {
|
if (preferences.showThumbnails) {
|
||||||
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
|
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
|
||||||
}
|
}
|
||||||
|
const firstBookId = await SeriesService.getFirstBook(seriesId);
|
||||||
// Sinon, récupérer la première page (streaming)
|
|
||||||
const firstBookId = await this.getFirstBook(seriesId);
|
|
||||||
return BookService.getPage(firstBookId, 1);
|
return BookService.getPage(firstBookId, 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
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 {
|
export interface HomeData {
|
||||||
ongoing: KomgaSeries[];
|
ongoing: NormalizedSeries[];
|
||||||
ongoingBooks: KomgaBook[];
|
ongoingBooks: NormalizedBook[];
|
||||||
recentlyRead: KomgaBook[];
|
recentlyRead: NormalizedBook[];
|
||||||
onDeck: KomgaBook[];
|
onDeck: NormalizedBook[];
|
||||||
latestSeries: KomgaSeries[];
|
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