mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
+1
-7
@@ -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) ---
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user