diff --git a/client/src/features/settings/composables/__tests__/useAppInfo.test.ts b/client/src/features/settings/composables/__tests__/useAppInfo.test.ts
index 1e5b5d18f..8aa98bae7 100644
--- a/client/src/features/settings/composables/__tests__/useAppInfo.test.ts
+++ b/client/src/features/settings/composables/__tests__/useAppInfo.test.ts
@@ -25,19 +25,21 @@ function makeErrorResponse(status: number): Response {
describe('useAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
- const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
+ const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
version.value = ''
updateAvailable.value = null
latestVersion.value = null
+ bookDockPath.value = ''
isLoading.value = false
error.value = null
})
it('starts in idle state before loadAppInfo is called', () => {
- const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
+ const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
expect(version.value).toBe('')
expect(updateAvailable.value).toBeNull()
expect(latestVersion.value).toBeNull()
+ expect(bookDockPath.value).toBe('')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
@@ -147,4 +149,45 @@ describe('useAppInfo', () => {
expect(mockApi).toHaveBeenCalledOnce()
})
+
+ it('populates bookDockPath from API response', async () => {
+ mockApi.mockResolvedValue(
+ makeOkResponse({
+ version: 'v1.2.3',
+ updateAvailable: false,
+ latestVersion: 'v1.2.3',
+ bookDockPath: '/data/book-dock',
+ }),
+ )
+
+ const { bookDockPath, loadAppInfo } = useAppInfo()
+ await loadAppInfo()
+
+ expect(bookDockPath.value).toBe('/data/book-dock')
+ })
+
+ it('defaults bookDockPath to empty string when field is absent from response', async () => {
+ mockApi.mockResolvedValue(
+ makeOkResponse({
+ version: 'v1.2.3',
+ updateAvailable: false,
+ latestVersion: 'v1.2.3',
+ }),
+ )
+
+ const { bookDockPath, loadAppInfo } = useAppInfo()
+ await loadAppInfo()
+
+ expect(bookDockPath.value).toBe('')
+ })
+
+ it('does not change bookDockPath on API error', async () => {
+ mockApi.mockRejectedValue(new Error('fail'))
+
+ const { bookDockPath, loadAppInfo } = useAppInfo()
+ bookDockPath.value = '/data/book-dock'
+ await loadAppInfo()
+
+ expect(bookDockPath.value).toBe('/data/book-dock')
+ })
})
diff --git a/client/src/features/settings/composables/useAppInfo.ts b/client/src/features/settings/composables/useAppInfo.ts
index 0d998893e..0b84ab9bd 100644
--- a/client/src/features/settings/composables/useAppInfo.ts
+++ b/client/src/features/settings/composables/useAppInfo.ts
@@ -7,6 +7,7 @@ import { api } from '@/lib/api'
const version = ref('')
const updateAvailable = ref
(null)
const latestVersion = ref(null)
+const bookDockPath = ref('')
const isLoading = ref(false)
const error = ref(null)
@@ -22,6 +23,7 @@ export function useAppInfo() {
version.value = data.version
updateAvailable.value = data.updateAvailable
latestVersion.value = data.latestVersion
+ bookDockPath.value = data.bookDockPath ?? ''
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load app info'
} finally {
@@ -29,5 +31,5 @@ export function useAppInfo() {
}
}
- return { version, updateAvailable, latestVersion, isLoading, error, loadAppInfo }
+ return { version, updateAvailable, latestVersion, bookDockPath, isLoading, error, loadAppInfo }
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 9fc945f8d..b19ed210f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,7 @@ services:
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/packages/types/src/app-info.ts b/packages/types/src/app-info.ts
index 8fa9d232c..393c3c061 100644
--- a/packages/types/src/app-info.ts
+++ b/packages/types/src/app-info.ts
@@ -2,4 +2,5 @@ export interface AppInfoResponse {
version: string;
updateAvailable: boolean | null;
latestVersion: string | null;
+ bookDockPath: string;
}
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 4b21113e6..529a48679 100644
--- a/server/src/modules/app-info/app-info.service.test.ts
+++ b/server/src/modules/app-info/app-info.service.test.ts
@@ -5,8 +5,14 @@ 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'): jest.Mocked {
- return { get: vi.fn().mockReturnValue(version) } as unknown as jest.Mocked;
+function makeConfig(version = 'v1.2.3', appDataPath = '/data'): jest.Mocked {
+ return {
+ get: vi.fn().mockImplementation((key: string) => {
+ if (key === 'storage.appDataPath') return appDataPath;
+ if (key === 'app.version') return version;
+ return undefined;
+ }),
+ } as unknown as jest.Mocked;
}
function makeAppSettings(enabled = true): jest.Mocked {
@@ -54,6 +60,28 @@ describe('AppInfoService', () => {
expect(info.updateAvailable).toBeNull();
expect(info.latestVersion).toBeNull();
});
+
+ it('returns bookDockPath as appDataPath/book-dock', () => {
+ const service = new AppInfoService(makeConfig('v1.0.0', '/custom/data'), makeAppSettings());
+ expect(service.getAppInfo().bookDockPath).toBe('/custom/data/book-dock');
+ });
+
+ it('falls back to /data/book-dock when storage.appDataPath is not configured', () => {
+ const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
+ const service = new AppInfoService(config, makeAppSettings());
+ expect(service.getAppInfo().bookDockPath).toBe('/data/book-dock');
+ });
+
+ it('includes bookDockPath in full response shape', () => {
+ const service = new AppInfoService(makeConfig('v1.2.3', '/app/data'), makeAppSettings());
+ const info = service.getAppInfo();
+ expect(info).toMatchObject({
+ version: 'v1.2.3',
+ updateAvailable: null,
+ latestVersion: null,
+ bookDockPath: '/app/data/book-dock',
+ });
+ });
});
describe('onApplicationBootstrap', () => {
diff --git a/server/src/modules/app-info/app-info.service.ts b/server/src/modules/app-info/app-info.service.ts
index 6d23aec9a..56483cfc8 100644
--- a/server/src/modules/app-info/app-info.service.ts
+++ b/server/src/modules/app-info/app-info.service.ts
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
+import { join } from 'path';
import type { AppInfoResponse } from '@bookorbit/types';
@@ -30,10 +31,12 @@ export class AppInfoService implements OnApplicationBootstrap {
}
getAppInfo(): AppInfoResponse {
+ const appDataPath = this.config.get('storage.appDataPath') ?? '/data';
return {
version: this.config.get('app.version') ?? 'Local build',
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
+ bookDockPath: join(appDataPath, 'book-dock'),
};
}
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 0142b0eec..ea6043ac4 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
@@ -1,10 +1,12 @@
import { BadRequestException, Logger } from '@nestjs/common';
import { Readable } from 'stream';
-import { stat } from 'fs/promises';
+import { mkdir, realpath, stat } from 'fs/promises';
import { BookDockIngestService } from './book-dock-ingest.service';
vi.mock('fs/promises', () => ({
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ realpath: vi.fn().mockImplementation((p: string) => Promise.resolve(p)),
stat: vi.fn(),
}));
@@ -77,6 +79,17 @@ describe('BookDockIngestService', () => {
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
});
+ it('onApplicationBootstrap resolves bookDockPath to its real path', async () => {
+ const { service } = makeService();
+ vi.mocked(realpath).mockResolvedValue('/real/books/book-dock');
+
+ await service.onApplicationBootstrap();
+
+ expect(mkdir).toHaveBeenCalledWith('/books/book-dock', { recursive: true });
+ expect(realpath).toHaveBeenCalledWith('/books/book-dock');
+ expect((service as any).bookDockPath).toBe('/real/books/book-dock');
+ });
+
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 80695892b..2583bc136 100644
--- a/server/src/modules/book-dock/book-dock-ingest.service.ts
+++ b/server/src/modules/book-dock/book-dock-ingest.service.ts
@@ -1,7 +1,7 @@
-import { BadRequestException, Injectable, Logger } from '@nestjs/common';
+import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { basename, extname, join } from 'path';
-import { stat } from 'fs/promises';
+import { mkdir, realpath, stat } from 'fs/promises';
import { Readable } from 'stream';
import type { BookDockMetadata } from '@bookorbit/types';
@@ -15,9 +15,9 @@ import { BookDockEventsService, BOOK_DOCK_FILE_INGESTED } from './book-dock-even
import { BookDockGateway } from './book-dock.gateway';
@Injectable()
-export class BookDockIngestService {
+export class BookDockIngestService implements OnApplicationBootstrap {
private readonly logger = new Logger(BookDockIngestService.name);
- private readonly bookDockPath: string;
+ private bookDockPath: string;
constructor(
private readonly config: ConfigService,
@@ -34,6 +34,11 @@ export class BookDockIngestService {
this.bookDockPath = join(appDataPath, 'book-dock');
}
+ async onApplicationBootstrap(): Promise {
+ await mkdir(this.bookDockPath, { recursive: true });
+ this.bookDockPath = await realpath(this.bookDockPath);
+ }
+
async ingestUpload(rawFilename: string, fileStream: Readable, uploadedBy?: number): Promise {
const filename = this.validator.sanitizeFilename(rawFilename);
const ext = extname(filename).toLowerCase().slice(1);
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 911eefb48..ade249895 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
@@ -9,6 +9,7 @@ vi.mock('../scanner/lib/stability', () => ({
vi.mock('fs/promises', () => ({
mkdir: vi.fn(),
readdir: vi.fn(),
+ realpath: vi.fn().mockImplementation((p: string) => Promise.resolve(p)),
unlink: vi.fn(),
}));
@@ -16,7 +17,7 @@ vi.mock('@parcel/watcher', () => ({
subscribe: vi.fn(),
}));
-import { mkdir, readdir, unlink } from 'fs/promises';
+import { mkdir, readdir, realpath, unlink } from 'fs/promises';
import { subscribe } from '@parcel/watcher';
import { isPrimaryFormat } from '../scanner/lib/classify';
@@ -65,6 +66,7 @@ describe('BookDockWatcherService', () => {
await (service as any).startWatcher();
expect(mkdir).toHaveBeenCalledWith('/data/book-dock', { recursive: true });
+ expect(realpath).toHaveBeenCalledWith('/data/book-dock');
expect(subscribe).toHaveBeenCalledWith('/data/book-dock', expect.any(Function));
});
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 5877b4006..dbd36a3c8 100644
--- a/server/src/modules/book-dock/book-dock-watcher.service.ts
+++ b/server/src/modules/book-dock/book-dock-watcher.service.ts
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Dirent } from 'fs';
-import { readdir, unlink } from 'fs/promises';
+import { mkdir, readdir, realpath, unlink } from 'fs/promises';
import { join } from 'path';
import type { AsyncSubscription } from '@parcel/watcher';
@@ -19,7 +19,7 @@ const COVERS_DIR = 'covers';
@Injectable()
export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(BookDockWatcherService.name);
- private readonly bookDockPath: string;
+ private bookDockPath: string;
private subscription: AsyncSubscription | null = null;
private readonly pendingTimers = new Map; type: EventType }>();
@@ -54,8 +54,8 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
private async startWatcher(): Promise {
try {
- const { mkdir } = await import('fs/promises');
await mkdir(this.bookDockPath, { recursive: true });
+ this.bookDockPath = await realpath(this.bookDockPath);
const { subscribe } = await import('@parcel/watcher');
this.subscription = await subscribe(this.bookDockPath, (err, events) => {