mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(book-dock): expose Book Dock folder as a configurable drop zone (#64)
* feat(docker): expose Book Dock folder as a configurable drop zone Add support for an external book drop folder that users can bind-mount into the container via the BOOK_DROP_HOST_PATH env var. Files copied or moved into this folder are automatically picked up and ingested by the existing Book Dock file watcher. - docker-compose.yml: add BOOK_DROP_HOST_PATH volume override so the book-dock path is separately bind-mountable - .env.example: document BOOK_DROP_HOST_PATH with an example NAS path - AppInfoResponse: add bookDockPath so the UI can show the container-internal path admins need to bind-mount - AppInfoService: compute and return bookDockPath from storage config - BookDockSettings: display drop folder path with BOOK_DROP_HOST_PATH hint - useAppInfo: expose bookDockPath ref from the app-info API response - README: document the optional drop folder in the quickstart section When BOOK_DROP_HOST_PATH is unset, the volume defaults to ./data/app/book-dock, preserving full backward compatibility. No watcher code changes required - the watcher already watches the correct container-internal path. Closes #58 * fix(book-dock): resolve symlink paths so rescan stays idempotent BookDockWatcherService and BookDockIngestService were building bookDockPath with path.join(), which preserves symlinks. `@parcel/watcher` resolves symlinks internally when reporting events (using realpath), so the absolutePath values stored by watcher events and the paths computed by walkAndIngest during rescan never matched. This caused findByAbsolutePath to always return undefined on rescan, creating duplicate DB records for every file already tracked via a watcher event. Fix: both services now call `realpath(bookDockPath)` in onApplicationBootstrap (after mkdir), updating the stored path to the canonical real path that the watcher also uses. Rescan is now idempotent regardless of symlinks in the data directory. Verified by E2E test: 3 consecutive rescans produce zero new records.
This commit is contained in:
@@ -8,6 +8,12 @@ APP_PORT=3000
|
|||||||
# Point this at your library root; use an absolute path on NAS/Portainer installs.
|
# Point this at your library root; use an absolute path on NAS/Portainer installs.
|
||||||
BOOKS_HOST_PATH=./books
|
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.
|
# Container runtime UID/GID for files written to app-managed data folders.
|
||||||
# Most users can leave these defaults.
|
# Most users can leave these defaults.
|
||||||
PUID=1000
|
PUID=1000
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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
|
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:
|
Then start:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
|||||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||||
|
import { useAppInfo } from './composables/useAppInfo'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||||
const autoFetch = ref(true)
|
const autoFetch = ref(true)
|
||||||
@@ -19,6 +20,7 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
const { libraries, fetchLibraries } = useLibraries()
|
const { libraries, fetchLibraries } = useLibraries()
|
||||||
|
const { bookDockPath, loadAppInfo } = useAppInfo()
|
||||||
|
|
||||||
const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value))
|
const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value))
|
||||||
const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? [])
|
const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? [])
|
||||||
@@ -26,7 +28,7 @@ const isThresholdApplicable = computed(() => autoFinalizeMetadataMode.value !==
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const settings: { key: string; value: string }[] = await res.json()
|
const settings: { key: string; value: string }[] = await res.json()
|
||||||
const get = (key: string) => settings.find((s) => s.key === key)?.value
|
const get = (key: string) => settings.find((s) => s.key === key)?.value
|
||||||
@@ -151,6 +153,30 @@ async function onMetadataModeChange(event: Event) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-5 md:mt-0 space-y-6">
|
<div v-else class="mt-5 md:mt-0 space-y-6">
|
||||||
|
<div>
|
||||||
|
<p class="settings-group-label">Drop folder</p>
|
||||||
|
<div class="mt-4 border border-border rounded-lg overflow-hidden shadow-xs">
|
||||||
|
<div class="px-4 py-3.5 bg-card md:px-5 md:py-4">
|
||||||
|
<p class="settings-label">Container path</p>
|
||||||
|
<p class="settings-hint mb-2">
|
||||||
|
Copy or move book files into this folder and they will be automatically picked up and processed by Book Dock. Subdirectories are
|
||||||
|
supported.
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
v-if="bookDockPath"
|
||||||
|
data-testid="book-dock-path"
|
||||||
|
class="block mt-1 px-3 py-2 rounded-md bg-muted text-foreground text-xs font-mono break-all select-all"
|
||||||
|
>{{ 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="settings-group-label">Metadata</p>
|
<p class="settings-group-label">Metadata</p>
|
||||||
|
|
||||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||||
|
|||||||
@@ -25,19 +25,21 @@ function makeErrorResponse(status: number): Response {
|
|||||||
describe('useAppInfo', () => {
|
describe('useAppInfo', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
|
const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
|
||||||
version.value = ''
|
version.value = ''
|
||||||
updateAvailable.value = null
|
updateAvailable.value = null
|
||||||
latestVersion.value = null
|
latestVersion.value = null
|
||||||
|
bookDockPath.value = ''
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts in idle state before loadAppInfo is called', () => {
|
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(version.value).toBe('')
|
||||||
expect(updateAvailable.value).toBeNull()
|
expect(updateAvailable.value).toBeNull()
|
||||||
expect(latestVersion.value).toBeNull()
|
expect(latestVersion.value).toBeNull()
|
||||||
|
expect(bookDockPath.value).toBe('')
|
||||||
expect(isLoading.value).toBe(false)
|
expect(isLoading.value).toBe(false)
|
||||||
expect(error.value).toBeNull()
|
expect(error.value).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -147,4 +149,45 @@ describe('useAppInfo', () => {
|
|||||||
|
|
||||||
expect(mockApi).toHaveBeenCalledOnce()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { api } from '@/lib/api'
|
|||||||
const version = ref('')
|
const version = ref('')
|
||||||
const updateAvailable = ref<boolean | null>(null)
|
const updateAvailable = ref<boolean | null>(null)
|
||||||
const latestVersion = ref<string | null>(null)
|
const latestVersion = ref<string | null>(null)
|
||||||
|
const bookDockPath = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function useAppInfo() {
|
|||||||
version.value = data.version
|
version.value = data.version
|
||||||
updateAvailable.value = data.updateAvailable
|
updateAvailable.value = data.updateAvailable
|
||||||
latestVersion.value = data.latestVersion
|
latestVersion.value = data.latestVersion
|
||||||
|
bookDockPath.value = data.bookDockPath ?? ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to load app info'
|
error.value = e instanceof Error ? e.message : 'Failed to load app info'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -29,5 +31,5 @@ export function useAppInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { version, updateAvailable, latestVersion, isLoading, error, loadAppInfo }
|
return { version, updateAvailable, latestVersion, bookDockPath, isLoading, error, loadAppInfo }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${BOOKS_HOST_PATH:-./books}:/books
|
- ${BOOKS_HOST_PATH:-./books}:/books
|
||||||
- ./data/app:/data
|
- ./data/app:/data
|
||||||
|
- ${BOOK_DROP_HOST_PATH:-./data/app/book-dock}:/data/book-dock
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export interface AppInfoResponse {
|
|||||||
version: string;
|
version: string;
|
||||||
updateAvailable: boolean | null;
|
updateAvailable: boolean | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
|
bookDockPath: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import { AppSettingsService } from '../app-settings/app-settings.service';
|
|||||||
import { GITHUB_RELEASES_API } from './app-info.constants';
|
import { GITHUB_RELEASES_API } from './app-info.constants';
|
||||||
import { AppInfoService } from './app-info.service';
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
|
function makeConfig(version = 'v1.2.3', appDataPath = '/data'): jest.Mocked<ConfigService> {
|
||||||
return { get: vi.fn().mockReturnValue(version) } as unknown as jest.Mocked<ConfigService>;
|
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<ConfigService>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAppSettings(enabled = true): jest.Mocked<AppSettingsService> {
|
function makeAppSettings(enabled = true): jest.Mocked<AppSettingsService> {
|
||||||
@@ -54,6 +60,28 @@ describe('AppInfoService', () => {
|
|||||||
expect(info.updateAvailable).toBeNull();
|
expect(info.updateAvailable).toBeNull();
|
||||||
expect(info.latestVersion).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', () => {
|
describe('onApplicationBootstrap', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
import type { AppInfoResponse } from '@bookorbit/types';
|
import type { AppInfoResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ export class AppInfoService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAppInfo(): AppInfoResponse {
|
getAppInfo(): AppInfoResponse {
|
||||||
|
const appDataPath = this.config.get<string>('storage.appDataPath') ?? '/data';
|
||||||
return {
|
return {
|
||||||
version: this.config.get<string>('app.version') ?? 'Local build',
|
version: this.config.get<string>('app.version') ?? 'Local build',
|
||||||
updateAvailable: this.updateAvailable,
|
updateAvailable: this.updateAvailable,
|
||||||
latestVersion: this.latestVersion,
|
latestVersion: this.latestVersion,
|
||||||
|
bookDockPath: join(appDataPath, 'book-dock'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { BadRequestException, Logger } from '@nestjs/common';
|
import { BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { stat } from 'fs/promises';
|
import { mkdir, realpath, stat } from 'fs/promises';
|
||||||
|
|
||||||
import { BookDockIngestService } from './book-dock-ingest.service';
|
import { BookDockIngestService } from './book-dock-ingest.service';
|
||||||
|
|
||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
|
realpath: vi.fn().mockImplementation((p: string) => Promise.resolve(p)),
|
||||||
stat: vi.fn(),
|
stat: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -77,6 +79,17 @@ describe('BookDockIngestService', () => {
|
|||||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
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', () => {
|
describe('ingestUpload', () => {
|
||||||
it('succeeds and returns the row ID', async () => {
|
it('succeeds and returns the row ID', async () => {
|
||||||
const { service, validator, storage, repo } = makeService();
|
const { service, validator, storage, repo } = makeService();
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { basename, extname, join } from 'path';
|
import { basename, extname, join } from 'path';
|
||||||
import { stat } from 'fs/promises';
|
import { mkdir, realpath, stat } from 'fs/promises';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import type { BookDockMetadata } from '@bookorbit/types';
|
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';
|
import { BookDockGateway } from './book-dock.gateway';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookDockIngestService {
|
export class BookDockIngestService implements OnApplicationBootstrap {
|
||||||
private readonly logger = new Logger(BookDockIngestService.name);
|
private readonly logger = new Logger(BookDockIngestService.name);
|
||||||
private readonly bookDockPath: string;
|
private bookDockPath: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@@ -34,6 +34,11 @@ export class BookDockIngestService {
|
|||||||
this.bookDockPath = join(appDataPath, 'book-dock');
|
this.bookDockPath = join(appDataPath, 'book-dock');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
await mkdir(this.bookDockPath, { recursive: true });
|
||||||
|
this.bookDockPath = await realpath(this.bookDockPath);
|
||||||
|
}
|
||||||
|
|
||||||
async ingestUpload(rawFilename: string, fileStream: Readable, uploadedBy?: number): Promise<number> {
|
async ingestUpload(rawFilename: string, fileStream: Readable, uploadedBy?: number): Promise<number> {
|
||||||
const filename = this.validator.sanitizeFilename(rawFilename);
|
const filename = this.validator.sanitizeFilename(rawFilename);
|
||||||
const ext = extname(filename).toLowerCase().slice(1);
|
const ext = extname(filename).toLowerCase().slice(1);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ vi.mock('../scanner/lib/stability', () => ({
|
|||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
mkdir: vi.fn(),
|
mkdir: vi.fn(),
|
||||||
readdir: vi.fn(),
|
readdir: vi.fn(),
|
||||||
|
realpath: vi.fn().mockImplementation((p: string) => Promise.resolve(p)),
|
||||||
unlink: vi.fn(),
|
unlink: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ vi.mock('@parcel/watcher', () => ({
|
|||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { mkdir, readdir, unlink } from 'fs/promises';
|
import { mkdir, readdir, realpath, unlink } from 'fs/promises';
|
||||||
import { subscribe } from '@parcel/watcher';
|
import { subscribe } from '@parcel/watcher';
|
||||||
|
|
||||||
import { isPrimaryFormat } from '../scanner/lib/classify';
|
import { isPrimaryFormat } from '../scanner/lib/classify';
|
||||||
@@ -65,6 +66,7 @@ describe('BookDockWatcherService', () => {
|
|||||||
await (service as any).startWatcher();
|
await (service as any).startWatcher();
|
||||||
|
|
||||||
expect(mkdir).toHaveBeenCalledWith('/data/book-dock', { recursive: true });
|
expect(mkdir).toHaveBeenCalledWith('/data/book-dock', { recursive: true });
|
||||||
|
expect(realpath).toHaveBeenCalledWith('/data/book-dock');
|
||||||
expect(subscribe).toHaveBeenCalledWith('/data/book-dock', expect.any(Function));
|
expect(subscribe).toHaveBeenCalledWith('/data/book-dock', expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Dirent } from 'fs';
|
import { Dirent } from 'fs';
|
||||||
import { readdir, unlink } from 'fs/promises';
|
import { mkdir, readdir, realpath, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import type { AsyncSubscription } from '@parcel/watcher';
|
import type { AsyncSubscription } from '@parcel/watcher';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const COVERS_DIR = 'covers';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(BookDockWatcherService.name);
|
private readonly logger = new Logger(BookDockWatcherService.name);
|
||||||
private readonly bookDockPath: string;
|
private bookDockPath: string;
|
||||||
private subscription: AsyncSubscription | null = null;
|
private subscription: AsyncSubscription | null = null;
|
||||||
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
|
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
|
|||||||
|
|
||||||
private async startWatcher(): Promise<void> {
|
private async startWatcher(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { mkdir } = await import('fs/promises');
|
|
||||||
await mkdir(this.bookDockPath, { recursive: true });
|
await mkdir(this.bookDockPath, { recursive: true });
|
||||||
|
this.bookDockPath = await realpath(this.bookDockPath);
|
||||||
|
|
||||||
const { subscribe } = await import('@parcel/watcher');
|
const { subscribe } = await import('@parcel/watcher');
|
||||||
this.subscription = await subscribe(this.bookDockPath, (err, events) => {
|
this.subscription = await subscribe(this.bookDockPath, (err, events) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user