diff --git a/.env.example b/.env.example index 00cd4384b..8f77059cd 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ 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 diff --git a/README.md b/README.md index 1b1655f66..e0d1279aa 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ BookOrbit is a self-hosted digital library and reading platform. Organize and re **Kobo and KOReader sync:** Auto-push books to Kobo; two-way reading progress sync via KOReader over OPDS. -**OPDS, email delivery, and Book Dock:** OPDS for compatible apps, Send-to-Kindle via email, and browser drag-and-drop uploads. +**OPDS, email delivery, and Book Dock:** OPDS for compatible apps, Send-to-Kindle via email, browser drag-and-drop uploads, and a configurable drop folder for automated ingestion. **Multi-user with multi-provider OIDC/SSO:** Granular per-user permissions, isolated reading data, and simultaneous support for Authentik, Keycloak, Authelia, etc. @@ -92,6 +92,18 @@ 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 7617c9d8b..b56d074e5 100644 --- a/client/src/features/settings/BookDockSettings.vue +++ b/client/src/features/settings/BookDockSettings.vue @@ -7,6 +7,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import SettingsPageHeader from './SettingsPageHeader.vue' import { api } from '@/lib/api' import { useLibraries } from '@/features/library/composables/useLibraries' +import { useAppInfo } from './composables/useAppInfo' const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) const autoFetch = ref(true) @@ -19,6 +20,7 @@ const loading = ref(true) const saving = ref(false) const { libraries, fetchLibraries } = useLibraries() +const { bookDockPath, loadAppInfo } = useAppInfo() const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value)) const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? []) @@ -26,7 +28,7 @@ const isThresholdApplicable = computed(() => autoFinalizeMetadataMode.value !== onMounted(async () => { try { - const [res] = await Promise.all([api('/api/v1/app-settings'), fetchLibraries()]) + const [res] = await Promise.all([api('/api/v1/app-settings'), fetchLibraries(), loadAppInfo()]) if (res.ok) { const settings: { key: string; value: string }[] = await res.json() const get = (key: string) => settings.find((s) => s.key === key)?.value @@ -151,6 +153,30 @@ async function onMetadataModeChange(event: Event) {
+
+

Drop folder

+
+
+

Container path

+

+ Copy or move book files into this folder and they will be automatically picked up and processed by Book Dock. Subdirectories are + supported. +

+ {{ 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. +

+
+
+
+

Metadata

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) => {