feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
Reference in New Issue
Block a user