From 3a349c83b16dc0faa7ae7e529a4405d3f67f45da Mon Sep 17 00:00:00 2001
From: neon <18559658+neonsolstice@users.noreply.github.com>
Date: Mon, 1 Jun 2026 23:56:21 +0000
Subject: [PATCH] feat(book-dock): support custom container directory via
BOOK_DOCK_PATH
The Book Dock directory inside the container was previously hardcoded to `/data/book-dock`. This adds support for the `BOOK_DOCK_PATH` environment variable, allowing users to customize the internal container directory where the Book Dock monitors for files. If the environment variable is not set, it defaults to the `book-dock` folder inside the configured app data path. This replaces the `BOOK_DROP_HOST_PATH` volume mapping in the compose file with a standard volume and environment variable approach.
Closes #178
---
.env.example | 8 +-------
README.md | 12 ------------
.../src/features/settings/BookDockSettings.vue | 4 +---
docker-compose.yml | 2 +-
server/src/config/config.test.ts | 17 +++++++++++++++++
server/src/config/config.ts | 14 ++++++++++----
server/src/config/env.validation.test.ts | 18 ++++++++++++++++++
server/src/config/env.validation.ts | 4 ++++
.../modules/app-info/app-info.service.test.ts | 14 +++++++-------
.../src/modules/app-info/app-info.service.ts | 3 ++-
.../book-dock/book-dock-ingest.service.test.ts | 15 +++++++++++++--
.../book-dock/book-dock-ingest.service.ts | 4 ++--
.../book-dock-watcher.service.test.ts | 15 +++++++++++++--
.../book-dock/book-dock-watcher.service.ts | 4 ++--
14 files changed, 91 insertions(+), 43 deletions(-)
diff --git a/.env.example b/.env.example
index cc4a25d7f..76a483d8f 100644
--- a/.env.example
+++ b/.env.example
@@ -8,12 +8,6 @@ APP_PORT=3000
# Point this at your library root; use an absolute path on NAS/Portainer installs.
BOOKS_HOST_PATH=./books
-# Optional: host folder for the Book Dock drop zone (maps to /data/book-dock inside the container).
-# Copy or move ebook files here and they will be automatically picked up and processed by Book Dock.
-# If not set, the drop folder lives inside ./data/app/book-dock (the default app data volume).
-# Use an absolute path when mounting a NAS share or a folder managed outside the app data directory.
-# BOOK_DROP_HOST_PATH=/mnt/nas/book-drop
-
# Container runtime UID/GID for files written to app-managed data folders.
# Most users can leave these defaults.
PUID=1000
@@ -32,7 +26,7 @@ SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
# External/public URL used by emails and Kobo endpoints
APP_URL=https://bookorbit.example.com
-# Node.js JavaScript heap limit in MB. Raise this for very large libraries.
+# Node.js JavaScript heap limit in MB. Raise this for very large libraries, 250K+ books.
NODE_MAX_OLD_SPACE_SIZE=2048
# --- Optional overrides (uncomment to set) ---
diff --git a/README.md b/README.md
index 81f387033..c24601de4 100644
--- a/README.md
+++ b/README.md
@@ -90,18 +90,6 @@ JWT_SECRET= # signs login tokens - openssl rand -hex 32
SETUP_BOOTSTRAP_TOKEN= # one-time setup wizard token - openssl rand -hex 16
```
-**Optional: Book Dock drop folder**
-
-To automatically ingest books placed in a folder (for example, a NAS share or a folder managed by another tool), set `BOOK_DROP_HOST_PATH` in `.env`:
-
-```dotenv
-BOOK_DROP_HOST_PATH=/mnt/nas/book-drop
-```
-
-Any ebook file copied or moved into that folder is automatically picked up and processed by Book Dock. The container-internal path it maps to is shown in Settings - Book Dock. Subdirectories are supported. Covers are stored in a `covers/` subdirectory inside the drop folder. Files are removed from the drop folder when finalized into a library.
-
-If `BOOK_DROP_HOST_PATH` is not set, the drop folder defaults to `./data/app/book-dock` inside the app data volume.
-
Then start:
```bash
diff --git a/client/src/features/settings/BookDockSettings.vue b/client/src/features/settings/BookDockSettings.vue
index b56d074e5..ce568dec8 100644
--- a/client/src/features/settings/BookDockSettings.vue
+++ b/client/src/features/settings/BookDockSettings.vue
@@ -169,9 +169,7 @@ async function onMetadataModeChange(event: Event) {
>{{ bookDockPath }}
- To use a custom host path, set BOOK_DROP_HOST_PATH in your
- .env file and add the corresponding volume to
- docker-compose.yml. The path shown above is the container-internal path to bind-mount.
+ To change this path, set BOOK_DOCK_PATH in your .env file.
diff --git a/docker-compose.yml b/docker-compose.yml
index b19ed210f..519791a36 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,13 +26,13 @@ services:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
NODE_MAX_OLD_SPACE_SIZE: ${NODE_MAX_OLD_SPACE_SIZE:-2048}
+ BOOK_DOCK_PATH: ${BOOK_DOCK_PATH:-}
depends_on:
postgres:
condition: service_healthy
volumes:
- ${BOOKS_HOST_PATH:-./books}:/books
- ./data/app:/data
- - ${BOOK_DROP_HOST_PATH:-./data/app/book-dock}:/data/book-dock
read_only: true
tmpfs:
- /tmp
diff --git a/server/src/config/config.test.ts b/server/src/config/config.test.ts
index ebf5ceb31..4a546876c 100644
--- a/server/src/config/config.test.ts
+++ b/server/src/config/config.test.ts
@@ -16,6 +16,7 @@ function resetEnv(): void {
delete process.env.JWT_REFRESH_EXPIRES_IN;
delete process.env.SETUP_BOOTSTRAP_TOKEN;
delete process.env.APP_DATA_PATH;
+ delete process.env.BOOK_DOCK_PATH;
delete process.env.FILE_WRITE_DEBOUNCE_MS;
delete process.env.FILE_WRITE_MAX_CONCURRENT_WRITES;
delete process.env.EMAIL_ENCRYPTION_KEY;
@@ -80,10 +81,26 @@ describe('config', () => {
it('resolves storage path from APP_DATA_PATH', () => {
process.env.APP_DATA_PATH = './tmp/bookorbit-data';
expect(storageConfig().appDataPath).toBe(resolve('./tmp/bookorbit-data'));
+ expect(storageConfig().bookDockPath).toBe(resolve('./tmp/bookorbit-data/book-dock'));
});
it('falls back storage path to /data when APP_DATA_PATH is not set', () => {
expect(storageConfig().appDataPath).toBe(resolve('/data'));
+ expect(storageConfig().bookDockPath).toBe(resolve('/data/book-dock'));
+ });
+
+ it('uses BOOK_DOCK_PATH as the Book Dock storage path when provided', () => {
+ process.env.APP_DATA_PATH = '/data';
+ process.env.BOOK_DOCK_PATH = '/books/bookdrop';
+
+ expect(storageConfig().bookDockPath).toBe(resolve('/books/bookdrop'));
+ });
+
+ it('ignores blank BOOK_DOCK_PATH values from compose defaults', () => {
+ process.env.APP_DATA_PATH = '/custom/data';
+ process.env.BOOK_DOCK_PATH = '';
+
+ expect(storageConfig().bookDockPath).toBe(resolve('/custom/data/book-dock'));
});
it('parses positive integers for file-write config and floors decimal values', () => {
diff --git a/server/src/config/config.ts b/server/src/config/config.ts
index cca0255a4..7b7d5af24 100644
--- a/server/src/config/config.ts
+++ b/server/src/config/config.ts
@@ -1,5 +1,5 @@
import { registerAs } from '@nestjs/config';
-import { resolve } from 'path';
+import { join, resolve } from 'path';
export const appConfig = registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
@@ -20,9 +20,15 @@ export const authConfig = registerAs('auth', () => ({
refreshRotationGraceMs: parsePositiveInteger(process.env.AUTH_REFRESH_ROTATION_GRACE_MS, 30_000),
}));
-export const storageConfig = registerAs('storage', () => ({
- appDataPath: resolve(process.env.APP_DATA_PATH ?? '/data'),
-}));
+export const storageConfig = registerAs('storage', () => {
+ const appDataPath = resolve(process.env.APP_DATA_PATH ?? '/data');
+ const bookDockPath = process.env.BOOK_DOCK_PATH?.trim();
+
+ return {
+ appDataPath,
+ bookDockPath: resolve(bookDockPath || join(appDataPath, 'book-dock')),
+ };
+});
export const fileWriteConfig = registerAs('fileWrite', () => ({
debounceMs: parsePositiveInteger(process.env.FILE_WRITE_DEBOUNCE_MS, 3_000),
diff --git a/server/src/config/env.validation.test.ts b/server/src/config/env.validation.test.ts
index a9e4c2be4..a39436313 100644
--- a/server/src/config/env.validation.test.ts
+++ b/server/src/config/env.validation.test.ts
@@ -78,4 +78,22 @@ describe('validateEnv', () => {
}),
).toThrow('OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off');
});
+
+ it('accepts a custom Book Dock container path', () => {
+ expect(() =>
+ validateEnv({
+ ...BASE_ENV,
+ BOOK_DOCK_PATH: '/books/bookdrop',
+ }),
+ ).not.toThrow();
+ });
+
+ it('accepts an empty custom Book Dock container path as an unset override', () => {
+ expect(() =>
+ validateEnv({
+ ...BASE_ENV,
+ BOOK_DOCK_PATH: '',
+ }),
+ ).not.toThrow();
+ });
});
diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts
index bbc0b6045..1cb04ef9e 100644
--- a/server/src/config/env.validation.ts
+++ b/server/src/config/env.validation.ts
@@ -32,6 +32,10 @@ const envSchema = z.object({
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
APP_DATA_PATH: z.string().default('/data'),
+ BOOK_DOCK_PATH: z
+ .string()
+ .transform((val) => val.trim())
+ .optional(),
FILE_WRITE_DEBOUNCE_MS: z.coerce.number().int().positive().optional(),
FILE_WRITE_MAX_CONCURRENT_WRITES: z.coerce.number().int().positive().optional(),
CLIENT_URL: z.string().url().optional(),
diff --git a/server/src/modules/app-info/app-info.service.test.ts b/server/src/modules/app-info/app-info.service.test.ts
index f20d7ca25..89766a0fd 100644
--- a/server/src/modules/app-info/app-info.service.test.ts
+++ b/server/src/modules/app-info/app-info.service.test.ts
@@ -5,10 +5,10 @@ import { AppSettingsService } from '../app-settings/app-settings.service';
import { GITHUB_RELEASES_API } from './app-info.constants';
import { AppInfoService } from './app-info.service';
-function makeConfig(version = 'v1.2.3', appDataPath = '/data'): jest.Mocked {
+function makeConfig(version = 'v1.2.3', bookDockPath = '/data/book-dock'): jest.Mocked {
return {
get: vi.fn().mockImplementation((key: string) => {
- if (key === 'storage.appDataPath') return appDataPath;
+ if (key === 'storage.bookDockPath') return bookDockPath;
if (key === 'app.version') return version;
return undefined;
}),
@@ -61,19 +61,19 @@ describe('AppInfoService', () => {
expect(info.latestVersion).toBeNull();
});
- it('returns bookDockPath as appDataPath/book-dock', async () => {
- const service = new AppInfoService(makeConfig('v1.0.0', '/custom/data'), makeAppSettings());
- expect((await service.getAppInfo()).bookDockPath).toBe('/custom/data/book-dock');
+ it('returns bookDockPath from config', async () => {
+ const service = new AppInfoService(makeConfig('v1.0.0', '/books/bookdrop'), makeAppSettings());
+ expect((await service.getAppInfo()).bookDockPath).toBe('/books/bookdrop');
});
- it('falls back to /data/book-dock when storage.appDataPath is not configured', async () => {
+ it('falls back to /data/book-dock when storage.bookDockPath is not configured', async () => {
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
const service = new AppInfoService(config, makeAppSettings());
expect((await service.getAppInfo()).bookDockPath).toBe('/data/book-dock');
});
it('includes bookDockPath in full response shape', async () => {
- const service = new AppInfoService(makeConfig('Local build', '/app/data'), makeAppSettings());
+ const service = new AppInfoService(makeConfig('Local build', '/app/data/book-dock'), makeAppSettings());
const info = await service.getAppInfo();
expect(info).toMatchObject({
version: 'Local build',
diff --git a/server/src/modules/app-info/app-info.service.ts b/server/src/modules/app-info/app-info.service.ts
index 099d38ca7..89a76c11c 100644
--- a/server/src/modules/app-info/app-info.service.ts
+++ b/server/src/modules/app-info/app-info.service.ts
@@ -36,6 +36,7 @@ export class AppInfoService implements OnApplicationBootstrap {
const version = this.config.get('app.version') ?? 'Local build';
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
const appDataPath = this.config.get('storage.appDataPath') ?? '/data';
+ const bookDockPath = this.config.get('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
if (enabled && SEMVER_RE.test(version)) {
const now = Date.now();
@@ -49,7 +50,7 @@ export class AppInfoService implements OnApplicationBootstrap {
version,
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
- bookDockPath: join(appDataPath, 'book-dock'),
+ bookDockPath,
};
}
diff --git a/server/src/modules/book-dock/book-dock-ingest.service.test.ts b/server/src/modules/book-dock/book-dock-ingest.service.test.ts
index ea6043ac4..f8f39a73b 100644
--- a/server/src/modules/book-dock/book-dock-ingest.service.test.ts
+++ b/server/src/modules/book-dock/book-dock-ingest.service.test.ts
@@ -12,10 +12,10 @@ vi.mock('fs/promises', () => ({
const mockedStat = vi.mocked(stat);
-function makeService() {
+function makeService(bookDockPath = '/books/book-dock') {
const config = {
get: vi.fn((key: string) => {
- if (key === 'storage.appDataPath') return '/books';
+ if (key === 'storage.bookDockPath') return bookDockPath;
return undefined;
}),
};
@@ -90,6 +90,17 @@ describe('BookDockIngestService', () => {
expect((service as any).bookDockPath).toBe('/real/books/book-dock');
});
+ it('uses a custom configured Book Dock path', async () => {
+ const { service } = makeService('/books/bookdrop');
+ vi.mocked(realpath).mockResolvedValue('/real/books/bookdrop');
+
+ await service.onApplicationBootstrap();
+
+ expect(mkdir).toHaveBeenCalledWith('/books/bookdrop', { recursive: true });
+ expect(realpath).toHaveBeenCalledWith('/books/bookdrop');
+ expect((service as any).bookDockPath).toBe('/real/books/bookdrop');
+ });
+
describe('ingestUpload', () => {
it('succeeds and returns the row ID', async () => {
const { service, validator, storage, repo } = makeService();
diff --git a/server/src/modules/book-dock/book-dock-ingest.service.ts b/server/src/modules/book-dock/book-dock-ingest.service.ts
index 2583bc136..e88a277b7 100644
--- a/server/src/modules/book-dock/book-dock-ingest.service.ts
+++ b/server/src/modules/book-dock/book-dock-ingest.service.ts
@@ -30,8 +30,8 @@ export class BookDockIngestService implements OnApplicationBootstrap {
private readonly metadataFetchPipeline: MetadataFetchPipeline,
private readonly gateway: BookDockGateway,
) {
- const appDataPath = this.config.get('storage.appDataPath')!;
- this.bookDockPath = join(appDataPath, 'book-dock');
+ const appDataPath = this.config.get('storage.appDataPath') ?? '/data';
+ this.bookDockPath = this.config.get('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
}
async onApplicationBootstrap(): Promise {
diff --git a/server/src/modules/book-dock/book-dock-watcher.service.test.ts b/server/src/modules/book-dock/book-dock-watcher.service.test.ts
index ade249895..5a3551892 100644
--- a/server/src/modules/book-dock/book-dock-watcher.service.test.ts
+++ b/server/src/modules/book-dock/book-dock-watcher.service.test.ts
@@ -24,9 +24,9 @@ import { isPrimaryFormat } from '../scanner/lib/classify';
import { waitForStability } from '../scanner/lib/stability';
import { BookDockWatcherService } from './book-dock-watcher.service';
-function makeService() {
+function makeService(bookDockPath = '/data/book-dock') {
const config = {
- get: vi.fn().mockReturnValue('/data'),
+ get: vi.fn().mockImplementation((key: string) => (key === 'storage.bookDockPath' ? bookDockPath : undefined)),
};
const ingestService = {
ingestFromWatchedFolder: vi.fn(),
@@ -59,6 +59,17 @@ describe('BookDockWatcherService', () => {
expect(emitSpy).toHaveBeenCalledTimes(1);
});
+ it('rescan uses a custom configured Book Dock path', async () => {
+ const { service } = makeService('/books/bookdrop');
+ const walkSpy = vi.spyOn(service as any, 'walkAndIngest').mockResolvedValue(undefined);
+ const emitSpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined);
+
+ await service.rescan();
+
+ expect(walkSpy).toHaveBeenCalledWith('/books/bookdrop');
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+
it('startWatcher ensures directory exists and subscribes for file events', async () => {
const { service } = makeService();
vi.mocked(subscribe).mockResolvedValue({ unsubscribe: vi.fn() } as never);
diff --git a/server/src/modules/book-dock/book-dock-watcher.service.ts b/server/src/modules/book-dock/book-dock-watcher.service.ts
index dbd36a3c8..72f743c1f 100644
--- a/server/src/modules/book-dock/book-dock-watcher.service.ts
+++ b/server/src/modules/book-dock/book-dock-watcher.service.ts
@@ -29,8 +29,8 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
private readonly repo: BookDockRepository,
private readonly gateway: BookDockGateway,
) {
- const appDataPath = this.config.get('storage.appDataPath')!;
- this.bookDockPath = join(appDataPath, 'book-dock');
+ const appDataPath = this.config.get('storage.appDataPath') ?? '/data';
+ this.bookDockPath = this.config.get('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
}
async onApplicationBootstrap(): Promise {