From 7e4c48469afa62e613a5ed021b80d44b70b9e69e Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 11 Mar 2026 21:25:58 +0100 Subject: [PATCH] feat: enhance Stripstream configuration handling - Introduced a new resolver function to streamline fetching Stripstream configuration from the database or environment variables. - Updated various components and API routes to utilize the new configuration resolver, improving code maintainability and reducing direct database calls. - Added optional environment variables for Stripstream URL and token in the .env.example file. - Refactored image loading logic in the reader components to improve performance and error handling. --- .env.example | 6 +- src/app/actions/stripstream-config.ts | 24 ++-- .../[bookId]/pages/[pageNumber]/route.ts | 4 +- .../images/books/[bookId]/thumbnail/route.ts | 4 +- src/components/auth/LoginForm.tsx | 5 +- src/components/auth/RegisterForm.tsx | 5 +- src/components/reader/PhotoswipeReader.tsx | 28 +++- .../reader/components/PageDisplay.tsx | 14 +- src/components/reader/hooks/useImageLoader.ts | 132 ++++++++++++------ src/lib/providers/provider.factory.ts | 15 +- .../stripstream-config-resolver.ts | 26 ++++ src/types/env.d.ts | 4 + 12 files changed, 183 insertions(+), 84 deletions(-) create mode 100644 src/lib/providers/stripstream/stripstream-config-resolver.ts diff --git a/.env.example b/.env.example index c6566e1..b2e2415 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,8 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth NEXTAUTH_SECRET=SECRET #openssl rand -base64 32 -NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file +NEXTAUTH_URL=http://localhost:3000 + +# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base) +# STRIPSTREAM_URL=https://librarian.example.com +# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx \ No newline at end of file diff --git a/src/app/actions/stripstream-config.ts b/src/app/actions/stripstream-config.ts index 80600a8..4ea6ad4 100644 --- a/src/app/actions/stripstream-config.ts +++ b/src/app/actions/stripstream-config.ts @@ -4,6 +4,7 @@ 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 { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver"; import { AppError } from "@/utils/errors"; import { ERROR_CODES } from "@/constants/errorCodes"; import type { ProviderType } from "@/lib/providers/types"; @@ -81,8 +82,8 @@ export async function setActiveProvider( if (!config) { return { success: false, message: "Komga n'est pas encore configuré" }; } - } else if (provider === "stripstream") { - const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); + } else if (provider === "stripstream") { + const config = await getResolvedStripstreamConfig(userId); if (!config) { return { success: false, message: "Stripstream n'est pas encore configuré" }; } @@ -108,7 +109,8 @@ export async function setActiveProvider( } /** - * Récupère la configuration Stripstream de l'utilisateur + * Récupère la configuration Stripstream de l'utilisateur (affichage settings). + * Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN. */ export async function getStripstreamConfig(): Promise<{ url?: string; @@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{ 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 }; + const resolved = await getResolvedStripstreamConfig(userId); + if (!resolved) return null; + return { url: resolved.url, hasToken: true }; } catch { return null; } @@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{ } const userId = parseInt(user.id, 10); - const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([ + const [dbUser, komgaConfig, stripstreamResolved] = 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 } }), + getResolvedStripstreamConfig(userId), ]); return { komgaConfigured: !!komgaConfig, - stripstreamConfigured: !!stripstreamConfig, + stripstreamConfigured: !!stripstreamResolved, activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga", }; } catch { diff --git a/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts b/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts index 422d7cd..5ca02fb 100644 --- a/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts +++ b/src/app/api/stripstream/images/books/[bookId]/pages/[pageNumber]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth-utils"; -import prisma from "@/lib/prisma"; +import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver"; import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client"; import { ERROR_CODES } from "@/constants/errorCodes"; import { AppError } from "@/utils/errors"; @@ -23,7 +23,7 @@ export async function GET( } const userId = parseInt(user.id, 10); - const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); + const config = await getResolvedStripstreamConfig(userId); if (!config) { throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG); } diff --git a/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts b/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts index 4b557a2..37f57fc 100644 --- a/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts +++ b/src/app/api/stripstream/images/books/[bookId]/thumbnail/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/auth-utils"; -import prisma from "@/lib/prisma"; +import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver"; import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client"; import { AppError } from "@/utils/errors"; import { ERROR_CODES } from "@/constants/errorCodes"; @@ -20,7 +20,7 @@ export async function GET( } const userId = parseInt(user.id, 10); - const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); + const config = await getResolvedStripstreamConfig(userId); if (!config) { throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG); } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index ce98eb5..797f400 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { signIn } from "next-auth/react"; import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { useTranslate } from "@/hooks/useTranslate"; @@ -16,7 +15,6 @@ interface LoginFormProps { } export function LoginForm({ from }: LoginFormProps) { - const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { t } = useTranslate(); @@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) { } const redirectPath = getSafeRedirectPath(from); - window.location.assign(redirectPath); - router.refresh(); + window.location.href = redirectPath; } catch { setError({ code: "AUTH_FETCH_ERROR", diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx index 91f9a22..6d2361a 100644 --- a/src/components/auth/RegisterForm.tsx +++ b/src/components/auth/RegisterForm.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; import { signIn } from "next-auth/react"; import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { useTranslate } from "@/hooks/useTranslate"; @@ -16,7 +15,6 @@ interface RegisterFormProps { } export function RegisterForm({ from }: RegisterFormProps) { - const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { t } = useTranslate(); @@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) { }); } else { const redirectPath = getSafeRedirectPath(from); - window.location.assign(redirectPath); - router.refresh(); + window.location.href = redirectPath; } } catch { setError({ diff --git a/src/components/reader/PhotoswipeReader.tsx b/src/components/reader/PhotoswipeReader.tsx index 2d04142..88c9099 100644 --- a/src/components/reader/PhotoswipeReader.tsx +++ b/src/components/reader/PhotoswipeReader.tsx @@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP const { loadedImages, imageBlobUrls, + prefetchImage, prefetchPages, prefetchNextBook, cancelAllPrefetches, handleForceReload, getPageUrl, prefetchCount, + isPageLoading, } = useImageLoader({ pageUrlBuilder: bookPageUrlBuilder, pages, @@ -87,8 +89,26 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP // Prefetch current and next pages useEffect(() => { - // Prefetch pages starting from current page - prefetchPages(currentPage, prefetchCount); + // Determine visible pages that need to be loaded immediately + const visiblePages: number[] = []; + if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) { + visiblePages.push(currentPage, currentPage + 1); + } else { + visiblePages.push(currentPage); + } + + // Load visible pages first (priority) to avoid duplicate requests from tags + // These will populate imageBlobUrls so tags use blob URLs instead of making HTTP requests + const loadVisiblePages = async () => { + await Promise.all(visiblePages.map((page) => prefetchImage(page))); + }; + loadVisiblePages().catch(() => { + // Silently fail - will fallback to direct HTTP requests + }); + + // Then prefetch other pages, excluding visible ones to avoid duplicates + const concurrency = isDoublePage && shouldShowDoublePage(currentPage, pages.length) ? 2 : 4; + prefetchPages(currentPage, prefetchCount, visiblePages, concurrency); // If double page mode, also prefetch additional pages for smooth double page navigation if ( @@ -96,7 +116,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length ) { - prefetchPages(currentPage + prefetchCount, 1); + prefetchPages(currentPage + prefetchCount, 1, visiblePages, concurrency); } // If we're near the end of the book, prefetch the next book @@ -108,6 +128,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP currentPage, isDoublePage, shouldShowDoublePage, + prefetchImage, prefetchPages, prefetchNextBook, prefetchCount, @@ -229,6 +250,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP imageBlobUrls={imageBlobUrls} getPageUrl={getPageUrl} isRTL={isRTL} + isPageLoading={isPageLoading} /> ; getPageUrl: (pageNum: number) => string; isRTL: boolean; + isPageLoading?: (pageNum: number) => boolean; } export function PageDisplay({ @@ -19,6 +20,7 @@ export function PageDisplay({ imageBlobUrls, getPageUrl, isRTL, + isPageLoading, }: PageDisplayProps) { const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); @@ -102,7 +104,10 @@ export function PageDisplay({ {/* eslint-disable-next-line @next/next/no-img-element */} {`Page>(new Set()); const abortControllersRef = useRef>(new Map()); + // Track promises for pages being loaded so we can await them + const loadingPromisesRef = useRef>>(new Map()); // Keep refs in sync with state useEffect(() => { @@ -44,12 +46,14 @@ export function useImageLoader({ isMountedRef.current = true; const abortControllers = abortControllersRef.current; const pendingFetches = pendingFetchesRef.current; + const loadingPromises = loadingPromisesRef.current; return () => { isMountedRef.current = false; abortControllers.forEach((controller) => controller.abort()); abortControllers.clear(); pendingFetches.clear(); + loadingPromises.clear(); }; }, []); @@ -57,6 +61,7 @@ export function useImageLoader({ abortControllersRef.current.forEach((controller) => controller.abort()); abortControllersRef.current.clear(); pendingFetchesRef.current.clear(); + loadingPromisesRef.current.clear(); }, []); const runWithConcurrency = useCallback( @@ -92,73 +97,96 @@ export function useImageLoader({ return; } - // Check if this page is already being fetched - if (pendingFetchesRef.current.has(pageNum)) { - return; + // Check if this page is already being fetched - if so, wait for it + const existingPromise = loadingPromisesRef.current.get(pageNum); + if (existingPromise) { + return existingPromise; } - // Mark as pending + // Mark as pending and create promise pendingFetchesRef.current.add(pageNum); const controller = new AbortController(); abortControllersRef.current.set(pageNum, controller); - try { - // Use browser cache if available - the server sets Cache-Control headers - const response = await fetch(getPageUrl(pageNum), { - cache: "default", // Respect Cache-Control headers from server - signal: controller.signal, - }); - if (!response.ok) { - return; - } - - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - - // Create image to get dimensions - const img = new Image(); - img.onload = () => { - if (!isMountedRef.current || controller.signal.aborted) { - URL.revokeObjectURL(blobUrl); + const promise = (async () => { + try { + // Use browser cache if available - the server sets Cache-Control headers + const response = await fetch(getPageUrl(pageNum), { + cache: "default", // Respect Cache-Control headers from server + signal: controller.signal, + }); + if (!response.ok) { return; } - setLoadedImages((prev) => ({ - ...prev, - [pageNum]: { width: img.naturalWidth, height: img.naturalHeight }, - })); + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); - // Store the blob URL for immediate use - setImageBlobUrls((prev) => ({ - ...prev, - [pageNum]: blobUrl, - })); - }; + // Create image to get dimensions + const img = new Image(); + + // Wait for image to load before resolving promise + await new Promise((resolve, reject) => { + img.onload = () => { + if (!isMountedRef.current || controller.signal.aborted) { + URL.revokeObjectURL(blobUrl); + reject(new Error("Aborted")); + return; + } - img.onerror = () => { - URL.revokeObjectURL(blobUrl); - }; + setLoadedImages((prev) => ({ + ...prev, + [pageNum]: { width: img.naturalWidth, height: img.naturalHeight }, + })); - img.src = blobUrl; - } catch { - // Silently fail prefetch - } finally { - // Remove from pending set - pendingFetchesRef.current.delete(pageNum); - abortControllersRef.current.delete(pageNum); - } + // Store the blob URL for immediate use + setImageBlobUrls((prev) => ({ + ...prev, + [pageNum]: blobUrl, + })); + + resolve(); + }; + + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error("Image load error")); + }; + + img.src = blobUrl; + }); + } catch { + // Silently fail prefetch + } finally { + // Remove from pending set and promise map + pendingFetchesRef.current.delete(pageNum); + abortControllersRef.current.delete(pageNum); + loadingPromisesRef.current.delete(pageNum); + } + })(); + + // Store promise so other calls can await it + loadingPromisesRef.current.set(pageNum, promise); + + return promise; }, [getPageUrl] ); // Prefetch multiple pages starting from a given page const prefetchPages = useCallback( - async (startPage: number, count: number = prefetchCount) => { + async ( + startPage: number, + count: number = prefetchCount, + excludePages: number[] = [], + concurrency?: number + ) => { const pagesToPrefetch = []; + const excludeSet = new Set(excludePages); for (let i = 0; i < count; i++) { const pageNum = startPage + i; - if (pageNum <= _pages.length) { + if (pageNum <= _pages.length && !excludeSet.has(pageNum)) { const hasDimensions = loadedImagesRef.current[pageNum]; const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const isPending = pendingFetchesRef.current.has(pageNum); @@ -170,10 +198,13 @@ export function useImageLoader({ } } + // Use provided concurrency or default + const effectiveConcurrency = concurrency ?? PREFETCH_CONCURRENCY; + // Let all prefetch requests run - the server queue will manage concurrency // The browser cache and our deduplication prevent redundant requests if (pagesToPrefetch.length > 0) { - runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => { + runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => { // Silently fail - prefetch is non-critical }); } @@ -340,6 +371,14 @@ export function useImageLoader({ }; }, []); // Empty dependency array - only cleanup on unmount + // Check if a page is currently being loaded + const isPageLoading = useCallback( + (pageNum: number) => { + return pendingFetchesRef.current.has(pageNum); + }, + [] + ); + return { loadedImages, imageBlobUrls, @@ -350,5 +389,6 @@ export function useImageLoader({ handleForceReload, getPageUrl, prefetchCount, + isPageLoading, }; } diff --git a/src/lib/providers/provider.factory.ts b/src/lib/providers/provider.factory.ts index 85734b7..9994946 100644 --- a/src/lib/providers/provider.factory.ts +++ b/src/lib/providers/provider.factory.ts @@ -1,5 +1,6 @@ import prisma from "@/lib/prisma"; import { getCurrentUser } from "@/lib/auth-utils"; +import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver"; import type { IMediaProvider } from "./provider.interface"; export async function getProvider(): Promise { @@ -13,7 +14,7 @@ export async function getProvider(): Promise { select: { activeProvider: true, config: { select: { url: true, authHeader: true } }, - stripstreamConfig: { select: { url: true, token: true } }, + stripstreamConfig: { select: { id: true } }, }, }); @@ -21,12 +22,12 @@ export async function getProvider(): Promise { const activeProvider = dbUser.activeProvider ?? "komga"; - if (activeProvider === "stripstream" && dbUser.stripstreamConfig) { - const { StripstreamProvider } = await import("./stripstream/stripstream.provider"); - return new StripstreamProvider( - dbUser.stripstreamConfig.url, - dbUser.stripstreamConfig.token - ); + if (activeProvider === "stripstream") { + const resolved = await getResolvedStripstreamConfig(userId); + if (resolved) { + const { StripstreamProvider } = await import("./stripstream/stripstream.provider"); + return new StripstreamProvider(resolved.url, resolved.token); + } } if (activeProvider === "komga" || !dbUser.activeProvider) { diff --git a/src/lib/providers/stripstream/stripstream-config-resolver.ts b/src/lib/providers/stripstream/stripstream-config-resolver.ts new file mode 100644 index 0000000..6b00e5c --- /dev/null +++ b/src/lib/providers/stripstream/stripstream-config-resolver.ts @@ -0,0 +1,26 @@ +import prisma from "@/lib/prisma"; + +export interface ResolvedStripstreamConfig { + url: string; + token: string; + source: "db" | "env"; +} + +/** + * Résout la config Stripstream : d'abord en base (par utilisateur), sinon depuis les env STRIPSTREAM_URL et STRIPSTREAM_TOKEN. + */ +export async function getResolvedStripstreamConfig( + userId: number +): Promise { + const fromDb = await prisma.stripstreamConfig.findUnique({ + where: { userId }, + select: { url: true, token: true }, + }); + if (fromDb) return { ...fromDb, source: "db" }; + + const url = process.env.STRIPSTREAM_URL?.trim(); + const token = process.env.STRIPSTREAM_TOKEN?.trim(); + if (url && token) return { url, token, source: "env" }; + + return null; +} diff --git a/src/types/env.d.ts b/src/types/env.d.ts index a53ce70..e75db60 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -3,5 +3,9 @@ declare namespace NodeJS { NEXT_PUBLIC_APP_URL: string; NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string; NEXT_PUBLIC_APP_VERSION: string; + /** URL Stripstream Librarian (fallback si pas de config en base) */ + STRIPSTREAM_URL?: string; + /** Token API Stripstream (fallback si pas de config en base) */ + STRIPSTREAM_TOKEN?: string; } }