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:
55
src/lib/providers/komga/komga.adapter.ts
Normal file
55
src/lib/providers/komga/komga.adapter.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { KomgaBook, KomgaSeries, KomgaLibrary, ReadProgress } from "@/types/komga";
|
||||
import type {
|
||||
NormalizedBook,
|
||||
NormalizedSeries,
|
||||
NormalizedLibrary,
|
||||
NormalizedReadProgress,
|
||||
} from "../types";
|
||||
|
||||
export class KomgaAdapter {
|
||||
static toNormalizedReadProgress(rp: ReadProgress | null): NormalizedReadProgress | null {
|
||||
if (!rp) return null;
|
||||
return {
|
||||
page: rp.page ?? null,
|
||||
completed: rp.completed,
|
||||
lastReadAt: rp.readDate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBook(book: KomgaBook): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.libraryId,
|
||||
title: book.metadata?.title || book.name,
|
||||
number: book.metadata?.number ?? null,
|
||||
seriesId: book.seriesId ?? null,
|
||||
volume: typeof book.number === "number" ? book.number : null,
|
||||
pageCount: book.media?.pagesCount ?? 0,
|
||||
thumbnailUrl: `/api/komga/images/books/${book.id}/thumbnail`,
|
||||
readProgress: KomgaAdapter.toNormalizedReadProgress(book.readProgress),
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedSeries(series: KomgaSeries): NormalizedSeries {
|
||||
return {
|
||||
id: series.id,
|
||||
name: series.metadata?.title ?? series.name,
|
||||
bookCount: series.booksCount,
|
||||
booksReadCount: series.booksReadCount,
|
||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||
summary: series.metadata?.summary ?? null,
|
||||
authors: series.booksMetadata?.authors ?? [],
|
||||
genres: series.metadata?.genres ?? [],
|
||||
tags: series.metadata?.tags ?? [],
|
||||
createdAt: series.created ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedLibrary(library: KomgaLibrary): NormalizedLibrary {
|
||||
return {
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
bookCount: library.booksCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
507
src/lib/providers/komga/komga.provider.ts
Normal file
507
src/lib/providers/komga/komga.provider.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type { IMediaProvider, BookListFilter } from "../provider.interface";
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "../types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { KomgaAdapter } from "./komga.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type { KomgaBook, KomgaSeries, KomgaLibrary } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import logger from "@/lib/logger";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
|
||||
const CACHE_TTL_LONG = 300;
|
||||
const CACHE_TTL_MED = 120;
|
||||
const CACHE_TTL_SHORT = 30;
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
export class KomgaProvider implements IMediaProvider {
|
||||
private config: AuthConfig;
|
||||
|
||||
constructor(url: string, authHeader: string) {
|
||||
this.config = { serverUrl: url, authHeader };
|
||||
}
|
||||
|
||||
private buildUrl(path: string, params?: Record<string, string | string[]>): string {
|
||||
const url = new URL(`${this.config.serverUrl}/api/v1/${path}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((val) => url.searchParams.append(k, val));
|
||||
} else {
|
||||
url.searchParams.append(k, v);
|
||||
}
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private getHeaders(extra: Record<string, string> = {}): Headers {
|
||||
return new Headers({
|
||||
Authorization: `Basic ${this.config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetch<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | string[]>,
|
||||
options: RequestInit & { revalidate?: number; tags?: string[] } = {}
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(path, params);
|
||||
const headers = this.getHeaders(options.body ? { "Content-Type": "application/json" } : {});
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined;
|
||||
|
||||
const fetchOptions = {
|
||||
headers,
|
||||
...options,
|
||||
next: nextOptions,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
interface FetchErrorLike {
|
||||
code?: string;
|
||||
cause?: { code?: string };
|
||||
}
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
return await fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (err: unknown) {
|
||||
const e = err as FetchErrorLike;
|
||||
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}, retrying...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await doFetch();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(
|
||||
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
|
||||
"🟢 Komga Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, status: response.status, statusText: response.statusText },
|
||||
"🔴 Komga Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration: `${Date.now() - startTime}ms`,
|
||||
},
|
||||
"🔴 Komga Request Failed"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async getLibraries(): Promise<NormalizedLibrary[]> {
|
||||
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
// Enrich with book counts
|
||||
const enriched = await Promise.all(
|
||||
raw.map(async (lib) => {
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return { ...lib, booksCount: resp.totalElements, booksReadCount: 0 } as KomgaLibrary;
|
||||
} catch {
|
||||
return { ...lib, booksCount: 0, booksReadCount: 0 } as KomgaLibrary;
|
||||
}
|
||||
})
|
||||
);
|
||||
return enriched.map(KomgaAdapter.toNormalizedLibrary);
|
||||
}
|
||||
|
||||
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
|
||||
const page = cursor ? parseInt(cursor, 10) - 1 : 0;
|
||||
|
||||
let condition: KomgaCondition;
|
||||
if (unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ libraryId: { operator: "is", value: libraryId } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||
}
|
||||
|
||||
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
|
||||
if (search) searchBody.fullTextSearch = search;
|
||||
|
||||
const response = await this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: String(page), size: String(limit), sort: "metadata.titleSort,asc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: CACHE_TTL_MED,
|
||||
tags: [LIBRARY_SERIES_CACHE_TAG],
|
||||
}
|
||||
);
|
||||
|
||||
const filtered = response.content.filter((s) => !s.deleted);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const ta = a.metadata?.titleSort ?? "";
|
||||
const tb = b.metadata?.titleSort ?? "";
|
||||
const cmp = ta.localeCompare(tb);
|
||||
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return {
|
||||
items: sorted.map(KomgaAdapter.toNormalizedSeries),
|
||||
nextCursor: response.last ? null : String(page + 1),
|
||||
totalPages: response.totalPages,
|
||||
totalElements: response.totalElements,
|
||||
};
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const page = filter.cursor ? parseInt(filter.cursor, 10) - 1 : 0;
|
||||
const limit = filter.limit ?? 24;
|
||||
let condition: KomgaCondition;
|
||||
|
||||
if (filter.seriesName && filter.unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ seriesId: { operator: "is", value: filter.seriesName } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (filter.seriesName) {
|
||||
condition = { seriesId: { operator: "is", value: filter.seriesName } };
|
||||
} else if (filter.libraryId) {
|
||||
condition = { libraryId: { operator: "is", value: filter.libraryId } };
|
||||
} else {
|
||||
condition = {};
|
||||
}
|
||||
|
||||
const response = await this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: String(page), size: String(limit), sort: "metadata.numberSort,asc" },
|
||||
{ method: "POST", body: JSON.stringify({ condition }), revalidate: CACHE_TTL_MED, tags: [SERIES_BOOKS_CACHE_TAG] }
|
||||
);
|
||||
const items = response.content.filter((b) => !b.deleted).map(KomgaAdapter.toNormalizedBook);
|
||||
return {
|
||||
items,
|
||||
nextCursor: response.last ? null : String(page + 1),
|
||||
totalPages: response.totalPages,
|
||||
totalElements: response.totalElements,
|
||||
};
|
||||
}
|
||||
|
||||
async getBook(bookId: string): Promise<NormalizedBook> {
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetch<KomgaBook>(`books/${bookId}`, undefined, { revalidate: CACHE_TTL_SHORT }),
|
||||
this.fetch<{ number: number }[]>(`books/${bookId}/pages`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
}),
|
||||
]);
|
||||
const normalized = KomgaAdapter.toNormalizedBook(book);
|
||||
return { ...normalized, pageCount: pages.length };
|
||||
}
|
||||
|
||||
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
|
||||
const series = await this.fetch<KomgaSeries>(`series/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
return KomgaAdapter.toNormalizedSeries(series);
|
||||
}
|
||||
|
||||
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
|
||||
const book = await this.fetch<KomgaBook>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
return KomgaAdapter.toNormalizedReadProgress(book.readProgress);
|
||||
}
|
||||
|
||||
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
|
||||
const url = this.buildUrl(`books/${bookId}/read-progress`);
|
||||
const headers = this.getHeaders({ "Content-Type": "application/json" });
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ page: page ?? 0, completed }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const body = { fullTextSearch: trimmed };
|
||||
const [seriesResp, booksResp] = await Promise.all([
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: "0", size: String(limit) },
|
||||
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
|
||||
),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: "0", size: String(limit) },
|
||||
{ method: "POST", body: JSON.stringify(body), revalidate: CACHE_TTL_SHORT }
|
||||
),
|
||||
]);
|
||||
|
||||
const results: NormalizedSearchResult[] = [
|
||||
...seriesResp.content
|
||||
.filter((s) => !s.deleted)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.metadata?.title ?? s.name,
|
||||
href: `/series/${s.id}`,
|
||||
coverUrl: `/api/komga/images/series/${s.id}/thumbnail`,
|
||||
type: "series" as const,
|
||||
bookCount: s.booksCount,
|
||||
})),
|
||||
...booksResp.content
|
||||
.filter((b) => !b.deleted)
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.metadata?.title ?? b.name,
|
||||
seriesTitle: b.seriesTitle,
|
||||
seriesId: b.seriesId,
|
||||
href: `/books/${b.id}`,
|
||||
coverUrl: `/api/komga/images/books/${b.id}/thumbnail`,
|
||||
type: "book" as const,
|
||||
})),
|
||||
];
|
||||
return results;
|
||||
}
|
||||
|
||||
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
|
||||
try {
|
||||
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return KomgaAdapter.toNormalizedLibrary({
|
||||
...lib,
|
||||
booksCount: resp.totalElements,
|
||||
booksReadCount: 0,
|
||||
});
|
||||
} catch {
|
||||
return KomgaAdapter.toNormalizedLibrary({ ...lib, booksCount: 0, booksReadCount: 0 });
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
|
||||
try {
|
||||
const book = await this.fetch<KomgaBook>(`books/${bookId}/next`);
|
||||
return KomgaAdapter.toNormalizedBook(book);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AppError &&
|
||||
(error as AppError & { params?: { status?: number } }).params?.status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
{ page: "0", size: "10", sort: "readDate,desc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: "0", size: "10", sort: "readProgress.readDate,desc" },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/ondeck",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
]);
|
||||
return {
|
||||
ongoing: (ongoing.content || []).map(KomgaAdapter.toNormalizedSeries),
|
||||
ongoingBooks: (ongoingBooks.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
recentlyRead: (recentlyRead.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
onDeck: (onDeck.content || []).map(KomgaAdapter.toNormalizedBook),
|
||||
latestSeries: (latestSeries.content || []).map(KomgaAdapter.toNormalizedSeries),
|
||||
};
|
||||
}
|
||||
|
||||
async resetReadProgress(bookId: string): Promise<void> {
|
||||
const url = this.buildUrl(`books/${bookId}/read-progress`);
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(url, { method: "DELETE", headers });
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async scanLibrary(libraryId: string): Promise<void> {
|
||||
const url = this.buildUrl(`libraries/${libraryId}/scan`);
|
||||
const headers = this.getHeaders();
|
||||
await fetch(url, { method: "POST", headers });
|
||||
}
|
||||
|
||||
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
|
||||
try {
|
||||
const libraryId = libraryIds?.length
|
||||
? libraryIds[Math.floor(Math.random() * libraryIds.length)]
|
||||
: undefined;
|
||||
const condition: KomgaCondition = libraryId
|
||||
? { libraryId: { operator: "is", value: libraryId } }
|
||||
: {};
|
||||
const randomPage = Math.floor(Math.random() * 5);
|
||||
const response = await this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/list",
|
||||
{ page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
|
||||
{ method: "POST", body: JSON.stringify({ condition }) }
|
||||
);
|
||||
const books = response.content.filter((b) => !b.deleted);
|
||||
if (!books.length) return null;
|
||||
return books[Math.floor(Math.random() * books.length)].id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await this.fetch<KomgaLibrary[]>("libraries");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
|
||||
}
|
||||
}
|
||||
|
||||
getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
}
|
||||
52
src/lib/providers/provider.factory.ts
Normal file
52
src/lib/providers/provider.factory.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import type { IMediaProvider } from "./provider.interface";
|
||||
|
||||
export async function getProvider(): Promise<IMediaProvider | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
activeProvider: true,
|
||||
config: { select: { url: true, authHeader: true } },
|
||||
stripstreamConfig: { select: { url: true, token: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser) return null;
|
||||
|
||||
const activeProvider = dbUser.activeProvider ?? "komga";
|
||||
|
||||
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
|
||||
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
||||
return new StripstreamProvider(
|
||||
dbUser.stripstreamConfig.url,
|
||||
dbUser.stripstreamConfig.token
|
||||
);
|
||||
}
|
||||
|
||||
if (activeProvider === "komga" || !dbUser.activeProvider) {
|
||||
if (!dbUser.config) return null;
|
||||
const { KomgaProvider } = await import("./komga/komga.provider");
|
||||
return new KomgaProvider(dbUser.config.url, dbUser.config.authHeader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getActiveProviderType(): Promise<string | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
|
||||
return dbUser?.activeProvider ?? "komga";
|
||||
}
|
||||
54
src/lib/providers/provider.interface.ts
Normal file
54
src/lib/providers/provider.interface.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "./types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
|
||||
export interface BookListFilter {
|
||||
libraryId?: string;
|
||||
seriesName?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IMediaProvider {
|
||||
// ── Collections ─────────────────────────────────────────────────────────
|
||||
getLibraries(): Promise<NormalizedLibrary[]>;
|
||||
getLibraryById(libraryId: string): Promise<NormalizedLibrary | null>;
|
||||
|
||||
getSeries(libraryId: string, cursor?: string, limit?: number, unreadOnly?: boolean, search?: string): Promise<NormalizedSeriesPage>;
|
||||
getSeriesById(seriesId: string): Promise<NormalizedSeries | null>;
|
||||
|
||||
getBooks(filter: BookListFilter): Promise<NormalizedBooksPage>;
|
||||
getBook(bookId: string): Promise<NormalizedBook>;
|
||||
getNextBook(bookId: string): Promise<NormalizedBook | null>;
|
||||
|
||||
// ── Home ─────────────────────────────────────────────────────────────────
|
||||
getHomeData(): Promise<HomeData>;
|
||||
|
||||
// ── Read progress ────────────────────────────────────────────────────────
|
||||
getReadProgress(bookId: string): Promise<NormalizedReadProgress | null>;
|
||||
saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void>;
|
||||
resetReadProgress(bookId: string): Promise<void>;
|
||||
|
||||
// ── Admin / utility ──────────────────────────────────────────────────────
|
||||
scanLibrary(libraryId: string): Promise<void>;
|
||||
getRandomBook(libraryIds?: string[]): Promise<string | null>;
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────────
|
||||
search(query: string, limit?: number): Promise<NormalizedSearchResult[]>;
|
||||
|
||||
// ── Connection ───────────────────────────────────────────────────────────
|
||||
testConnection(): Promise<{ ok: boolean; error?: string }>;
|
||||
|
||||
// ── URL builders (return local proxy URLs) ───────────────────────────────
|
||||
getBookThumbnailUrl(bookId: string): string;
|
||||
getSeriesThumbnailUrl(seriesId: string): string;
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string;
|
||||
}
|
||||
85
src/lib/providers/stripstream/stripstream.adapter.ts
Normal file
85
src/lib/providers/stripstream/stripstream.adapter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
StripstreamBookItem,
|
||||
StripstreamBookDetails,
|
||||
StripstreamSeriesItem,
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamReadingProgressResponse,
|
||||
} from "@/types/stripstream";
|
||||
import type {
|
||||
NormalizedBook,
|
||||
NormalizedSeries,
|
||||
NormalizedLibrary,
|
||||
NormalizedReadProgress,
|
||||
} from "../types";
|
||||
|
||||
export class StripstreamAdapter {
|
||||
static toNormalizedReadProgress(
|
||||
rp: StripstreamReadingProgressResponse | null
|
||||
): NormalizedReadProgress | null {
|
||||
if (!rp) return null;
|
||||
return {
|
||||
page: rp.current_page ?? null,
|
||||
completed: rp.status === "read",
|
||||
lastReadAt: rp.last_read_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBook(book: StripstreamBookItem): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.library_id,
|
||||
title: book.title,
|
||||
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
|
||||
seriesId: book.series ?? null,
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedBookDetails(book: StripstreamBookDetails): NormalizedBook {
|
||||
return {
|
||||
id: book.id,
|
||||
libraryId: book.library_id,
|
||||
title: book.title,
|
||||
number: book.volume !== null && book.volume !== undefined ? String(book.volume) : null,
|
||||
seriesId: book.series ?? null,
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedSeries(series: StripstreamSeriesItem): NormalizedSeries {
|
||||
return {
|
||||
id: series.first_book_id,
|
||||
name: series.name,
|
||||
bookCount: series.book_count,
|
||||
booksReadCount: series.books_read_count,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
static toNormalizedLibrary(library: StripstreamLibraryResponse): NormalizedLibrary {
|
||||
return {
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
bookCount: library.book_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
161
src/lib/providers/stripstream/stripstream.client.ts
Normal file
161
src/lib/providers/stripstream/stripstream.client.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
interface FetchErrorLike { code?: string; cause?: { code?: string } }
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
revalidate?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class StripstreamClient {
|
||||
private baseUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
// Trim trailing slash
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private getHeaders(extra: Record<string, string> = {}): Headers {
|
||||
return new Headers({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
buildUrl(path: string, params?: Record<string, string | undefined>): string {
|
||||
const url = new URL(`${this.baseUrl}/${path}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined) url.searchParams.append(k, v);
|
||||
});
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async fetch<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | undefined>,
|
||||
options: FetchOptions = {}
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(path, params);
|
||||
const headers = this.getHeaders(
|
||||
options.body ? { "Content-Type": "application/json" } : {}
|
||||
);
|
||||
|
||||
const isDebug = process.env.STRIPSTREAM_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
|
||||
"🔵 Stripstream Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined;
|
||||
|
||||
const fetchOptions = {
|
||||
headers,
|
||||
...options,
|
||||
next: nextOptions,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
return await fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (err: unknown) {
|
||||
const e = err as FetchErrorLike;
|
||||
if (e.cause?.code === "EAI_AGAIN" || e.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}, retrying...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
if (e.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.info(`⏱️ Connection timeout for ${url}, retrying (cold start)...`);
|
||||
return fetch(url, { ...fetchOptions, signal: controller.signal });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await doFetch();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(
|
||||
{ url, status: response.status, duration: `${duration}ms`, ok: response.ok },
|
||||
"🟢 Stripstream Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, status: response.status, statusText: response.statusText },
|
||||
"🔴 Stripstream Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{ url, error: error instanceof Error ? error.message : String(error), duration: `${Date.now() - startTime}ms` },
|
||||
"🔴 Stripstream Request Failed"
|
||||
);
|
||||
}
|
||||
if (error instanceof AppError) throw error;
|
||||
logger.error({ err: error, url }, "Stripstream request failed");
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.CONNECTION_ERROR, {}, error);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchImage(path: string): Promise<Response> {
|
||||
const url = this.buildUrl(path);
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "image/webp, image/jpeg, image/png, */*",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
327
src/lib/providers/stripstream/stripstream.provider.ts
Normal file
327
src/lib/providers/stripstream/stripstream.provider.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type { IMediaProvider, BookListFilter } from "../provider.interface";
|
||||
import logger from "@/lib/logger";
|
||||
import type {
|
||||
NormalizedLibrary,
|
||||
NormalizedSeries,
|
||||
NormalizedBook,
|
||||
NormalizedReadProgress,
|
||||
NormalizedSearchResult,
|
||||
NormalizedSeriesPage,
|
||||
NormalizedBooksPage,
|
||||
} from "../types";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { StripstreamClient } from "./stripstream.client";
|
||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type {
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamBooksPage,
|
||||
StripstreamSeriesPage,
|
||||
StripstreamBookDetails,
|
||||
StripstreamReadingProgressResponse,
|
||||
StripstreamSearchResponse,
|
||||
} from "@/types/stripstream";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
const CACHE_TTL_LONG = 300;
|
||||
const CACHE_TTL_MED = 120;
|
||||
const CACHE_TTL_SHORT = 30;
|
||||
|
||||
export class StripstreamProvider implements IMediaProvider {
|
||||
private client: StripstreamClient;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.client = new StripstreamClient(url, token);
|
||||
}
|
||||
|
||||
async getLibraries(): Promise<NormalizedLibrary[]> {
|
||||
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
return libraries.map(StripstreamAdapter.toNormalizedLibrary);
|
||||
}
|
||||
|
||||
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
|
||||
try {
|
||||
const libraries = await this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
const lib = libraries.find((l) => l.id === libraryId);
|
||||
return lib ? StripstreamAdapter.toNormalizedLibrary(lib) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Stripstream series endpoint: GET /libraries/{library_id}/series
|
||||
async getSeries(libraryId: string, page?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
|
||||
const pageNumber = page ? parseInt(page) : 1;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit), page: String(pageNumber) };
|
||||
if (unreadOnly) params.reading_status = "unread,reading";
|
||||
if (search?.trim()) params.q = search.trim();
|
||||
|
||||
const response = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraryId}/series`,
|
||||
params,
|
||||
{ revalidate: CACHE_TTL_MED, tags: [LIBRARY_SERIES_CACHE_TAG] }
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(response.total / limit);
|
||||
return {
|
||||
items: response.items.map(StripstreamAdapter.toNormalizedSeries),
|
||||
nextCursor: null,
|
||||
totalElements: response.total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async getSeriesById(seriesId: string): Promise<NormalizedSeries | null> {
|
||||
// seriesId can be either a first_book_id (from series cards) or a series name (from book.seriesId).
|
||||
// Try first_book_id first; fall back to series name search.
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
if (!book.series) return null;
|
||||
return {
|
||||
id: seriesId,
|
||||
name: book.series,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
} catch {
|
||||
// Fall back: treat seriesId as a series name, find its first book
|
||||
try {
|
||||
const page = await this.client.fetch<StripstreamBooksPage>(
|
||||
"books",
|
||||
{ series: seriesId, limit: "1" },
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
if (!page.items.length) return null;
|
||||
const firstBook = page.items[0];
|
||||
return {
|
||||
id: firstBook.id,
|
||||
name: seriesId,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const limit = filter.limit ?? 24;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||
|
||||
if (filter.seriesName) {
|
||||
// seriesName is first_book_id for Stripstream — resolve to actual series name
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(
|
||||
`books/${filter.seriesName}`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
params.series = book.series ?? filter.seriesName;
|
||||
} catch {
|
||||
params.series = filter.seriesName;
|
||||
}
|
||||
} else if (filter.libraryId) {
|
||||
params.library_id = filter.libraryId;
|
||||
}
|
||||
|
||||
if (filter.unreadOnly) params.reading_status = "unread,reading";
|
||||
const pageNumber = filter.cursor ? parseInt(filter.cursor) : 1;
|
||||
params.page = String(pageNumber);
|
||||
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", params, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
tags: [SERIES_BOOKS_CACHE_TAG],
|
||||
});
|
||||
|
||||
const pageSize = filter.limit ?? 24;
|
||||
const totalPages = Math.ceil(response.total / pageSize);
|
||||
return {
|
||||
items: response.items.map(StripstreamAdapter.toNormalizedBook),
|
||||
nextCursor: null,
|
||||
totalElements: response.total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
async getBook(bookId: string): Promise<NormalizedBook> {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
return StripstreamAdapter.toNormalizedBookDetails(book);
|
||||
}
|
||||
|
||||
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${bookId}`, undefined, {
|
||||
revalidate: CACHE_TTL_SHORT,
|
||||
});
|
||||
if (!book.series || book.volume == null) return null;
|
||||
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", {
|
||||
series: book.series,
|
||||
limit: "200",
|
||||
}, { revalidate: CACHE_TTL_SHORT });
|
||||
|
||||
const sorted = response.items
|
||||
.filter((b) => b.volume != null)
|
||||
.sort((a, b) => (a.volume ?? 0) - (b.volume ?? 0));
|
||||
|
||||
const idx = sorted.findIndex((b) => b.id === bookId);
|
||||
if (idx === -1 || idx === sorted.length - 1) return null;
|
||||
return StripstreamAdapter.toNormalizedBook(sorted[idx + 1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
// Stripstream has no "in-progress" filter — show recent books and first library's series
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
const [booksPage, libraries] = await Promise.allSettled([
|
||||
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts),
|
||||
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }),
|
||||
]);
|
||||
|
||||
const books = booksPage.status === "fulfilled"
|
||||
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
|
||||
: [];
|
||||
|
||||
let latestSeries: NormalizedSeries[] = [];
|
||||
if (libraries.status === "fulfilled" && libraries.value.length > 0) {
|
||||
try {
|
||||
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraries.value[0].id}/series`,
|
||||
{ limit: "10" },
|
||||
homeOpts
|
||||
);
|
||||
latestSeries = seriesPage.items.map(StripstreamAdapter.toNormalizedSeries);
|
||||
} catch {
|
||||
latestSeries = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ongoing: latestSeries,
|
||||
ongoingBooks: books,
|
||||
recentlyRead: books,
|
||||
onDeck: [],
|
||||
latestSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async getReadProgress(bookId: string): Promise<NormalizedReadProgress | null> {
|
||||
const progress = await this.client.fetch<StripstreamReadingProgressResponse>(
|
||||
`books/${bookId}/progress`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_SHORT }
|
||||
);
|
||||
return StripstreamAdapter.toNormalizedReadProgress(progress);
|
||||
}
|
||||
|
||||
async saveReadProgress(bookId: string, page: number | null, completed: boolean): Promise<void> {
|
||||
const status = completed ? "read" : page !== null && page > 0 ? "reading" : "unread";
|
||||
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, current_page: page }),
|
||||
});
|
||||
}
|
||||
|
||||
async resetReadProgress(bookId: string): Promise<void> {
|
||||
await this.client.fetch<unknown>(`books/${bookId}/progress`, undefined, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "unread", current_page: null }),
|
||||
});
|
||||
}
|
||||
|
||||
async scanLibrary(libraryId: string): Promise<void> {
|
||||
await this.client.fetch<unknown>(`libraries/${libraryId}/scan`, undefined, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async getRandomBook(libraryIds?: string[]): Promise<string | null> {
|
||||
try {
|
||||
const params: Record<string, string | undefined> = { limit: "50" };
|
||||
if (libraryIds?.length) {
|
||||
params.library_id = libraryIds[Math.floor(Math.random() * libraryIds.length)];
|
||||
}
|
||||
const response = await this.client.fetch<StripstreamBooksPage>("books", params);
|
||||
if (!response.items.length) return null;
|
||||
return response.items[Math.floor(Math.random() * response.items.length)].id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, limit = 6): Promise<NormalizedSearchResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const response = await this.client.fetch<StripstreamSearchResponse>("search", {
|
||||
q: trimmed,
|
||||
limit: String(limit),
|
||||
}, { revalidate: CACHE_TTL_SHORT });
|
||||
|
||||
const seriesResults: NormalizedSearchResult[] = response.series_hits.map((s) => ({
|
||||
id: s.first_book_id,
|
||||
title: s.name,
|
||||
href: `/series/${s.first_book_id}`,
|
||||
coverUrl: `/api/stripstream/images/books/${s.first_book_id}/thumbnail`,
|
||||
type: "series" as const,
|
||||
bookCount: s.book_count,
|
||||
}));
|
||||
|
||||
const bookResults: NormalizedSearchResult[] = response.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
title: hit.title,
|
||||
seriesTitle: hit.series ?? null,
|
||||
seriesId: hit.series ?? null,
|
||||
href: `/books/${hit.id}`,
|
||||
coverUrl: `/api/stripstream/images/books/${hit.id}/thumbnail`,
|
||||
type: "book" as const,
|
||||
}));
|
||||
|
||||
return [...seriesResults, ...bookResults];
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await this.client.fetch<StripstreamLibraryResponse[]>("libraries");
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "Connexion échouée" };
|
||||
}
|
||||
}
|
||||
|
||||
getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/stripstream/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/stripstream/images/books/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/stripstream/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
}
|
||||
64
src/lib/providers/types.ts
Normal file
64
src/lib/providers/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type ProviderType = "komga" | "stripstream";
|
||||
|
||||
export interface NormalizedReadProgress {
|
||||
page: number | null;
|
||||
completed: boolean;
|
||||
lastReadAt: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
export interface NormalizedSeries {
|
||||
id: string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
booksReadCount: number;
|
||||
thumbnailUrl: string;
|
||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||
summary?: string | null;
|
||||
authors?: Array<{ name: string; role: string }>;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface NormalizedBook {
|
||||
id: string;
|
||||
libraryId: string;
|
||||
title: string;
|
||||
number: string | null;
|
||||
seriesId: string | null;
|
||||
volume: number | null;
|
||||
pageCount: number;
|
||||
thumbnailUrl: string;
|
||||
readProgress: NormalizedReadProgress | null;
|
||||
}
|
||||
|
||||
export interface NormalizedSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
seriesTitle?: string | null;
|
||||
seriesId?: string | null;
|
||||
href: string;
|
||||
coverUrl: string;
|
||||
type: "series" | "book";
|
||||
bookCount?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedSeriesPage {
|
||||
items: NormalizedSeries[];
|
||||
nextCursor: string | null;
|
||||
totalPages?: number;
|
||||
totalElements?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedBooksPage {
|
||||
items: NormalizedBook[];
|
||||
nextCursor: string | null;
|
||||
totalPages?: number;
|
||||
totalElements?: number;
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface KomgaRequestInit extends RequestInit {
|
||||
isImage?: boolean;
|
||||
noJson?: boolean;
|
||||
/** Next.js cache duration in seconds. Use false to disable cache, number for TTL */
|
||||
revalidate?: number | false;
|
||||
/** Cache tags for targeted invalidation */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface KomgaUrlBuilder {
|
||||
path: string;
|
||||
params?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
interface FetchErrorLike {
|
||||
code?: string;
|
||||
cause?: {
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class BaseApiService {
|
||||
protected static async getKomgaConfig(): Promise<AuthConfig> {
|
||||
try {
|
||||
const config: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||
if (!config) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: config.url,
|
||||
authHeader: config.authHeader,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
||||
throw error;
|
||||
}
|
||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration");
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
protected static getAuthHeaders(config: AuthConfig): Headers {
|
||||
if (!config.authHeader) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.MISSING_CREDENTIALS);
|
||||
}
|
||||
|
||||
return new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
protected static buildUrl(
|
||||
config: AuthConfig,
|
||||
path: string,
|
||||
params?: Record<string, string | string[]>
|
||||
): string {
|
||||
const url = new URL(`${config.serverUrl}/api/v1/${path}`);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
if (v !== undefined) {
|
||||
url.searchParams.append(key, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
protected static async fetchFromApi<T>(
|
||||
urlBuilder: KomgaUrlBuilder,
|
||||
headersOptions = {},
|
||||
options: KomgaRequestInit = {}
|
||||
): Promise<T> {
|
||||
const config: AuthConfig = await this.getKomgaConfig();
|
||||
const { path, params } = urlBuilder;
|
||||
const url = this.buildUrl(config, path, params);
|
||||
|
||||
const headers: Headers = this.getAuthHeaders(config);
|
||||
if (headersOptions) {
|
||||
for (const [key, value] of Object.entries(headersOptions)) {
|
||||
headers.set(key as string, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug || isCacheDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
params,
|
||||
isImage: options.isImage,
|
||||
noJson: options.noJson,
|
||||
revalidate: options.revalidate,
|
||||
},
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
cache: "enabled",
|
||||
ttl: options.revalidate,
|
||||
},
|
||||
"💾 Cache enabled"
|
||||
);
|
||||
}
|
||||
|
||||
// Timeout de 15 secondes pour éviter les blocages longs
|
||||
const timeoutMs = 15000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options not in standard fetch types
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
const normalizedError = fetchError as FetchErrorLike;
|
||||
// Gestion spécifique des erreurs DNS
|
||||
if (normalizedError.cause?.code === "EAI_AGAIN" || normalizedError.code === "EAI_AGAIN") {
|
||||
logger.error(`DNS resolution failed for ${url}. Retrying with different DNS settings...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Force IPv4 si IPv6 pose problème
|
||||
family: 4,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else if (normalizedError.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
// Retry automatique sur timeout de connexion (cold start)
|
||||
logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
// @ts-expect-error - undici-specific options
|
||||
connectTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
// Next.js cache with tags support
|
||||
next: options.tags
|
||||
? { tags: options.tags }
|
||||
: options.revalidate !== undefined
|
||||
? { revalidate: options.revalidate }
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
duration: `${duration}ms`,
|
||||
ok: response.ok,
|
||||
},
|
||||
"🟢 Komga Response"
|
||||
);
|
||||
}
|
||||
|
||||
// Log potential cache hit/miss based on response time
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
// Fast response (< 50ms) is likely a cache hit
|
||||
if (duration < 50) {
|
||||
logger.info({ url, duration: `${duration}ms` }, "⚡ Cache HIT (fast response)");
|
||||
} else {
|
||||
logger.info({ url, duration: `${duration}ms` }, "🔄 Cache MISS (slow response)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
},
|
||||
"🔴 Komga Error Response"
|
||||
);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.isImage) {
|
||||
return response as T;
|
||||
}
|
||||
|
||||
if (options.noJson) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(
|
||||
{
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
"🔴 Komga Request Failed"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,12 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaBookWithPages } from "@/types/komga";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
type ErrorWithStatusParams = AppError & { params?: { status?: number } };
|
||||
|
||||
export class BookService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 60; // 1 minute
|
||||
|
||||
static async getBook(bookId: string): Promise<KomgaBookWithPages> {
|
||||
try {
|
||||
// Récupération parallèle des détails du tome et des pages
|
||||
const [book, pages] = await Promise.all([
|
||||
this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
this.fetchFromApi<{ number: number }[]>(
|
||||
{ path: `books/${bookId}/pages` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
book,
|
||||
pages: pages.map((page) => page.number),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getNextBook(bookId: string, _seriesId: string): Promise<KomgaBook | null> {
|
||||
try {
|
||||
// Utiliser l'endpoint natif Komga pour obtenir le livre suivant
|
||||
return await this.fetchFromApi<KomgaBook>({ path: `books/${bookId}/next` });
|
||||
} catch (error) {
|
||||
// Si le livre suivant n'existe pas, Komga retourne 404
|
||||
// On retourne null dans ce cas
|
||||
if (
|
||||
error instanceof AppError &&
|
||||
error.code === ERROR_CODES.KOMGA.HTTP_ERROR &&
|
||||
(error as ErrorWithStatusParams).params?.status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Pour les autres erreurs, on les propage
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getBookSeriesId(bookId: string): Promise<string> {
|
||||
try {
|
||||
const book = await this.fetchFromApi<KomgaBook>(
|
||||
{ path: `books/${bookId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return book.seriesId;
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateReadProgress(
|
||||
bookId: string,
|
||||
page: number,
|
||||
completed: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await this.getKomgaConfig();
|
||||
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
|
||||
const headers = this.getAuthHeaders(config);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ page, completed }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_UPDATE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteReadProgress(bookId: string): Promise<void> {
|
||||
try {
|
||||
const config = await this.getKomgaConfig();
|
||||
const url = this.buildUrl(config, `books/${bookId}/read-progress`);
|
||||
const headers = this.getAuthHeaders(config);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.BOOK.PROGRESS_DELETE_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
export class BookService {
|
||||
static async getPage(bookId: string, pageNumber: number): Promise<Response> {
|
||||
try {
|
||||
// Ajuster le numéro de page pour l'API Komga (zero-based)
|
||||
const adjustedPageNumber = pageNumber - 1;
|
||||
// Stream directement sans buffer en mémoire
|
||||
return ImageService.streamImage(
|
||||
`books/${bookId}/pages/${adjustedPageNumber}?zero_based=true`
|
||||
);
|
||||
@@ -133,32 +17,18 @@ export class BookService extends BaseApiService {
|
||||
|
||||
static async getCover(bookId: string): Promise<Response> {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||
if (preferences.showThumbnails) {
|
||||
return ImageService.streamImage(`books/${bookId}/thumbnail`);
|
||||
}
|
||||
|
||||
// Sinon, récupérer la première page (streaming)
|
||||
return this.getPage(bookId, 1);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
|
||||
static getPageThumbnailUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getPageThumbnail(bookId: string, pageNumber: number): Promise<Response> {
|
||||
try {
|
||||
// Stream directement sans buffer en mémoire
|
||||
return ImageService.streamImage(
|
||||
`books/${bookId}/pages/${pageNumber}/thumbnail?zero_based=true`
|
||||
);
|
||||
@@ -166,78 +36,4 @@ export class BookService extends BaseApiService {
|
||||
throw new AppError(ERROR_CODES.BOOK.PAGES_FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getCoverUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getRandomBookFromLibraries(libraryIds: string[]): Promise<string> {
|
||||
try {
|
||||
if (libraryIds.length === 0) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND, {
|
||||
message: "Aucune bibliothèque sélectionnée",
|
||||
});
|
||||
}
|
||||
|
||||
// Use books/list directly with library filter to avoid extra series/list call
|
||||
const randomLibraryIndex = Math.floor(Math.random() * libraryIds.length);
|
||||
const randomLibraryId = libraryIds[randomLibraryIndex];
|
||||
|
||||
// Random page offset for variety (assuming most libraries have at least 100 books)
|
||||
const randomPage = Math.floor(Math.random() * 5); // Pages 0-4
|
||||
|
||||
const searchBody = {
|
||||
condition: {
|
||||
libraryId: {
|
||||
operator: "is",
|
||||
value: randomLibraryId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const booksResponse = await this.fetchFromApi<{
|
||||
content: KomgaBook[];
|
||||
totalElements: number;
|
||||
}>(
|
||||
{
|
||||
path: "books/list",
|
||||
params: { page: String(randomPage), size: "20", sort: "metadata.numberSort,asc" },
|
||||
},
|
||||
{ "Content-Type": "application/json" },
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
);
|
||||
|
||||
if (booksResponse.content.length === 0) {
|
||||
// Fallback to page 0 if random page was empty
|
||||
const fallbackResponse = await this.fetchFromApi<{
|
||||
content: KomgaBook[];
|
||||
totalElements: number;
|
||||
}>(
|
||||
{
|
||||
path: "books/list",
|
||||
params: { page: "0", size: "20", sort: "metadata.numberSort,asc" },
|
||||
},
|
||||
{ "Content-Type": "application/json" },
|
||||
{ method: "POST", body: JSON.stringify(searchBody) }
|
||||
);
|
||||
|
||||
if (fallbackResponse.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.BOOK.NOT_FOUND, {
|
||||
message: "Aucun livre trouvé dans les bibliothèques sélectionnées",
|
||||
});
|
||||
}
|
||||
|
||||
const randomBookIndex = Math.floor(Math.random() * fallbackResponse.content.length);
|
||||
return fallbackResponse.content[randomBookIndex].id;
|
||||
}
|
||||
|
||||
const randomBookIndex = Math.floor(Math.random() * booksResponse.content.length);
|
||||
return booksResponse.content[randomBookIndex].id;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
|
||||
export class ClientOfflineBookService {
|
||||
static setCurrentPage(book: KomgaBook, page: number) {
|
||||
static setCurrentPage(book: NormalizedBook, page: number) {
|
||||
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.setItem) {
|
||||
try {
|
||||
localStorage.setItem(`${book.id}-page`, page.toString());
|
||||
@@ -11,7 +11,7 @@ export class ClientOfflineBookService {
|
||||
}
|
||||
}
|
||||
|
||||
static getCurrentPage(book: KomgaBook) {
|
||||
static getCurrentPage(book: NormalizedBook) {
|
||||
const readProgressPage = book.readProgress?.page || 0;
|
||||
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.getItem) {
|
||||
try {
|
||||
@@ -31,7 +31,7 @@ export class ClientOfflineBookService {
|
||||
}
|
||||
}
|
||||
|
||||
static removeCurrentPage(book: KomgaBook) {
|
||||
static removeCurrentPage(book: NormalizedBook) {
|
||||
if (typeof window !== "undefined" && typeof localStorage !== "undefined" && localStorage.removeItem) {
|
||||
try {
|
||||
localStorage.removeItem(`${book.id}-page`);
|
||||
|
||||
@@ -9,7 +9,6 @@ export class FavoriteService {
|
||||
private static readonly FAVORITES_CHANGE_EVENT = "favoritesChanged";
|
||||
|
||||
private static dispatchFavoritesChanged() {
|
||||
// Dispatch l'événement pour notifier les changements
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(FavoriteService.FAVORITES_CHANGE_EVENT));
|
||||
}
|
||||
@@ -23,19 +22,26 @@ export class FavoriteService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private static async getCurrentUserWithProvider(): Promise<{ userId: number; provider: string }> {
|
||||
const user = await FavoriteService.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
const provider = dbUser?.activeProvider ?? "komga";
|
||||
return { userId, provider };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une série est dans les favoris
|
||||
* Vérifie si une série est dans les favoris (pour le provider actif)
|
||||
*/
|
||||
static async isFavorite(seriesId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
const favorite = await prisma.favorite.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
seriesId: seriesId,
|
||||
},
|
||||
where: { userId, seriesId, provider },
|
||||
});
|
||||
return !!favorite;
|
||||
} catch (error) {
|
||||
@@ -45,25 +51,18 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une série aux favoris
|
||||
* Ajoute une série aux favoris (pour le provider actif)
|
||||
*/
|
||||
static async addToFavorites(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
userId_provider_seriesId: { userId, provider, seriesId },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
create: { userId, provider, seriesId },
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
@@ -73,18 +72,14 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une série des favoris
|
||||
* Retire une série des favoris (pour le provider actif)
|
||||
*/
|
||||
static async removeFromFavorites(seriesId: string): Promise<void> {
|
||||
try {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
where: { userId, seriesId, provider },
|
||||
});
|
||||
|
||||
this.dispatchFavoritesChanged();
|
||||
@@ -94,49 +89,16 @@ export class FavoriteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les IDs des séries favorites
|
||||
* Récupère tous les IDs des séries favorites (pour le provider actif)
|
||||
*/
|
||||
static async getAllFavoriteIds(): Promise<string[]> {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
const { userId, provider } = await this.getCurrentUserWithProvider();
|
||||
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
where: { userId, provider },
|
||||
select: { seriesId: true },
|
||||
});
|
||||
return favorites.map((favorite) => favorite.seriesId);
|
||||
}
|
||||
|
||||
static async addFavorite(seriesId: string) {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const favorite = await prisma.favorite.upsert({
|
||||
where: {
|
||||
userId_seriesId: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
return favorite;
|
||||
}
|
||||
|
||||
static async removeFavorite(seriesId: string): Promise<boolean> {
|
||||
const user = await this.getCurrentUser();
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const result = await prisma.favorite.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
seriesId,
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { FavoriteService } from "./favorite.service";
|
||||
import { SeriesService } from "./series.service";
|
||||
import type { KomgaSeries } from "@/types/komga";
|
||||
import { getProvider } from "@/lib/providers/provider.factory";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export class FavoritesService {
|
||||
static async getFavorites(context?: {
|
||||
requestPath?: string;
|
||||
requestPathname?: string;
|
||||
}): Promise<KomgaSeries[]> {
|
||||
}): Promise<NormalizedSeries[]> {
|
||||
try {
|
||||
const favoriteIds = await FavoriteService.getAllFavoriteIds();
|
||||
const [favoriteIds, provider] = await Promise.all([
|
||||
FavoriteService.getAllFavoriteIds(),
|
||||
getProvider(),
|
||||
]);
|
||||
|
||||
if (favoriteIds.length === 0) {
|
||||
if (favoriteIds.length === 0 || !provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch toutes les séries en parallèle
|
||||
const promises = favoriteIds.map(async (id: string) => {
|
||||
try {
|
||||
return await SeriesService.getSeries(id);
|
||||
return await provider.getSeriesById(id);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -40,7 +42,7 @@ export class FavoritesService {
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter((series): series is KomgaSeries => series !== null);
|
||||
return results.filter((series): series is NormalizedSeries => series !== null);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
|
||||
export type { HomeData };
|
||||
|
||||
// Cache tag pour invalidation ciblée
|
||||
const HOME_CACHE_TAG = "home-data";
|
||||
|
||||
export class HomeService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes fallback
|
||||
private static readonly CACHE_TAG = HOME_CACHE_TAG;
|
||||
|
||||
static async getHomeData(): Promise<HomeData> {
|
||||
try {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books",
|
||||
params: {
|
||||
read_status: "IN_PROGRESS",
|
||||
sort: "readProgress.readDate,desc",
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{
|
||||
path: "books/ondeck",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
this.fetchFromApi<LibraryResponse<KomgaSeries>>(
|
||||
{
|
||||
path: "series/latest",
|
||||
params: {
|
||||
page: "0",
|
||||
size: "10",
|
||||
media_status: "READY",
|
||||
},
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL, tags: [this.CACHE_TAG] }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
ongoing: ongoing.content || [],
|
||||
ongoingBooks: ongoingBooks.content || [],
|
||||
recentlyRead: recentlyRead.content || [],
|
||||
onDeck: onDeck.content || [],
|
||||
latestSeries: latestSeries.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.HOME.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Cache HTTP navigateur : 30 jours (immutable car les thumbnails ne changent pas)
|
||||
const IMAGE_CACHE_MAX_AGE = 2592000;
|
||||
|
||||
export class ImageService extends BaseApiService {
|
||||
/**
|
||||
* Stream an image directly from Komga without buffering in memory
|
||||
* Returns a Response that can be directly returned to the client
|
||||
*/
|
||||
export class ImageService {
|
||||
static async streamImage(
|
||||
path: string,
|
||||
cacheMaxAge: number = IMAGE_CACHE_MAX_AGE
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const headers = { Accept: "image/jpeg, image/png, image/gif, image/webp, */*" };
|
||||
const config = await ConfigDBService.getConfig();
|
||||
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
|
||||
const response = await this.fetchFromApi<Response>({ path }, headers, { isImage: true });
|
||||
const url = new URL(`${config.url}/api/v1/${path}`).toString();
|
||||
const headers = new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "image/jpeg, image/png, image/gif, image/webp, */*",
|
||||
});
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, { status: response.status });
|
||||
|
||||
// Stream the response body directly without buffering
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
@@ -31,23 +33,8 @@ export class ImageService extends BaseApiService {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors du streaming de l'image");
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(ERROR_CODES.IMAGE.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getSeriesThumbnailUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
static getBookThumbnailUrl(bookId: string): string {
|
||||
return `/api/komga/images/books/${bookId}/thumbnail`;
|
||||
}
|
||||
|
||||
static getBookPageUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}`;
|
||||
}
|
||||
|
||||
static getBookPageThumbnailUrl(bookId: string, pageNumber: number): string {
|
||||
return `/api/komga/images/books/${bookId}/pages/${pageNumber}/thumbnail`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { Series } from "@/types/series";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
|
||||
// Raw library type from Komga API (without booksCount)
|
||||
interface KomgaLibraryRaw {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
|
||||
const sortSeriesDeterministically = <T extends { id: string; metadata?: { titleSort?: string } }>(
|
||||
items: T[]
|
||||
): T[] => {
|
||||
return [...items].sort((a, b) => {
|
||||
const titleA = a.metadata?.titleSort ?? "";
|
||||
const titleB = b.metadata?.titleSort ?? "";
|
||||
const titleComparison = titleA.localeCompare(titleB);
|
||||
|
||||
if (titleComparison !== 0) {
|
||||
return titleComparison;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const LIBRARY_SERIES_CACHE_TAG = "library-series";
|
||||
|
||||
export class LibraryService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
static async getLibraries(): Promise<KomgaLibrary[]> {
|
||||
try {
|
||||
const libraries = await this.fetchFromApi<KomgaLibraryRaw[]>(
|
||||
{ path: "libraries" },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
|
||||
// Enrich each library with book counts (parallel requests)
|
||||
const enrichedLibraries = await Promise.all(
|
||||
libraries.map(async (library) => {
|
||||
try {
|
||||
const booksResponse = await this.fetchFromApi<{ totalElements: number }>(
|
||||
{
|
||||
path: "books",
|
||||
params: { library_id: library.id, size: "0" },
|
||||
},
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
return {
|
||||
...library,
|
||||
importLastModified: "",
|
||||
lastModified: "",
|
||||
booksCount: booksResponse.totalElements,
|
||||
booksReadCount: 0,
|
||||
} as KomgaLibrary;
|
||||
} catch {
|
||||
return {
|
||||
...library,
|
||||
importLastModified: "",
|
||||
lastModified: "",
|
||||
booksCount: 0,
|
||||
booksReadCount: 0,
|
||||
} as KomgaLibrary;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return enrichedLibraries;
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getLibrary(libraryId: string): Promise<KomgaLibrary> {
|
||||
try {
|
||||
return this.fetchFromApi<KomgaLibrary>({ path: `libraries/${libraryId}` });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.LIBRARY.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getLibrarySeries(
|
||||
libraryId: string,
|
||||
page: number = 0,
|
||||
size: number = 20,
|
||||
unreadOnly: boolean = false,
|
||||
search?: string
|
||||
): Promise<LibraryResponse<Series>> {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Construction du body de recherche pour Komga
|
||||
let condition: KomgaCondition;
|
||||
|
||||
if (unreadOnly) {
|
||||
condition = {
|
||||
allOf: [
|
||||
{ libraryId: { operator: "is", value: libraryId } },
|
||||
{
|
||||
anyOf: [
|
||||
{ readStatus: { operator: "is", value: "UNREAD" } },
|
||||
{ readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = { libraryId: { operator: "is", value: libraryId } };
|
||||
}
|
||||
|
||||
const searchBody: { condition: KomgaCondition; fullTextSearch?: string } = { condition };
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.titleSort,asc",
|
||||
};
|
||||
|
||||
if (search) {
|
||||
searchBody.fullTextSearch = search;
|
||||
}
|
||||
|
||||
const response = await this.fetchFromApi<LibraryResponse<Series>>(
|
||||
{ path: "series/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
tags: [LIBRARY_SERIES_CACHE_TAG],
|
||||
}
|
||||
);
|
||||
|
||||
const filteredContent = response.content.filter((series) => !series.deleted);
|
||||
const sortedContent = sortSeriesDeterministically(filteredContent);
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: sortedContent,
|
||||
numberOfElements: sortedContent.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async scanLibrary(libraryId: string, deep: boolean = false): Promise<void> {
|
||||
try {
|
||||
await this.fetchFromApi(
|
||||
{ path: `libraries/${libraryId}/scan`, params: { deep: String(deep) } },
|
||||
{},
|
||||
{ method: "POST", noJson: true, revalidate: 0 } // bypass cache on mutations
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.LIBRARY.SCAN_ERROR, { libraryId }, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
|
||||
interface SearchResponse<T> {
|
||||
content: T[];
|
||||
}
|
||||
|
||||
export interface GlobalSearchResult {
|
||||
series: KomgaSeries[];
|
||||
books: KomgaBook[];
|
||||
}
|
||||
|
||||
export class SearchService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 30;
|
||||
|
||||
static async globalSearch(query: string, limit: number = 6): Promise<GlobalSearchResult> {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
return { series: [], books: [] };
|
||||
}
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const searchBody = {
|
||||
fullTextSearch: trimmedQuery,
|
||||
};
|
||||
|
||||
try {
|
||||
const [seriesResponse, booksResponse] = await Promise.all([
|
||||
this.fetchFromApi<SearchResponse<KomgaSeries>>(
|
||||
{ path: "series/list", params: { page: "0", size: String(limit) } },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
),
|
||||
this.fetchFromApi<SearchResponse<KomgaBook>>(
|
||||
{ path: "books/list", params: { page: "0", size: String(limit) } },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
series: seriesResponse.content.filter((item) => !item.deleted),
|
||||
books: booksResponse.content.filter((item) => !item.deleted),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,45 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { LibraryResponse } from "@/types/library";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import type { KomgaBook } from "@/types/komga";
|
||||
import { BookService } from "./book.service";
|
||||
import { ImageService } from "./image.service";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import { ConfigDBService } from "./config-db.service";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
export class SeriesService {
|
||||
private static async getFirstBook(seriesId: string): Promise<string> {
|
||||
const config = await ConfigDBService.getConfig();
|
||||
if (!config) throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG);
|
||||
|
||||
export class SeriesService extends BaseApiService {
|
||||
private static readonly CACHE_TTL = 120; // 2 minutes
|
||||
const url = new URL(`${config.url}/api/v1/series/${seriesId}/books`);
|
||||
url.searchParams.set("page", "0");
|
||||
url.searchParams.set("size", "1");
|
||||
|
||||
static async getSeries(seriesId: string): Promise<KomgaSeries> {
|
||||
try {
|
||||
return this.fetchFromApi<KomgaSeries>(
|
||||
{ path: `series/${seriesId}` },
|
||||
{},
|
||||
{ revalidate: this.CACHE_TTL }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
const headers = new Headers({
|
||||
Authorization: `Basic ${config.authHeader}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
static async getSeriesBooks(
|
||||
seriesId: string,
|
||||
page: number = 0,
|
||||
size: number = 24,
|
||||
unreadOnly: boolean = false
|
||||
): Promise<LibraryResponse<KomgaBook>> {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||
|
||||
// Construction du body de recherche pour Komga
|
||||
let condition: KomgaCondition;
|
||||
const data: { content: KomgaBook[] } = await response.json();
|
||||
if (!data.content?.length) throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
|
||||
if (unreadOnly) {
|
||||
// Utiliser allOf pour combiner seriesId avec anyOf pour UNREAD ou IN_PROGRESS
|
||||
condition = {
|
||||
allOf: [
|
||||
{
|
||||
seriesId: {
|
||||
operator: "is",
|
||||
value: seriesId,
|
||||
},
|
||||
},
|
||||
{
|
||||
anyOf: [
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "UNREAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
readStatus: {
|
||||
operator: "is",
|
||||
value: "IN_PROGRESS",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
condition = {
|
||||
seriesId: {
|
||||
operator: "is",
|
||||
value: seriesId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const searchBody = { condition };
|
||||
|
||||
const params: Record<string, string | string[]> = {
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
sort: "metadata.numberSort,asc",
|
||||
};
|
||||
|
||||
const response = await this.fetchFromApi<LibraryResponse<KomgaBook>>(
|
||||
{ path: "books/list", params },
|
||||
headers,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(searchBody),
|
||||
revalidate: this.CACHE_TTL,
|
||||
}
|
||||
);
|
||||
|
||||
// Filtrer uniquement les livres supprimés côté client (léger)
|
||||
const filteredContent = response.content.filter((book: KomgaBook) => !book.deleted);
|
||||
|
||||
return {
|
||||
...response,
|
||||
content: filteredContent,
|
||||
numberOfElements: filteredContent.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getFirstBook(seriesId: string): Promise<string> {
|
||||
try {
|
||||
const data: LibraryResponse<KomgaBook> = await this.fetchFromApi<LibraryResponse<KomgaBook>>({
|
||||
path: `series/${seriesId}/books`,
|
||||
params: { page: "0", size: "1" },
|
||||
});
|
||||
if (!data.content || data.content.length === 0) {
|
||||
throw new AppError(ERROR_CODES.SERIES.NO_BOOKS_FOUND);
|
||||
}
|
||||
|
||||
return data.content[0].id;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération du premier livre");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
return data.content[0].id;
|
||||
}
|
||||
|
||||
static async getCover(seriesId: string): Promise<Response> {
|
||||
try {
|
||||
// Récupérer les préférences de l'utilisateur
|
||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||
|
||||
// Si l'utilisateur préfère les vignettes, utiliser la miniature (streaming)
|
||||
const preferences = await PreferencesService.getPreferences();
|
||||
if (preferences.showThumbnails) {
|
||||
return ImageService.streamImage(`series/${seriesId}/thumbnail`);
|
||||
}
|
||||
|
||||
// Sinon, récupérer la première page (streaming)
|
||||
const firstBookId = await this.getFirstBook(seriesId);
|
||||
const firstBookId = await SeriesService.getFirstBook(seriesId);
|
||||
return BookService.getPage(firstBookId, 1);
|
||||
} catch (error) {
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
static getCoverUrl(seriesId: string): string {
|
||||
return `/api/komga/images/series/${seriesId}/thumbnail`;
|
||||
}
|
||||
|
||||
static async getMultipleSeries(seriesIds: string[]): Promise<KomgaSeries[]> {
|
||||
try {
|
||||
const seriesPromises: Promise<KomgaSeries>[] = seriesIds.map((id: string) =>
|
||||
this.getSeries(id)
|
||||
);
|
||||
const series: KomgaSeries[] = await Promise.all(seriesPromises);
|
||||
return series.filter(Boolean);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération de la couverture de la série");
|
||||
throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { BaseApiService } from "./base-api.service";
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import { ERROR_CODES } from "../../constants/errorCodes";
|
||||
import { AppError } from "../../utils/errors";
|
||||
import type { KomgaLibrary } from "@/types/komga";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export class TestService extends BaseApiService {
|
||||
static async testConnection(config: AuthConfig): Promise<{ libraries: KomgaLibrary[] }> {
|
||||
try {
|
||||
const url = this.buildUrl(config, "libraries");
|
||||
const headers = this.getAuthHeaders(config);
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR, { message: errorData.message });
|
||||
}
|
||||
|
||||
const libraries = await response.json();
|
||||
return { libraries };
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors du test de connexion");
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("fetch")) {
|
||||
throw new AppError(ERROR_CODES.KOMGA.SERVER_UNREACHABLE);
|
||||
}
|
||||
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR, {}, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user