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,6 +1,6 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate";
@@ -9,22 +9,22 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { formatDate } from "@/lib/utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { Progress } from "@/components/ui/progress";
import { Calendar, FileText, User, Tag } from "lucide-react";
import { FileText } from "lucide-react";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button";
interface BookListProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
books: NormalizedBook[];
onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean;
onRefresh?: () => void;
}
interface BookListItemProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
book: NormalizedBook;
onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact?: boolean;
}
@@ -40,7 +40,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const totalPages = book.pageCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
const getStatusInfo = () => {
@@ -52,7 +52,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
}
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",
@@ -77,8 +77,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const statusInfo = getStatusInfo();
const title =
book.metadata.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
book.title ||
(book.number ? t("navigation.volume", { number: book.number }) : "");
if (isCompact) {
return (
@@ -130,8 +130,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{/* Métadonnées minimales */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{book.metadata.number && (
<span>{t("navigation.volume", { number: book.metadata.number })}</span>
{book.number && (
<span>{t("navigation.volume", { number: book.number })}</span>
)}
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
@@ -139,12 +139,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div>
</div>
</div>
@@ -189,9 +183,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
>
{title}
</h3>
{book.metadata.number && (
{book.number && (
<p className="text-sm text-muted-foreground mt-1">
{t("navigation.volume", { number: book.metadata.number })}
{t("navigation.volume", { number: book.number })}
</p>
)}
</div>
@@ -207,13 +201,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</span>
</div>
{/* Résumé */}
{book.metadata.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{book.metadata.summary}
</p>
)}
{/* Métadonnées */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Pages */}
@@ -223,35 +210,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{/* Auteurs */}
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de sortie */}
{book.metadata.releaseDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(book.metadata.releaseDate)}</span>
</div>
)}
{/* Tags */}
{book.metadata.tags && book.metadata.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.tags.slice(0, 3).join(", ")}
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
@@ -269,7 +227,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
pagesCount={book.pageCount}
isRead={isRead}
onSuccess={() => onSuccess(book, "read")}
className="text-xs"
@@ -311,7 +269,7 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
@@ -320,10 +278,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
page: previousBook.pageCount,
lastReadAt: new Date().toISOString(),
},
}
: previousBook