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 {