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
This commit is contained in:
neon
2026-06-01 23:56:21 +00:00
parent f20efdeedd
commit 3a349c83b1
14 changed files with 91 additions and 43 deletions
+1 -7
View File
@@ -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) ---
-12
View File
@@ -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
@@ -169,9 +169,7 @@ async function onMetadataModeChange(event: Event) {
>{{ bookDockPath }}</code
>
<p class="settings-hint mt-2">
To use a custom host path, set <code class="text-xs font-mono">BOOK_DROP_HOST_PATH</code> in your
<code class="text-xs font-mono">.env</code> file and add the corresponding volume to
<code class="text-xs font-mono">docker-compose.yml</code>. The path shown above is the container-internal path to bind-mount.
To change this path, set <code class="text-xs font-mono">BOOK_DOCK_PATH</code> in your <code class="text-xs font-mono">.env</code> file.
</p>
</div>
</div>
+1 -1
View File
@@ -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
+17
View File
@@ -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', () => {
+10 -4
View File
@@ -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),
+18
View File
@@ -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();
});
});
+4
View File
@@ -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(),
@@ -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<ConfigService> {
function makeConfig(version = 'v1.2.3', bookDockPath = '/data/book-dock'): jest.Mocked<ConfigService> {
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',
@@ -36,6 +36,7 @@ export class AppInfoService implements OnApplicationBootstrap {
const version = this.config.get<string>('app.version') ?? 'Local build';
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
const bookDockPath = this.config.get<string>('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,
};
}
@@ -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();
@@ -30,8 +30,8 @@ export class BookDockIngestService implements OnApplicationBootstrap {
private readonly metadataFetchPipeline: MetadataFetchPipeline,
private readonly gateway: BookDockGateway,
) {
const appDataPath = this.config.get<string>('storage.appDataPath')!;
this.bookDockPath = join(appDataPath, 'book-dock');
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
this.bookDockPath = this.config.get<string>('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
}
async onApplicationBootstrap(): Promise<void> {
@@ -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);
@@ -29,8 +29,8 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
private readonly repo: BookDockRepository,
private readonly gateway: BookDockGateway,
) {
const appDataPath = this.config.get<string>('storage.appDataPath')!;
this.bookDockPath = join(appDataPath, 'book-dock');
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
this.bookDockPath = this.config.get<string>('storage.bookDockPath') ?? join(appDataPath, 'book-dock');
}
async onApplicationBootstrap(): Promise<void> {