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,39 +2,27 @@
import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
import type { KomgaSeries } from "@/types/komga";
interface OptimizedHeroSeries {
id: string;
metadata: {
title: string;
};
}
import type { NormalizedSeries } from "@/lib/providers/types";
interface HeroSectionProps {
series: OptimizedHeroSeries[];
series: NormalizedSeries[];
}
export function HeroSection({ series }: HeroSectionProps) {
const { t } = useTranslate();
// logger.info("HeroSection - Séries reçues:", {
// count: series?.length || 0,
// firstSeries: series?.[0],
// });
return (
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
{/* Grille de couvertures en arrière-plan */}
<div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 sm:gap-4 p-4 opacity-10">
{series?.map((series) => (
{series?.map((s) => (
<div
key={series.id}
key={s.id}
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
>
<SeriesCover
series={series as KomgaSeries}
alt={t("home.hero.coverAlt", { title: series.metadata.title })}
series={s}
alt={t("home.hero.coverAlt", { title: s.name })}
showProgressUi={false}
/>
</div>

View File

@@ -1,39 +1,17 @@
import { MediaRow } from "./MediaRow";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { HomeData } from "@/types/home";
interface HomeContentProps {
data: HomeData;
}
const optimizeSeriesData = (series: KomgaSeries[]) => {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({
id,
metadata: { title: metadata.title },
booksCount,
booksReadCount,
}));
};
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
export function HomeContent({ data }: HomeContentProps) {
return (
<div className="space-y-10 pb-2">
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
<MediaRow
titleKey="home.sections.continue_reading"
items={optimizeBookData(data.ongoingBooks)}
items={data.ongoingBooks}
iconName="BookOpen"
featuredHeader
/>
@@ -42,7 +20,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.ongoing && data.ongoing.length > 0 && (
<MediaRow
titleKey="home.sections.continue_series"
items={optimizeSeriesData(data.ongoing)}
items={data.ongoing}
iconName="LibraryBig"
/>
)}
@@ -50,7 +28,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.onDeck && data.onDeck.length > 0 && (
<MediaRow
titleKey="home.sections.up_next"
items={optimizeBookData(data.onDeck)}
items={data.onDeck}
iconName="Clock"
/>
)}
@@ -58,7 +36,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow
titleKey="home.sections.latest_series"
items={optimizeSeriesData(data.latestSeries)}
items={data.latestSeries}
iconName="Sparkles"
/>
)}
@@ -66,7 +44,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow
titleKey="home.sections.recently_added"
items={optimizeBookData(data.recentlyRead)}
items={data.recentlyRead}
iconName="History"
/>
)}

View File

@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
@@ -12,34 +12,9 @@ import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils";
interface BaseItem {
id: string;
metadata: {
title: string;
};
}
interface OptimizedSeries extends BaseItem {
booksCount: number;
booksReadCount: number;
}
interface OptimizedBook extends BaseItem {
readProgress: {
page: number;
};
media: {
pagesCount: number;
};
metadata: {
title: string;
number?: string;
};
}
interface MediaRowProps {
titleKey: string;
items: (OptimizedSeries | OptimizedBook)[];
items: (NormalizedSeries | NormalizedBook)[];
iconName?: string;
featuredHeader?: boolean;
}
@@ -52,13 +27,17 @@ const iconMap = {
History,
};
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
return "bookCount" in item;
}
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
const router = useRouter();
const { t } = useTranslate();
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
const onItemClick = (item: OptimizedSeries | OptimizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`;
const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
router.push(path);
};
@@ -92,24 +71,24 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
}
interface MediaCardProps {
item: OptimizedSeries | OptimizedBook;
item: NormalizedSeries | NormalizedBook;
onClick?: () => void;
}
function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const isSeries = "booksCount" in item;
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
const isSeriesItem = isSeries(item);
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
const title = isSeries
? item.metadata.title
: item.metadata.title ||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
const title = isSeriesItem
? item.name
: item.title ||
(item.number ? t("navigation.volume", { number: item.number }) : "");
const handleClick = () => {
// Pour les séries, toujours autoriser le clic
// Pour les livres, vérifier si accessible
if (isSeries || isAccessible) {
if (isSeriesItem || isAccessible) {
onClick?.();
}
};
@@ -119,24 +98,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick}
className={cn(
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
!isSeriesItem && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
)}
>
<div className="relative aspect-[2/3] bg-muted">
{isSeries ? (
{isSeriesItem ? (
<>
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1">
{t("series.books", { count: item.booksCount })}
</p>
<SeriesCover series={item} alt={`Couverture de ${title}`} />
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1">
{t("series.books", { count: item.bookCount })}
</p>
</div>
</>
) : (
<>
<BookCover
book={item as KomgaBook}
book={item}
alt={`Couverture de ${title}`}
showControls={false}
overlayVariant="home"