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

@@ -2,20 +2,18 @@
import { ProgressBar } from "./progress-bar";
import type { BookCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { MarkAsReadButton } from "./mark-as-read-button";
import { MarkAsUnreadButton } from "./mark-as-unread-button";
import { BookOfflineButton } from "./book-offline-button";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaBook } from "@/types/komga";
import { formatDate } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { WifiOff } from "lucide-react";
// Fonction utilitaire pour obtenir les informations de statut de lecture
const getReadingStatusInfo = (
book: KomgaBook,
book: BookCoverProps["book"],
t: (key: string, options?: { [key: string]: string | number }) => string
) => {
if (!book.readProgress) {
@@ -26,7 +24,7 @@ const getReadingStatusInfo = (
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
@@ -39,7 +37,7 @@ const getReadingStatusInfo = (
return {
label: t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
}),
className: "bg-blue-500/10 text-blue-500",
};
@@ -64,11 +62,10 @@ export function BookCover({
const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id);
const imageUrl = getImageUrl("book", book.id);
const isCompleted = book.readProgress?.completed || false;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
const statusInfo = getReadingStatusInfo(book, t);
@@ -90,7 +87,7 @@ export function BookCover({
<>
<div className={`relative w-full h-full ${isUnavailable ? "opacity-40 grayscale" : ""}`}>
<img
src={imageUrl.trim()}
src={book.thumbnailUrl.trim()}
alt={alt || t("books.defaultCoverAlt")}
loading="lazy"
className={[
@@ -121,7 +118,7 @@ export function BookCover({
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => handleMarkAsRead()}
className="bg-white/90 hover:bg-white text-black shadow-sm"
@@ -143,9 +140,9 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</p>
<div className="flex items-center gap-2">
@@ -160,15 +157,15 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && (
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
{book.title ||
(book.number
? t("navigation.volume", { number: book.number })
: "")}
</h3>
<p className="text-xs text-white/80 mt-1">
{t("books.status.progress", {
current: currentPage,
total: book.media.pagesCount,
total: book.pageCount,
})}
</p>
</div>

View File

@@ -4,11 +4,11 @@ import { useState, useEffect, useCallback } from "react";
import { Download, Check, Loader2 } from "lucide-react";
import { Button } from "./button";
import { useToast } from "./use-toast";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookOfflineButtonProps {
book: KomgaBook;
book: NormalizedBook;
className?: string;
}
@@ -57,7 +57,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Marque le début du téléchargement
setBookStatus(book.id, {
status: "downloading",
progress: ((startFromPage - 1) / book.media.pagesCount) * 100,
progress: ((startFromPage - 1) / book.pageCount) * 100,
timestamp: Date.now(),
lastDownloadedPage: startFromPage - 1,
});
@@ -71,7 +71,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Cache chaque page avec retry
let failedPages = 0;
for (let i = startFromPage; i <= book.media.pagesCount; i++) {
for (let i = startFromPage; i <= book.pageCount; i++) {
let retryCount = 0;
const maxRetries = 3;
@@ -105,7 +105,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
}
// Mise à jour du statut
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
setBookStatus(book.id, {
status: "downloading",
@@ -125,7 +125,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
if (failedPages > 0) {
// Si des pages ont échoué, on supprime tout le cache pour ce livre
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
}
setIsAvailableOffline(false);
@@ -159,7 +159,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setDownloadProgress(0);
}
},
[book.id, book.media.pagesCount, getBookStatus, setBookStatus, toast]
[book.id, book.pageCount, getBookStatus, setBookStatus, toast]
);
const checkOfflineAvailability = useCallback(async () => {
@@ -177,7 +177,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
// Vérifie que toutes les pages sont dans le cache
let allPagesAvailable = true;
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
const page = await cache.match(`/api/komga/images/books/${book.id}/pages/${i}`);
if (!page) {
allPagesAvailable = false;
@@ -195,7 +195,7 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
logger.error({ err: error }, "Erreur lors de la vérification du cache:");
setBookStatus(book.id, { status: "error", progress: 0, timestamp: Date.now() });
}
}, [book.id, book.media.pagesCount, setBookStatus]);
}, [book.id, book.pageCount, setBookStatus]);
useEffect(() => {
const checkStatus = async () => {
@@ -242,9 +242,9 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
setBookStatus(book.id, { status: "idle", progress: 0, timestamp: Date.now() });
// Supprime le livre du cache
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) {
for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
const progress = (i / book.media.pagesCount) * 100;
const progress = (i / book.pageCount) * 100;
setDownloadProgress(progress);
}
setIsAvailableOffline(false);

View File

@@ -1,4 +1,4 @@
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface BaseCoverProps {
alt?: string;
@@ -9,13 +9,13 @@ export interface BaseCoverProps {
}
export interface BookCoverProps extends BaseCoverProps {
book: KomgaBook;
onSuccess?: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onSuccess?: (book: NormalizedBook, action: "read" | "unread") => void;
showControls?: boolean;
showOverlay?: boolean;
overlayVariant?: "default" | "home";
}
export interface SeriesCoverProps extends BaseCoverProps {
series: KomgaSeries;
series: NormalizedSeries;
}

View File

@@ -1,6 +1,5 @@
import { ProgressBar } from "./progress-bar";
import type { SeriesCoverProps } from "./cover-utils";
import { getImageUrl } from "@/lib/utils/image-url";
export function SeriesCover({
series,
@@ -8,17 +7,16 @@ export function SeriesCover({
className,
showProgressUi = true,
}: SeriesCoverProps) {
const imageUrl = getImageUrl("series", series.id);
const isCompleted = series.booksCount === series.booksReadCount;
const isCompleted = series.bookCount === series.booksReadCount;
const readBooks = series.booksReadCount;
const totalBooks = series.booksCount;
const totalBooks = series.bookCount;
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
return (
<div className="relative w-full h-full">
<img
src={imageUrl}
src={series.thumbnailUrl}
alt={alt}
loading="lazy"
className={[