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

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

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites
- Node.js 20.x or higher
- Yarn 1.22.x or higher
- pnpm 9.x or higher
- Docker and Docker Compose (optional)
## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies
```bash
yarn install
pnpm install
```
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
```bash
yarn dev
pnpm dev
```
### With Docker (Build Local)
@@ -121,7 +121,7 @@ cd stripstream
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)
@@ -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:
```yaml
version: '3.8'
services:
app:
image: julienfroidefond32/stripstream:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
# Add your environment variables here or use an .env file
# Required
- 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:
- ./data:/app/data
- ./data:/app/prisma/data
restart: unless-stopped
```
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
## 🔧 Available Scripts
- `yarn dev` - Starts the development server
- `yarn build` - Creates a production build
- `yarn start` - Runs the production version
- `yarn lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier
- `pnpm dev` - Starts the development server
- `pnpm build` - Creates a production build
- `pnpm start` - Runs the production version
- `pnpm lint` - Checks code with ESLint
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script

View File

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

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
webpack: (config) => {
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 { 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 {

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<AppErrorType | null>(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",

View File

@@ -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<AppErrorType | null>(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({

View File

@@ -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 <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 (
@@ -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}
/>
<NavigationBar

View File

@@ -9,6 +9,7 @@ interface PageDisplayProps {
imageBlobUrls: Record<number, string>;
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 */}
<img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
src={
imageBlobUrls[currentPage] ||
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
}
alt={`Page ${currentPage}`}
className={cn(
"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 */}
<img
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}`}
className={cn(
"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
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
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
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<void>((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,
};
}

View File

@@ -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<IMediaProvider | null> {
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
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<IMediaProvider | null> {
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) {

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_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;
}
}