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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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