Files
stripstream/src/lib/providers/komga/komga.provider.ts
Froidefond Julien 539bb34716
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
perf: optimize Komga caching with unstable_cache for POST requests and reduce API calls
- Fix POST requests (series/list, books/list) not being cached by Next.js fetch cache
  by wrapping them with unstable_cache in the private fetch method
- Wrap getHomeData() entirely with unstable_cache so all 5 home requests are cached
  as a single unit, reducing cold-start cost from 5 parallel calls to 0 on cache hit
- Remove N+1 book count enrichment from getLibraries() (8 extra calls per cold start)
  as LibraryDto does not return booksCount and the value was only used in BackgroundSettings
- Simplify getLibraryById() to reuse cached getLibraries() data instead of making
  separate HTTP calls (saves 2 calls per library page load)
- Fix cache debug logs: replace misleading x-nextjs-cache header check (always UNKNOWN
  on external APIs) with pre-request logs showing the configured cache strategy
- Remove book count display from BackgroundSettings as it is no longer fetched

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:10:31 +01:00

485 lines
16 KiB
TypeScript

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";
import { unstable_cache } from "next/cache";
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";
if (isDebug) {
logger.info(
{ url, method: options.method || "GET", params, revalidate: options.revalidate },
"🔵 Komga Request"
);
}
if (isCacheDebug) {
if (options.tags) {
logger.info({ url, cache: "tags", tags: options.tags }, "💾 Cache tags");
} else if (options.revalidate !== undefined) {
logger.info({ url, cache: "revalidate", ttl: options.revalidate }, "💾 Cache revalidate");
} else {
logger.info({ url, cache: "none" }, "💾 Cache none");
}
}
const nextOptions = options.tags
? { tags: options.tags }
: options.revalidate !== undefined
? { revalidate: options.revalidate }
: undefined;
const fetchOptions = {
headers,
...options,
next: nextOptions,
};
// Next.js does not cache POST fetch requests — use unstable_cache to cache results instead
if (options.method === "POST" && nextOptions) {
const cacheKey = ["komga", this.config.authHeader, url, String(options.body ?? "")];
return unstable_cache(() => this.executeRequest<T>(url, fetchOptions), cacheKey, nextOptions)();
}
return this.executeRequest<T>(url, fetchOptions);
}
private async executeRequest<T>(url: string, fetchOptions: RequestInit): Promise<T> {
const isDebug = process.env.KOMGA_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
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 (!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,
});
return raw.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> {
const libraries = await this.getLibraries();
return libraries.find((lib) => lib.id === libraryId) ?? 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> {
return unstable_cache(
() => this.fetchHomeData(),
["komga-home", this.config.authHeader],
{ revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] }
)();
}
private async fetchHomeData(): Promise<HomeData> {
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" } },
}),
}
).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" } },
}),
}
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/latest",
{ page: "0", size: "10", media_status: "READY" }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/ondeck",
{ page: "0", size: "10", media_status: "READY" }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaSeries>>(
"series/latest",
{ page: "0", size: "10", media_status: "READY" }
).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}`;
}
}