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

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

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

View File

@@ -1,36 +1,40 @@
"use server";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookDataResult {
success: boolean;
data?: {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
};
message?: string;
}
export async function getBookData(bookId: string): Promise<BookDataResult> {
try {
const data = await BookService.getBook(bookId);
let nextBook = null;
const provider = await getProvider();
if (!provider) {
return { success: false, message: "KOMGA_MISSING_CONFIG" };
}
const book = await provider.getBook(bookId);
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
let nextBook: NormalizedBook | null = null;
try {
nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
nextBook = await provider.getNextBook(bookId);
} catch (error) {
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
}
return {
success: true,
data: {
...data,
nextBook,
},
data: { book, pages, nextBook },
};
} catch (error) {
if (error instanceof AppError) {

View File

@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { ConfigDBService } from "@/lib/services/config-db.service";
import { TestService } from "@/lib/services/test.service";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
interface SaveConfigInput {
@@ -13,9 +13,6 @@ interface SaveConfigInput {
authHeader?: string;
}
/**
* Teste la connexion à Komga
*/
export async function testKomgaConnection(
serverUrl: string,
username: string,
@@ -23,12 +20,19 @@ export async function testKomgaConnection(
): Promise<{ success: boolean; message: string }> {
try {
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({
serverUrl,
authHeader,
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
const headers = new Headers({
Authorization: `Basic ${authHeader}`,
Accept: "application/json",
});
const response = await fetch(url, { headers });
if (!response.ok) {
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR);
}
const libraries: KomgaLibrary[] = await response.json();
return {
success: true,
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
@@ -41,9 +45,6 @@ export async function testKomgaConnection(
}
}
/**
* Sauvegarde la configuration Komga
*/
export async function saveKomgaConfig(
config: SaveConfigInput
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
@@ -55,15 +56,8 @@ export async function saveKomgaConfig(
authHeader: config.authHeader || "",
};
const mongoConfig = await ConfigDBService.saveConfig(configData);
// Invalider le cache
revalidatePath("/settings");
return {
success: true,
message: "Configuration sauvegardée",
data: mongoConfig,
};
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };

View File

@@ -1,20 +1,18 @@
"use server";
import { revalidatePath } from "next/cache";
import { LibraryService } from "@/lib/services/library.service";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
/**
* Lance un scan de bibliothèque
*/
export async function scanLibrary(
libraryId: string
): Promise<{ success: boolean; message: string }> {
try {
await LibraryService.scanLibrary(libraryId, false);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.scanLibrary(libraryId);
// Invalider le cache de la bibliothèque
revalidatePath(`/libraries/${libraryId}`);
revalidatePath("/libraries");
@@ -27,9 +25,6 @@ export async function scanLibrary(
}
}
/**
* Retourne un livre aléatoire depuis les bibliothèques sélectionnées
*/
export async function getRandomBookFromLibraries(
libraryIds: string[]
): Promise<{ success: boolean; bookId?: string; message?: string }> {
@@ -38,13 +33,15 @@ export async function getRandomBookFromLibraries(
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
}
const bookId = await BookService.getRandomBookFromLibraries(libraryIds);
return { success: true, bookId };
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
const bookId = await provider.getRandomBook(libraryIds);
return { success: true, bookId: bookId ?? undefined };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
}
}

View File

@@ -1,27 +1,27 @@
"use server";
import { revalidateTag } from "next/cache";
import { BookService } from "@/lib/services/book.service";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
import { AppError } from "@/utils/errors";
const HOME_CACHE_TAG = "home-data";
function revalidateReadCaches() {
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
revalidateTag(SERIES_BOOKS_CACHE_TAG, "max");
}
/**
* Met à jour la progression de lecture d'un livre
* Note: ne pas utiliser "use server" avec redirect - on gère manuellement
*/
export async function updateReadProgress(
bookId: string,
page: number,
completed: boolean = false
): Promise<{ success: boolean; message: string }> {
try {
await BookService.updateReadProgress(bookId, page, completed);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
await provider.saveReadProgress(bookId, page, completed);
revalidateReadCaches();
return { success: true, message: "Progression mise à jour" };
} catch (error) {
@@ -32,18 +32,15 @@ export async function updateReadProgress(
}
}
/**
* Supprime la progression de lecture d'un livre
*/
export async function deleteReadProgress(
bookId: string
): Promise<{ success: boolean; message: string }> {
try {
await BookService.deleteReadProgress(bookId);
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries)
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
await provider.resetReadProgress(bookId);
revalidateReadCaches();
return { success: true, message: "Progression supprimée" };
} catch (error) {

View File

@@ -1,9 +1,7 @@
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service";
const HOME_CACHE_TAG = "home-data";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
export type RefreshScope = "home" | "library" | "series";

View File

@@ -0,0 +1,183 @@
"use server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types";
/**
* Sauvegarde la configuration Stripstream
*/
export async function saveStripstreamConfig(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
await prisma.stripstreamConfig.upsert({
where: { userId },
update: { url, token },
create: { userId, url, token },
});
revalidatePath("/settings");
return { success: true, message: "Configuration Stripstream sauvegardée" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };
}
}
/**
* Teste la connexion à Stripstream Librarian
*/
export async function testStripstreamConnection(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const provider = new StripstreamProvider(url, token);
const result = await provider.testConnection();
if (!result.ok) {
return { success: false, message: result.error ?? "Connexion échouée" };
}
return { success: true, message: "Connexion Stripstream réussie !" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du test de connexion" };
}
}
/**
* Définit le provider actif de l'utilisateur
*/
export async function setActiveProvider(
provider: ProviderType
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
// Vérifier que le provider est configuré avant de l'activer
if (provider === "komga") {
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
if (!config) {
return { success: false, message: "Komga n'est pas encore configuré" };
}
} else if (provider === "stripstream") {
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" };
}
}
await prisma.user.update({
where: { id: userId },
data: { activeProvider: provider },
});
revalidatePath("/");
revalidatePath("/settings");
return {
success: true,
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
};
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du changement de provider" };
}
}
/**
* Récupère la configuration Stripstream de l'utilisateur
*/
export async function getStripstreamConfig(): Promise<{
url?: string;
hasToken: boolean;
} | null> {
try {
const user = await getCurrentUser();
if (!user) return null;
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({
where: { userId },
select: { url: true },
});
if (!config) return null;
return { url: config.url, hasToken: true };
} catch {
return null;
}
}
/**
* Récupère le provider actif de l'utilisateur
*/
export async function getActiveProvider(): Promise<ProviderType> {
try {
const user = await getCurrentUser();
if (!user) return "komga";
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { activeProvider: true },
});
return (dbUser?.activeProvider as ProviderType) ?? "komga";
} catch {
return "komga";
}
}
/**
* Vérifie quels providers sont configurés
*/
export async function getProvidersStatus(): Promise<{
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: ProviderType;
}> {
try {
const user = await getCurrentUser();
if (!user) {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
const userId = parseInt(user.id, 10);
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
]);
return {
komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamConfig,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
};
} catch {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
}