Compare commits

...

2 Commits

Author SHA1 Message Date
8d1f91d636 feat: optimize Docker startup with Next.js standalone output and proper migrations
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Add `output: standalone` to next.config.js for faster cold start
- Rebuild runner stage around standalone bundle (node server.js instead of pnpm start)
- Replace prisma db push with prisma migrate deploy (proper migration workflow)
- Remove npx/pnpm at runtime, use direct binary paths
- Add HOSTNAME=0.0.0.0 for standalone server to listen on all interfaces
- Fix next.config.js not copied in builder stage
- Update README: pnpm instead of yarn, correct ports, full env vars documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:52:49 +01:00
7e4c48469a 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.
2026-03-11 21:25:58 +01:00
18 changed files with 297 additions and 117 deletions

View File

@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
NEXTAUTH_SECRET=SECRET NEXTAUTH_SECRET=SECRET
#openssl rand -base64 32 #openssl rand -base64 32
NEXTAUTH_URL=http://localhost:3000 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

View File

@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma COPY prisma ./prisma
# Copy configuration files # Copy configuration files
COPY tsconfig.json .eslintrc.json ./ COPY tsconfig.json .eslintrc.json next.config.js ./
COPY tailwind.config.ts postcss.config.js ./ COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with pnpm using cache mount for store # Install dependencies with pnpm using cache mount for store
@@ -43,22 +43,20 @@ WORKDIR /app
# Install OpenSSL (required by Prisma) # Install OpenSSL (required by Prisma)
RUN apk add --no-cache openssl libc6-compat RUN apk add --no-cache openssl libc6-compat
# Copy package files and prisma schema # Copy standalone output (server.js + minimal node_modules)
COPY package.json pnpm-lock.yaml ./ COPY --from=builder /app/.next/standalone ./
COPY prisma ./prisma
# Enable pnpm # Copy static assets and public directory
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy the entire node_modules from builder (includes Prisma Client) # Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
# Copy built application from builder stage # Copy prisma schema and init scripts
COPY --from=builder /app/.next ./.next COPY prisma ./prisma
COPY --from=builder /app/public ./public
COPY --from=builder /app/next-env.d.ts ./
COPY --from=builder /app/tailwind.config.ts ./
COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/scripts ./scripts
COPY package.json ./
# Copy entrypoint script # Copy entrypoint script
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
@@ -76,6 +74,7 @@ USER nextjs
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME="0.0.0.0"
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 3000 EXPOSE 3000

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites ## 🛠 Prerequisites
- Node.js 20.x or higher - Node.js 20.x or higher
- Yarn 1.22.x or higher - pnpm 9.x or higher
- Docker and Docker Compose (optional) - Docker and Docker Compose (optional)
## 📦 Installation ## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies 2. Install dependencies
```bash ```bash
yarn install pnpm install
``` ```
3. Copy the example environment file and adjust it to your needs 3. Copy the example environment file and adjust it to your needs
@@ -103,7 +103,7 @@ cp .env.example .env.local
4. Start the development server 4. Start the development server
```bash ```bash
yarn dev pnpm dev
``` ```
### With Docker (Build Local) ### With Docker (Build Local)
@@ -121,7 +121,7 @@ cd stripstream
docker-compose up --build docker-compose up --build
``` ```
The application will be accessible at `http://localhost:3000` The application will be accessible at `http://localhost:3020`
### With Docker (DockerHub Image) ### With Docker (DockerHub Image)
@@ -130,18 +130,24 @@ You can also use the pre-built image from DockerHub without cloning the reposito
1. Create a `docker-compose.yml` file: 1. Create a `docker-compose.yml` file:
```yaml ```yaml
version: '3.8'
services: services:
app: app:
image: julienfroidefond32/stripstream:latest image: julienfroidefond32/stripstream:latest
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production # Required
# Add your environment variables here or use an .env file - NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
- NEXTAUTH_URL=http://localhost:3000
# Optional — defaults shown
# - NODE_ENV=production
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
# - AUTH_TRUST_HOST=true
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
volumes: volumes:
- ./data:/app/data - ./data:/app/prisma/data
restart: unless-stopped restart: unless-stopped
``` ```
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
## 🔧 Available Scripts ## 🔧 Available Scripts
- `yarn dev` - Starts the development server - `pnpm dev` - Starts the development server
- `yarn build` - Creates a production build - `pnpm build` - Creates a production build
- `yarn start` - Runs the production version - `pnpm start` - Runs the production version
- `yarn lint` - Checks code with ESLint - `pnpm lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`) - `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script ### Docker Push Script

View File

@@ -1,15 +1,11 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "📁 Ensuring data directory exists..." echo "🔄 Applying database migrations..."
mkdir -p /app/data ./node_modules/.bin/prisma migrate deploy
echo "🔄 Pushing Prisma schema to database..."
npx prisma db push --skip-generate --accept-data-loss
echo "🔧 Initializing database..." echo "🔧 Initializing database..."
node scripts/init-db.mjs node scripts/init-db.mjs
echo "🚀 Starting application..." echo "🚀 Starting application..."
exec pnpm start exec node server.js

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
webpack: (config) => { webpack: (config) => {
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,

View File

@@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "users" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
"authenticated" BOOLEAN NOT NULL DEFAULT true,
"activeProvider" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "komgaconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"authHeader" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "stripstreamconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "preferences" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
"displayMode" JSONB NOT NULL,
"background" JSONB NOT NULL,
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "favorites" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"seriesId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'komga',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
-- CreateIndex
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils"; import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider"; import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types"; import type { ProviderType } from "@/lib/providers/types";
@@ -82,7 +83,7 @@ export async function setActiveProvider(
return { success: false, message: "Komga n'est pas encore configuré" }; return { success: false, message: "Komga n'est pas encore configuré" };
} }
} else if (provider === "stripstream") { } else if (provider === "stripstream") {
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" }; 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<{ export async function getStripstreamConfig(): Promise<{
url?: string; url?: string;
@@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{
if (!user) return null; if (!user) return null;
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ const resolved = await getResolvedStripstreamConfig(userId);
where: { userId }, if (!resolved) return null;
select: { url: true }, return { url: resolved.url, hasToken: true };
});
if (!config) return null;
return { url: config.url, hasToken: true };
} catch { } catch {
return null; return null;
} }
@@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{
} }
const userId = parseInt(user.id, 10); 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.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }), prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }), getResolvedStripstreamConfig(userId),
]); ]);
return { return {
komgaConfigured: !!komgaConfig, komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamConfig, stripstreamConfigured: !!stripstreamResolved,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga", activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
}; };
} catch { } catch {

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils"; 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 { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -23,7 +23,7 @@ export async function GET(
} }
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG); throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
} }

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils"; 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 { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
@@ -20,7 +20,7 @@ export async function GET(
} }
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG); throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@@ -16,7 +15,6 @@ interface LoginFormProps {
} }
export function LoginForm({ from }: LoginFormProps) { export function LoginForm({ from }: LoginFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppErrorType | null>(null); const [error, setError] = useState<AppErrorType | null>(null);
const { t } = useTranslate(); const { t } = useTranslate();
@@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) {
} }
const redirectPath = getSafeRedirectPath(from); const redirectPath = getSafeRedirectPath(from);
window.location.assign(redirectPath); window.location.href = redirectPath;
router.refresh();
} catch { } catch {
setError({ setError({
code: "AUTH_FETCH_ERROR", code: "AUTH_FETCH_ERROR",

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@@ -16,7 +15,6 @@ interface RegisterFormProps {
} }
export function RegisterForm({ from }: RegisterFormProps) { export function RegisterForm({ from }: RegisterFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppErrorType | null>(null); const [error, setError] = useState<AppErrorType | null>(null);
const { t } = useTranslate(); const { t } = useTranslate();
@@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) {
}); });
} else { } else {
const redirectPath = getSafeRedirectPath(from); const redirectPath = getSafeRedirectPath(from);
window.location.assign(redirectPath); window.location.href = redirectPath;
router.refresh();
} }
} catch { } catch {
setError({ setError({

View File

@@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { const {
loadedImages, loadedImages,
imageBlobUrls, imageBlobUrls,
prefetchImage,
prefetchPages, prefetchPages,
prefetchNextBook, prefetchNextBook,
cancelAllPrefetches, cancelAllPrefetches,
handleForceReload, handleForceReload,
getPageUrl, getPageUrl,
prefetchCount, prefetchCount,
isPageLoading,
} = useImageLoader({ } = useImageLoader({
pageUrlBuilder: bookPageUrlBuilder, pageUrlBuilder: bookPageUrlBuilder,
pages, pages,
@@ -87,8 +89,26 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
// Prefetch current and next pages // Prefetch current and next pages
useEffect(() => { useEffect(() => {
// Prefetch pages starting from current page // Determine visible pages that need to be loaded immediately
prefetchPages(currentPage, prefetchCount); 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 <img> tags
// These will populate imageBlobUrls so <img> 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 double page mode, also prefetch additional pages for smooth double page navigation
if ( if (
@@ -96,7 +116,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
shouldShowDoublePage(currentPage, pages.length) && shouldShowDoublePage(currentPage, pages.length) &&
currentPage + prefetchCount < 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 // 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, currentPage,
isDoublePage, isDoublePage,
shouldShowDoublePage, shouldShowDoublePage,
prefetchImage,
prefetchPages, prefetchPages,
prefetchNextBook, prefetchNextBook,
prefetchCount, prefetchCount,
@@ -229,6 +250,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
imageBlobUrls={imageBlobUrls} imageBlobUrls={imageBlobUrls}
getPageUrl={getPageUrl} getPageUrl={getPageUrl}
isRTL={isRTL} isRTL={isRTL}
isPageLoading={isPageLoading}
/> />
<NavigationBar <NavigationBar

View File

@@ -9,6 +9,7 @@ interface PageDisplayProps {
imageBlobUrls: Record<number, string>; imageBlobUrls: Record<number, string>;
getPageUrl: (pageNum: number) => string; getPageUrl: (pageNum: number) => string;
isRTL: boolean; isRTL: boolean;
isPageLoading?: (pageNum: number) => boolean;
} }
export function PageDisplay({ export function PageDisplay({
@@ -19,6 +20,7 @@ export function PageDisplay({
imageBlobUrls, imageBlobUrls,
getPageUrl, getPageUrl,
isRTL, isRTL,
isPageLoading,
}: PageDisplayProps) { }: PageDisplayProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
@@ -102,7 +104,10 @@ export function PageDisplay({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`} key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)} src={
imageBlobUrls[currentPage] ||
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
}
alt={`Page ${currentPage}`} alt={`Page ${currentPage}`}
className={cn( className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity", "max-h-full max-w-full cursor-pointer object-contain transition-opacity",
@@ -166,7 +171,12 @@ export function PageDisplay({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`} key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)} src={
imageBlobUrls[currentPage + 1] ||
(isPageLoading && isPageLoading(currentPage + 1)
? undefined
: getPageUrl(currentPage + 1))
}
alt={`Page ${currentPage + 1}`} alt={`Page ${currentPage + 1}`}
className={cn( className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity", "max-h-full max-w-full cursor-pointer object-contain transition-opacity",

View File

@@ -30,6 +30,8 @@ export function useImageLoader({
// Track ongoing fetch requests to prevent duplicates // Track ongoing fetch requests to prevent duplicates
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set()); const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map()); const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
// Track promises for pages being loaded so we can await them
const loadingPromisesRef = useRef<Map<ImageKey, Promise<void>>>(new Map());
// Keep refs in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
@@ -44,12 +46,14 @@ export function useImageLoader({
isMountedRef.current = true; isMountedRef.current = true;
const abortControllers = abortControllersRef.current; const abortControllers = abortControllersRef.current;
const pendingFetches = pendingFetchesRef.current; const pendingFetches = pendingFetchesRef.current;
const loadingPromises = loadingPromisesRef.current;
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
abortControllers.forEach((controller) => controller.abort()); abortControllers.forEach((controller) => controller.abort());
abortControllers.clear(); abortControllers.clear();
pendingFetches.clear(); pendingFetches.clear();
loadingPromises.clear();
}; };
}, []); }, []);
@@ -57,6 +61,7 @@ export function useImageLoader({
abortControllersRef.current.forEach((controller) => controller.abort()); abortControllersRef.current.forEach((controller) => controller.abort());
abortControllersRef.current.clear(); abortControllersRef.current.clear();
pendingFetchesRef.current.clear(); pendingFetchesRef.current.clear();
loadingPromisesRef.current.clear();
}, []); }, []);
const runWithConcurrency = useCallback( const runWithConcurrency = useCallback(
@@ -92,16 +97,18 @@ export function useImageLoader({
return; return;
} }
// Check if this page is already being fetched // Check if this page is already being fetched - if so, wait for it
if (pendingFetchesRef.current.has(pageNum)) { const existingPromise = loadingPromisesRef.current.get(pageNum);
return; if (existingPromise) {
return existingPromise;
} }
// Mark as pending // Mark as pending and create promise
pendingFetchesRef.current.add(pageNum); pendingFetchesRef.current.add(pageNum);
const controller = new AbortController(); const controller = new AbortController();
abortControllersRef.current.set(pageNum, controller); abortControllersRef.current.set(pageNum, controller);
const promise = (async () => {
try { try {
// Use browser cache if available - the server sets Cache-Control headers // Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), { const response = await fetch(getPageUrl(pageNum), {
@@ -117,9 +124,13 @@ export function useImageLoader({
// Create image to get dimensions // Create image to get dimensions
const img = new Image(); const img = new Image();
// Wait for image to load before resolving promise
await new Promise<void>((resolve, reject) => {
img.onload = () => { img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) { if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
reject(new Error("Aborted"));
return; return;
} }
@@ -133,32 +144,49 @@ export function useImageLoader({
...prev, ...prev,
[pageNum]: blobUrl, [pageNum]: blobUrl,
})); }));
resolve();
}; };
img.onerror = () => { img.onerror = () => {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
reject(new Error("Image load error"));
}; };
img.src = blobUrl; img.src = blobUrl;
});
} catch { } catch {
// Silently fail prefetch // Silently fail prefetch
} finally { } finally {
// Remove from pending set // Remove from pending set and promise map
pendingFetchesRef.current.delete(pageNum); pendingFetchesRef.current.delete(pageNum);
abortControllersRef.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] [getPageUrl]
); );
// Prefetch multiple pages starting from a given page // Prefetch multiple pages starting from a given page
const prefetchPages = useCallback( const prefetchPages = useCallback(
async (startPage: number, count: number = prefetchCount) => { async (
startPage: number,
count: number = prefetchCount,
excludePages: number[] = [],
concurrency?: number
) => {
const pagesToPrefetch = []; const pagesToPrefetch = [];
const excludeSet = new Set(excludePages);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const pageNum = startPage + i; const pageNum = startPage + i;
if (pageNum <= _pages.length) { if (pageNum <= _pages.length && !excludeSet.has(pageNum)) {
const hasDimensions = loadedImagesRef.current[pageNum]; const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum]; const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(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 // Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests // The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) { if (pagesToPrefetch.length > 0) {
runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => { runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => {
// Silently fail - prefetch is non-critical // Silently fail - prefetch is non-critical
}); });
} }
@@ -340,6 +371,14 @@ export function useImageLoader({
}; };
}, []); // Empty dependency array - only cleanup on unmount }, []); // 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 { return {
loadedImages, loadedImages,
imageBlobUrls, imageBlobUrls,
@@ -350,5 +389,6 @@ export function useImageLoader({
handleForceReload, handleForceReload,
getPageUrl, getPageUrl,
prefetchCount, prefetchCount,
isPageLoading,
}; };
} }

View File

@@ -1,5 +1,6 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils"; import { getCurrentUser } from "@/lib/auth-utils";
import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver";
import type { IMediaProvider } from "./provider.interface"; import type { IMediaProvider } from "./provider.interface";
export async function getProvider(): Promise<IMediaProvider | null> { export async function getProvider(): Promise<IMediaProvider | null> {
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
select: { select: {
activeProvider: true, activeProvider: true,
config: { select: { url: true, authHeader: 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<IMediaProvider | null> {
const activeProvider = dbUser.activeProvider ?? "komga"; const activeProvider = dbUser.activeProvider ?? "komga";
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) { if (activeProvider === "stripstream") {
const resolved = await getResolvedStripstreamConfig(userId);
if (resolved) {
const { StripstreamProvider } = await import("./stripstream/stripstream.provider"); const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
return new StripstreamProvider( return new StripstreamProvider(resolved.url, resolved.token);
dbUser.stripstreamConfig.url, }
dbUser.stripstreamConfig.token
);
} }
if (activeProvider === "komga" || !dbUser.activeProvider) { if (activeProvider === "komga" || !dbUser.activeProvider) {

View File

@@ -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<ResolvedStripstreamConfig | null> {
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;
}

4
src/types/env.d.ts vendored
View File

@@ -3,5 +3,9 @@ declare namespace NodeJS {
NEXT_PUBLIC_APP_URL: string; NEXT_PUBLIC_APP_URL: string;
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string; NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
NEXT_PUBLIC_APP_VERSION: 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;
} }
} }